Cutover migration, job-id convention, jobs boundary, two-step deploy
This page collects four related "shape of the world" decisions that otherwise float around the repo without a canonical home.
Cutover from the legacy app
The frozen Astro/React predecessor lives in careervector-corbet-ch-legacy
and serves old.careervector.corbet.ch as a read-only artefact. The
cutover from that codebase into the current SvelteKit + D1 + relay stack
was a one-time migration, not an ongoing sync. The migration scripts
under ui/scripts/ exist to:
import-workspaces-from-existing.ts— pull a workspace's content from the legacy data store and land it in the current D1 schema.select-cutover-workspace.ts— pick a candidate workspace, with the guards needed for a one-shot migration.prove-cutover-migrated-workspace.ts— verify a migrated workspace matches its legacy source on the dimensions that matter (job count, custom columns, profile content).
Every one of those scripts is paired with a .test.ts because the
migration ran once and any future "small re-run" carries the risk of
silent regression. The tests are the safety net for that re-run.
The migration is not expected to run again on the legacy data, but the shape it left behind is permanent: every cutover workspace shares the same D1 schema as a freshly created workspace. There is no "legacy mode" branch in the production code.
Workspace job-id convention
remint-workspace-job-ids.ts enforces the rule that job IDs are
deterministic within a workspace: (workspaceId, n) where n is a
per-workspace counter. The job ID is not a global UUID, so URLs are
short and pasted job-detail links are readable.
This convention has two load-bearing consequences:
- A workspace's nth job has a stable, share-friendly ID across reloads, collaborators, and exports.
- A job ID is meaningless outside its workspace. Operations that reference
a job always carry
(workspaceId, jobId); never the bare jobId.
Workspace ↔ jobs boundary
A workspace's private state (custom columns, scoring config, kanban
phases, profile, notes, BYOK chains) lives inside workspace_sub_doc rows
and is scoped strictly to that workspace. The job ads themselves — the
upstream URL, scraped description, employer, role taxonomy — are part of
the broader CareerVector / JobCache shared ad/role index and may be referenced
across workspaces.
The split is enforced at the API layer (api/src/routers/workspaces.ts
and the relay walker) and is the reason a workspace cleanup script like
cleanup-raw-jobs-boundary.ts exists: it removes per-workspace job rows
that were accidentally hydrated with shared-index fields. The shared index
remains; only the per-workspace mirror is pruned.
See jobcache-shared-data and private-workspace-shared-ad-role-boundary
for the JobCache side of the same boundary.
Two-step deploy: upload, then promote
The UI Worker deploy is two steps, not one. The upload step pushes the new bundle as a named version without serving any production traffic; the promote step flips the production URL onto that version. The split exists so:
- A bad bundle that imports a missing module fails at upload, with the current production still serving.
- A deploy script can re-target an old version trivially if the new one starts erroring after promotion.
- The CI gate has a hold-point between "code looks good" and "users see
it" without needing a separate staging account (see
cf-workers-quota-policy.mdfor why a real staging Worker is not free).
The deploy command is documented in ui/README.md. The same two-step
pattern is followed by every other CareerVector Worker that has a public
URL.