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.md for 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.

Source: wiki/content/canon/cv-cutover-migration.md