@fullstackhouse/open-mercato-data-sync-durable 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -95,6 +95,7 @@ function outcomeOf(recorder, runId, runStatus) {
95
95
  if (recorder.stopReason() === "budget") return "budget";
96
96
  if (recorder.stopReason() === "cancelled") return "cancelled";
97
97
  if (!captured) {
98
+ if (runStatus === "cancelled") return "cancelled";
98
99
  if (recorder.committedBatches() > 0 && runStatus && runStatus !== "running" && runStatus !== "pending") {
99
100
  throw new SeamBrokenError(runId, `the run reached "${runStatus}" without the adopter recording it`);
100
101
  }
@@ -108,7 +109,7 @@ async function mirrorRunStatus(tx, runId, status, errorMessage) {
108
109
  const result = await tx.query(
109
110
  `update sync_runs
110
111
  set status = $2, last_error = $3, updated_at = now()
111
- where id = $1 and deleted_at is null and status in ('pending','running')`,
112
+ where id = $1 and deleted_at is null and (status in ('pending','running') or status = $2)`,
112
113
  [runId, status, errorMessage]
113
114
  );
114
115
  return { matched: result.rowCount };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/engine/durable-run.ts"],
4
- "sourcesContent": ["// How a `data_sync` run becomes durable work without core's engine being changed or copied.\n//\n// Core's engine already survives being displaced by another worker: it asks a cancellation\n// question at every batch boundary, and it stays silent when a terminal write is refused. A\n// durable adopter is exactly a worker in that position, so all this does is answer those two\n// questions differently \u2014 and route the cursor commit through the lease fence.\n//\n// See docs/adr/0004: the original plan was to fork the batch loop. Reading the engine showed\n// the seams were already there.\n\nimport type { SliceContext, SliceOutcome, SqlExecutor } from '@fullstackhouse/open-mercato-durable-work'\n\nimport { SeamBrokenError } from '../modules/data_sync/lib/version-guard'\n\nexport type SyncScope = { tenantId: string; organizationId: string | null; userId?: string | null }\nexport type SyncTerminalStatus = 'completed' | 'failed' | 'cancelled'\n\n/** The methods this decorator *wraps*, not the whole service: core's engine calls plenty more,\n * and they are passed through untouched. Anything not listed here is delegated. */\nexport type SyncRunServiceLike = {\n [method: string]: unknown\n getRun(runId: string, scope: SyncScope): Promise<{ status: string; progressJobId?: string | null } | null>\n markStatus(runId: string, status: string, scope: SyncScope, error?: string): Promise<unknown>\n commitBatchProgress(\n runId: string,\n delta: Record<string, unknown>,\n cursor: unknown,\n scope: SyncScope,\n options?: Record<string, unknown>,\n ): Promise<unknown>\n}\n\nexport type ProgressServiceLike = {\n [method: string]: unknown\n isCancellationRequested(progressJobId: string, tenantId: string, organizationId: string | null): Promise<boolean>\n}\n\n/** What core's engine tried to do, captured rather than applied. */\nexport type CapturedOutcome = { status: SyncTerminalStatus; error?: string } | null\n\nexport type SliceRecorder = {\n runService: SyncRunServiceLike\n progressService: ProgressServiceLike\n /** What the engine tried to finalize the run as, if anything. */\n captured(): CapturedOutcome\n /** Why the slice stopped early, when it did. */\n stopReason(): 'budget' | 'cancelled' | null\n /** Batches whose cursor this slice committed. Zero means the slice made no progress. */\n committedBatches(): number\n}\n\n/**\n * Wraps core's two services for the duration of one slice.\n *\n * @param ctx the slice this recorder belongs to; its lease is what fences the cursor commits\n */\nexport function recordSlice(\n ctx: SliceContext,\n runService: SyncRunServiceLike,\n progressService: ProgressServiceLike,\n): SliceRecorder {\n let captured: CapturedOutcome = null\n let stopReason: 'budget' | 'cancelled' | null = null\n let committed = 0\n\n return {\n captured: () => captured,\n stopReason: () => stopReason,\n committedBatches: () => committed,\n\n // Spread, not rebuilt.\n //\n // Core's engine calls far more than the three methods decorated here \u2014 `startJob`,\n // `updateProgress`, `touchJobHeartbeat`, `markCancelled` and others on the progress\n // service, and more of the run service besides. An object carrying only the overrides\n // looks fine to TypeScript through a structural type and then fails at the first\n // undecorated call, mid-run.\n runService: {\n ...runService,\n\n /**\n * Records a terminal transition instead of performing it, and answers with the run\n * unchanged.\n *\n * Core's `finalizeRun` compares what comes back to what it asked for, and stays silent\n * when they differ \u2014 the branch it has for \"another worker already finalized this\". So\n * this both captures the outcome and suppresses the progress write, the operational log\n * and the lifecycle event. The run's terminal status is then written by `onTransition`,\n * in the same transaction as the job's own; the three suppressed side effects are\n * replayed by `replayFinalize` after that commit, since an event inside a transaction\n * that can still roll back is a lie waiting to happen.\n */\n async markStatus(runId, status, scope, error) {\n if (status === 'completed' || status === 'failed' || status === 'cancelled') {\n captured = { status, error }\n return runService.getRun(runId, scope)\n }\n return runService.markStatus(runId, status, scope, error)\n },\n\n /**\n * Commits the batch cursor under the lease.\n *\n * This is the write that must not outlive the right to make it: a worker whose lease\n * expired mid-batch would otherwise advance the cursor of a run another worker is now\n * driving, and the two would interleave over one stream.\n */\n async commitBatchProgress(runId, delta, cursor, scope, options) {\n const result = await ctx.fencedWrite(async () => runService.commitBatchProgress(runId, delta, cursor, scope, options))\n committed += 1\n // A committed unit of work resets the failure and orphan budgets: a run that is making\n // progress has not earned the suspicion those counters represent, however many times\n // it was interrupted getting there.\n await ctx.heartbeat({ committed: true })\n return result\n },\n },\n\n progressService: {\n ...progressService,\n\n /**\n * Mirrors the run's counters onto the durable job as they move.\n *\n * Core reports progress here after every committed batch. Without this the operator API\n * shows a job that is plainly running with `0 of null` processed, and the one place\n * somebody looks to see whether a multi-day backfill is advancing tells them nothing.\n */\n async updateProgress(progressJobId: string, patch: { processedCount?: number; totalCount?: number | null }, scope: unknown) {\n await ctx\n .heartbeat({ processedCount: patch?.processedCount, totalCount: patch?.totalCount ?? null })\n .catch(() => undefined)\n const inner = progressService.updateProgress as\n | ((id: string, patch: unknown, scope: unknown) => Promise<unknown>)\n | undefined\n return inner?.call(progressService, progressJobId, patch, scope)\n },\n\n /**\n * Core asks this once per batch and stops the stream cleanly when it is true. That makes\n * it the slice's hand-back point as well as its cancellation point \u2014 the two need the\n * same clean stop, and differ only in what happens afterwards.\n */\n async isCancellationRequested(progressJobId, tenantId, organizationId) {\n if (await progressService.isCancellationRequested(progressJobId, tenantId, organizationId)) {\n stopReason = 'cancelled'\n return true\n }\n if (ctx.signal.aborted || ctx.shouldYield()) {\n stopReason = 'budget'\n return true\n }\n return false\n },\n },\n }\n}\n\n/** A run that ended `failed` inside core's engine. Thrown so the durable error taxonomy and the\n * retry budget apply to it, instead of the run being thrown away at the first blip. */\nexport class SyncRunFailedError extends Error {\n readonly durableErrorClass = 'transient' as const\n constructor(\n readonly runId: string,\n message: string,\n ) {\n super(message)\n this.name = 'SyncRunFailedError'\n }\n}\n\n/**\n * Turns what the recorder saw into the outcome the mechanism understands.\n *\n * @param runStatus the run's status after the slice, used only to tell \"already finished\" from\n * \"core finalized this itself\" \u2014 see the seam check below\n */\nexport function outcomeOf(recorder: SliceRecorder, runId: string, runStatus?: string): SliceOutcome {\n const captured = recorder.captured()\n\n if (recorder.stopReason() === 'budget') return 'budget'\n if (recorder.stopReason() === 'cancelled') return 'cancelled'\n\n if (!captured) {\n // Nothing was recorded. Two very different situations look the same from here, and telling\n // them apart is the whole point:\n //\n // - the run was already terminal, or gone, before this slice started \u2014 nothing to do\n // - core finalized the run itself, without going through the decorated `markStatus`\n //\n // The second means the seam this package rests on has moved (ADR 0004), and reporting it\n // as success would leave a job that says `completed` beside a run that says `failed`.\n // Committed batches are what distinguishes them: a slice that did real work and then found\n // the run terminal without recording anything did not simply arrive late.\n if (recorder.committedBatches() > 0 && runStatus && runStatus !== 'running' && runStatus !== 'pending') {\n throw new SeamBrokenError(runId, `the run reached \"${runStatus}\" without the adopter recording it`)\n }\n return 'drained'\n }\n if (captured.status === 'failed') throw new SyncRunFailedError(runId, captured.error ?? 'Sync run failed')\n if (captured.status === 'cancelled') return 'cancelled'\n return 'drained'\n}\n\n/** Maps a durable terminal state onto the run's own, inside the terminal transaction. */\nexport async function mirrorRunStatus(\n tx: SqlExecutor,\n runId: string,\n status: SyncTerminalStatus,\n errorMessage: string | null,\n): Promise<{ matched: number }> {\n // Fenced on the run still being open, so a run finished by another path is not overwritten \u2014\n // \"mirrored\" means the domain row agrees, and a row that already disagrees for a good reason\n // must not be forced.\n const result = await tx.query(\n `update sync_runs\n set status = $2, last_error = $3, updated_at = now()\n where id = $1 and deleted_at is null and status in ('pending','running')`,\n [runId, status, errorMessage],\n )\n return { matched: result.rowCount }\n}\n\n/** Re-opens a run when an operator re-drives its job. The mirror image of the above. */\nexport async function reopenRun(tx: SqlExecutor, runId: string): Promise<{ matched: number }> {\n const result = await tx.query(\n `update sync_runs\n set status = 'running', last_error = null, updated_at = now()\n where id = $1 and deleted_at is null and status in ('failed','pending','running')`,\n [runId],\n )\n return { matched: result.rowCount }\n}\n\n/**\n * The services core's `finalizeRun` reaches for once the status is written.\n *\n * Named separately from the slice's dependencies because these are needed at a different\n * moment: the slice runs under a lease, this runs after the job's terminal transition has\n * committed, on whatever worker got there.\n */\nexport type FinalizeDeps = {\n progressService: ProgressServiceLike\n integrationLogService: { write(entry: Record<string, unknown>, scope: SyncScope): Promise<unknown> }\n integrationStateService?: {\n upsert(integrationId: string, patch: Record<string, unknown>, scope: SyncScope): Promise<unknown>\n } | null\n /** `adapter.operationalTelemetry === true`, which is core's own gate on the two writes below. */\n operationalTelemetry(integrationId: string): boolean\n emitEvent(name: string, payload: Record<string, unknown>): Promise<void>\n}\n\n/** The run fields core's tail reads. */\nexport type FinalizedRun = {\n id: string\n integrationId: string\n entityType: string\n direction: string\n progressJobId?: string | null\n createdCount?: number\n updatedCount?: number\n skippedCount?: number\n failedCount?: number\n batchesCompleted?: number\n}\n\n/**\n * Everything core's `finalizeRun` does after the status write, done here instead.\n *\n * Core stops at its \"another worker already finalized this\" branch on every durable run \u2014 that\n * is deliberate, and it is what lets the durable transition own the terminal state (see\n * `markStatus` above). But stopping there also skips the three things that came after it: the\n * progress job is never resolved, the operational log never written, and the lifecycle event\n * never emitted. Left unreplayed, a host loses the progress indicator an operator watches, the\n * integration health state, and `data_sync.run.completed` \u2014 which is dispatched to tenant\n * webhooks, so its absence is visible outside the app entirely.\n *\n * This runs after the commit, not inside it: events and enqueues must not sit in a transaction\n * that can still roll back, and the mechanism gives after-commit hooks at-most-once semantics\n * for exactly this. A throw here is logged and dropped rather than retried \u2014 the same standing\n * core's own tail has, where a failed webhook has never un-completed a run.\n */\nexport async function replayFinalize(\n deps: FinalizeDeps,\n run: FinalizedRun,\n status: SyncTerminalStatus,\n errorMessage: string | null,\n scope: SyncScope,\n userId: string | null,\n): Promise<void> {\n const progressScope = { tenantId: scope.tenantId, organizationId: scope.organizationId, userId: userId ?? undefined }\n const enabled = deps.operationalTelemetry(run.integrationId)\n\n if (run.progressJobId) {\n const progress = deps.progressService as unknown as Record<string, ((...args: unknown[]) => Promise<unknown>) | undefined>\n if (status === 'completed') {\n await progress.completeJob?.(\n run.progressJobId,\n {\n resultSummary: {\n createdCount: run.createdCount,\n updatedCount: run.updatedCount,\n skippedCount: run.skippedCount,\n failedCount: run.failedCount,\n batchesCompleted: run.batchesCompleted,\n },\n },\n progressScope,\n )\n } else if (status === 'failed') {\n await progress.failJob?.(run.progressJobId, { errorMessage: errorMessage ?? 'Sync run failed' }, progressScope)\n } else {\n await progress.markCancelled?.(run.progressJobId, progressScope)\n }\n }\n\n const health = status === 'completed' ? 'healthy' : status === 'cancelled' ? 'degraded' : 'unhealthy'\n if (enabled && deps.integrationStateService) {\n await deps.integrationStateService.upsert(\n run.integrationId,\n { lastHealthStatus: health, lastHealthCheckedAt: new Date() },\n scope,\n )\n }\n\n if (enabled) {\n // `code` is core's own field on an integration log row, and core's `finalizeRun` leaves it\n // unset. It is set here because a host that reports error rows outward \u2014 reading `code` as\n // the fingerprint, which the integration log service does \u2014 would otherwise group every\n // durable terminal failure under a generic fallback, losing the one attribute separating a\n // dead sync run from any other integration error.\n const log =\n status === 'completed'\n ? {\n level: 'info',\n code: 'data_sync.run_completed',\n message: 'Sync run completed',\n payload: {\n operationalStatus: 'completed',\n summary: `Sync completed with ${run.createdCount ?? 0} created, ${run.updatedCount ?? 0} updated, ${run.failedCount ?? 0} failed.`,\n createdCount: run.createdCount,\n updatedCount: run.updatedCount,\n skippedCount: run.skippedCount,\n failedCount: run.failedCount,\n batchesCompleted: run.batchesCompleted,\n },\n }\n : status === 'cancelled'\n ? {\n level: 'warn',\n code: 'data_sync.run_cancelled',\n message: 'Sync run cancelled',\n payload: { operationalStatus: 'cancelled', summary: 'The sync run was cancelled before completion.' },\n }\n : {\n level: 'error',\n code: 'data_sync.run_failed',\n message: errorMessage ?? 'Sync run failed',\n payload: { operationalStatus: 'failed', summary: errorMessage ?? 'The sync run failed.' },\n }\n\n await deps.integrationLogService.write({ integrationId: run.integrationId, runId: run.id, ...log }, scope)\n }\n\n await deps.emitEvent(`data_sync.run.${status}`, {\n runId: run.id,\n integrationId: run.integrationId,\n entityType: run.entityType,\n direction: run.direction,\n // Only the failure event carries this, and a subscriber that switches on it would see\n // every durable failure as an unexplained one if it were dropped.\n ...(status === 'failed' ? { error: errorMessage ?? null } : {}),\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["// How a `data_sync` run becomes durable work without core's engine being changed or copied.\n//\n// Core's engine already survives being displaced by another worker: it asks a cancellation\n// question at every batch boundary, and it stays silent when a terminal write is refused. A\n// durable adopter is exactly a worker in that position, so all this does is answer those two\n// questions differently \u2014 and route the cursor commit through the lease fence.\n//\n// See docs/adr/0004: the original plan was to fork the batch loop. Reading the engine showed\n// the seams were already there.\n\nimport type { SliceContext, SliceOutcome, SqlExecutor } from '@fullstackhouse/open-mercato-durable-work'\n\nimport { SeamBrokenError } from '../modules/data_sync/lib/version-guard'\n\nexport type SyncScope = { tenantId: string; organizationId: string | null; userId?: string | null }\nexport type SyncTerminalStatus = 'completed' | 'failed' | 'cancelled'\n\n/** The methods this decorator *wraps*, not the whole service: core's engine calls plenty more,\n * and they are passed through untouched. Anything not listed here is delegated. */\nexport type SyncRunServiceLike = {\n [method: string]: unknown\n getRun(runId: string, scope: SyncScope): Promise<{ status: string; progressJobId?: string | null } | null>\n markStatus(runId: string, status: string, scope: SyncScope, error?: string): Promise<unknown>\n commitBatchProgress(\n runId: string,\n delta: Record<string, unknown>,\n cursor: unknown,\n scope: SyncScope,\n options?: Record<string, unknown>,\n ): Promise<unknown>\n}\n\nexport type ProgressServiceLike = {\n [method: string]: unknown\n isCancellationRequested(progressJobId: string, tenantId: string, organizationId: string | null): Promise<boolean>\n}\n\n/** What core's engine tried to do, captured rather than applied. */\nexport type CapturedOutcome = { status: SyncTerminalStatus; error?: string } | null\n\nexport type SliceRecorder = {\n runService: SyncRunServiceLike\n progressService: ProgressServiceLike\n /** What the engine tried to finalize the run as, if anything. */\n captured(): CapturedOutcome\n /** Why the slice stopped early, when it did. */\n stopReason(): 'budget' | 'cancelled' | null\n /** Batches whose cursor this slice committed. Zero means the slice made no progress. */\n committedBatches(): number\n}\n\n/**\n * Wraps core's two services for the duration of one slice.\n *\n * @param ctx the slice this recorder belongs to; its lease is what fences the cursor commits\n */\nexport function recordSlice(\n ctx: SliceContext,\n runService: SyncRunServiceLike,\n progressService: ProgressServiceLike,\n): SliceRecorder {\n let captured: CapturedOutcome = null\n let stopReason: 'budget' | 'cancelled' | null = null\n let committed = 0\n\n return {\n captured: () => captured,\n stopReason: () => stopReason,\n committedBatches: () => committed,\n\n // Spread, not rebuilt.\n //\n // Core's engine calls far more than the three methods decorated here \u2014 `startJob`,\n // `updateProgress`, `touchJobHeartbeat`, `markCancelled` and others on the progress\n // service, and more of the run service besides. An object carrying only the overrides\n // looks fine to TypeScript through a structural type and then fails at the first\n // undecorated call, mid-run.\n runService: {\n ...runService,\n\n /**\n * Records a terminal transition instead of performing it, and answers with the run\n * unchanged.\n *\n * Core's `finalizeRun` compares what comes back to what it asked for, and stays silent\n * when they differ \u2014 the branch it has for \"another worker already finalized this\". So\n * this both captures the outcome and suppresses the progress write, the operational log\n * and the lifecycle event. The run's terminal status is then written by `onTransition`,\n * in the same transaction as the job's own; the three suppressed side effects are\n * replayed by `replayFinalize` after that commit, since an event inside a transaction\n * that can still roll back is a lie waiting to happen.\n */\n async markStatus(runId, status, scope, error) {\n if (status === 'completed' || status === 'failed' || status === 'cancelled') {\n captured = { status, error }\n return runService.getRun(runId, scope)\n }\n return runService.markStatus(runId, status, scope, error)\n },\n\n /**\n * Commits the batch cursor under the lease.\n *\n * This is the write that must not outlive the right to make it: a worker whose lease\n * expired mid-batch would otherwise advance the cursor of a run another worker is now\n * driving, and the two would interleave over one stream.\n */\n async commitBatchProgress(runId, delta, cursor, scope, options) {\n const result = await ctx.fencedWrite(async () => runService.commitBatchProgress(runId, delta, cursor, scope, options))\n committed += 1\n // A committed unit of work resets the failure and orphan budgets: a run that is making\n // progress has not earned the suspicion those counters represent, however many times\n // it was interrupted getting there.\n await ctx.heartbeat({ committed: true })\n return result\n },\n },\n\n progressService: {\n ...progressService,\n\n /**\n * Mirrors the run's counters onto the durable job as they move.\n *\n * Core reports progress here after every committed batch. Without this the operator API\n * shows a job that is plainly running with `0 of null` processed, and the one place\n * somebody looks to see whether a multi-day backfill is advancing tells them nothing.\n */\n async updateProgress(progressJobId: string, patch: { processedCount?: number; totalCount?: number | null }, scope: unknown) {\n await ctx\n .heartbeat({ processedCount: patch?.processedCount, totalCount: patch?.totalCount ?? null })\n .catch(() => undefined)\n const inner = progressService.updateProgress as\n | ((id: string, patch: unknown, scope: unknown) => Promise<unknown>)\n | undefined\n return inner?.call(progressService, progressJobId, patch, scope)\n },\n\n /**\n * Core asks this once per batch and stops the stream cleanly when it is true. That makes\n * it the slice's hand-back point as well as its cancellation point \u2014 the two need the\n * same clean stop, and differ only in what happens afterwards.\n */\n async isCancellationRequested(progressJobId, tenantId, organizationId) {\n if (await progressService.isCancellationRequested(progressJobId, tenantId, organizationId)) {\n stopReason = 'cancelled'\n return true\n }\n if (ctx.signal.aborted || ctx.shouldYield()) {\n stopReason = 'budget'\n return true\n }\n return false\n },\n },\n }\n}\n\n/** A run that ended `failed` inside core's engine. Thrown so the durable error taxonomy and the\n * retry budget apply to it, instead of the run being thrown away at the first blip. */\nexport class SyncRunFailedError extends Error {\n readonly durableErrorClass = 'transient' as const\n constructor(\n readonly runId: string,\n message: string,\n ) {\n super(message)\n this.name = 'SyncRunFailedError'\n }\n}\n\n/**\n * Turns what the recorder saw into the outcome the mechanism understands.\n *\n * @param runStatus the run's status after the slice, used only to tell \"already finished\" from\n * \"core finalized this itself\" \u2014 see the seam check below\n */\nexport function outcomeOf(recorder: SliceRecorder, runId: string, runStatus?: string): SliceOutcome {\n const captured = recorder.captured()\n\n if (recorder.stopReason() === 'budget') return 'budget'\n if (recorder.stopReason() === 'cancelled') return 'cancelled'\n\n if (!captured) {\n // Nothing was recorded. Two very different situations look the same from here, and telling\n // them apart is the whole point:\n //\n // - the run was already terminal, or gone, before this slice started \u2014 nothing to do\n // - core finalized the run itself, without going through the decorated `markStatus`\n //\n // The second means the seam this package rests on has moved (ADR 0004), and reporting it\n // as success would leave a job that says `completed` beside a run that says `failed`.\n // Committed batches are what distinguishes them: a slice that did real work and then found\n // the run terminal without recording anything did not simply arrive late.\n // A cancelled run is neither of those: it says plainly who ended it. An operator cancels\n // through core's route, which writes `cancelled` onto the run \u2014 and if the job was between\n // slices at that moment, the next delivery starts a slice, core's engine returns early\n // because the run is over, and nothing is captured. Draining here would complete the job\n // beside a run that says `cancelled`, which is the disagreement the mirror exists to\n // prevent. Checked before the seam test, because committed work does not make it a mystery.\n if (runStatus === 'cancelled') return 'cancelled'\n\n if (recorder.committedBatches() > 0 && runStatus && runStatus !== 'running' && runStatus !== 'pending') {\n throw new SeamBrokenError(runId, `the run reached \"${runStatus}\" without the adopter recording it`)\n }\n return 'drained'\n }\n if (captured.status === 'failed') throw new SyncRunFailedError(runId, captured.error ?? 'Sync run failed')\n if (captured.status === 'cancelled') return 'cancelled'\n return 'drained'\n}\n\n/** Maps a durable terminal state onto the run's own, inside the terminal transaction. */\nexport async function mirrorRunStatus(\n tx: SqlExecutor,\n runId: string,\n status: SyncTerminalStatus,\n errorMessage: string | null,\n): Promise<{ matched: number }> {\n // Fenced on the run still being open, so a run finished by another path is not overwritten \u2014\n // \"mirrored\" means the domain row agrees, and a row that already disagrees for a good reason\n // must not be forced.\n //\n // `or status = $2` is what makes that precise rather than merely strict. A row already in the\n // target status *agrees*; this writer simply was not the one that put it there. Treating that\n // as no-match cost a staging environment twenty minutes: core's cancel route writes\n // `cancelled` straight onto the run, so by the time the job mirrored its own cancellation the\n // row already said so, the transition failed, and the only exit from `running` is that same\n // mirror \u2014 so it retried until its budget was gone and stopped there.\n const result = await tx.query(\n `update sync_runs\n set status = $2, last_error = $3, updated_at = now()\n where id = $1 and deleted_at is null and (status in ('pending','running') or status = $2)`,\n [runId, status, errorMessage],\n )\n return { matched: result.rowCount }\n}\n\n/** Re-opens a run when an operator re-drives its job. The mirror image of the above. */\nexport async function reopenRun(tx: SqlExecutor, runId: string): Promise<{ matched: number }> {\n const result = await tx.query(\n `update sync_runs\n set status = 'running', last_error = null, updated_at = now()\n where id = $1 and deleted_at is null and status in ('failed','pending','running')`,\n [runId],\n )\n return { matched: result.rowCount }\n}\n\n/**\n * The services core's `finalizeRun` reaches for once the status is written.\n *\n * Named separately from the slice's dependencies because these are needed at a different\n * moment: the slice runs under a lease, this runs after the job's terminal transition has\n * committed, on whatever worker got there.\n */\nexport type FinalizeDeps = {\n progressService: ProgressServiceLike\n integrationLogService: { write(entry: Record<string, unknown>, scope: SyncScope): Promise<unknown> }\n integrationStateService?: {\n upsert(integrationId: string, patch: Record<string, unknown>, scope: SyncScope): Promise<unknown>\n } | null\n /** `adapter.operationalTelemetry === true`, which is core's own gate on the two writes below. */\n operationalTelemetry(integrationId: string): boolean\n emitEvent(name: string, payload: Record<string, unknown>): Promise<void>\n}\n\n/** The run fields core's tail reads. */\nexport type FinalizedRun = {\n id: string\n integrationId: string\n entityType: string\n direction: string\n progressJobId?: string | null\n createdCount?: number\n updatedCount?: number\n skippedCount?: number\n failedCount?: number\n batchesCompleted?: number\n}\n\n/**\n * Everything core's `finalizeRun` does after the status write, done here instead.\n *\n * Core stops at its \"another worker already finalized this\" branch on every durable run \u2014 that\n * is deliberate, and it is what lets the durable transition own the terminal state (see\n * `markStatus` above). But stopping there also skips the three things that came after it: the\n * progress job is never resolved, the operational log never written, and the lifecycle event\n * never emitted. Left unreplayed, a host loses the progress indicator an operator watches, the\n * integration health state, and `data_sync.run.completed` \u2014 which is dispatched to tenant\n * webhooks, so its absence is visible outside the app entirely.\n *\n * This runs after the commit, not inside it: events and enqueues must not sit in a transaction\n * that can still roll back, and the mechanism gives after-commit hooks at-most-once semantics\n * for exactly this. A throw here is logged and dropped rather than retried \u2014 the same standing\n * core's own tail has, where a failed webhook has never un-completed a run.\n */\nexport async function replayFinalize(\n deps: FinalizeDeps,\n run: FinalizedRun,\n status: SyncTerminalStatus,\n errorMessage: string | null,\n scope: SyncScope,\n userId: string | null,\n): Promise<void> {\n const progressScope = { tenantId: scope.tenantId, organizationId: scope.organizationId, userId: userId ?? undefined }\n const enabled = deps.operationalTelemetry(run.integrationId)\n\n if (run.progressJobId) {\n const progress = deps.progressService as unknown as Record<string, ((...args: unknown[]) => Promise<unknown>) | undefined>\n if (status === 'completed') {\n await progress.completeJob?.(\n run.progressJobId,\n {\n resultSummary: {\n createdCount: run.createdCount,\n updatedCount: run.updatedCount,\n skippedCount: run.skippedCount,\n failedCount: run.failedCount,\n batchesCompleted: run.batchesCompleted,\n },\n },\n progressScope,\n )\n } else if (status === 'failed') {\n await progress.failJob?.(run.progressJobId, { errorMessage: errorMessage ?? 'Sync run failed' }, progressScope)\n } else {\n await progress.markCancelled?.(run.progressJobId, progressScope)\n }\n }\n\n const health = status === 'completed' ? 'healthy' : status === 'cancelled' ? 'degraded' : 'unhealthy'\n if (enabled && deps.integrationStateService) {\n await deps.integrationStateService.upsert(\n run.integrationId,\n { lastHealthStatus: health, lastHealthCheckedAt: new Date() },\n scope,\n )\n }\n\n if (enabled) {\n // `code` is core's own field on an integration log row, and core's `finalizeRun` leaves it\n // unset. It is set here because a host that reports error rows outward \u2014 reading `code` as\n // the fingerprint, which the integration log service does \u2014 would otherwise group every\n // durable terminal failure under a generic fallback, losing the one attribute separating a\n // dead sync run from any other integration error.\n const log =\n status === 'completed'\n ? {\n level: 'info',\n code: 'data_sync.run_completed',\n message: 'Sync run completed',\n payload: {\n operationalStatus: 'completed',\n summary: `Sync completed with ${run.createdCount ?? 0} created, ${run.updatedCount ?? 0} updated, ${run.failedCount ?? 0} failed.`,\n createdCount: run.createdCount,\n updatedCount: run.updatedCount,\n skippedCount: run.skippedCount,\n failedCount: run.failedCount,\n batchesCompleted: run.batchesCompleted,\n },\n }\n : status === 'cancelled'\n ? {\n level: 'warn',\n code: 'data_sync.run_cancelled',\n message: 'Sync run cancelled',\n payload: { operationalStatus: 'cancelled', summary: 'The sync run was cancelled before completion.' },\n }\n : {\n level: 'error',\n code: 'data_sync.run_failed',\n message: errorMessage ?? 'Sync run failed',\n payload: { operationalStatus: 'failed', summary: errorMessage ?? 'The sync run failed.' },\n }\n\n await deps.integrationLogService.write({ integrationId: run.integrationId, runId: run.id, ...log }, scope)\n }\n\n await deps.emitEvent(`data_sync.run.${status}`, {\n runId: run.id,\n integrationId: run.integrationId,\n entityType: run.entityType,\n direction: run.direction,\n // Only the failure event carries this, and a subscriber that switches on it would see\n // every durable failure as an unexplained one if it were dropped.\n ...(status === 'failed' ? { error: errorMessage ?? null } : {}),\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,3 +1,4 @@
1
+ import { store } from "@fullstackhouse/open-mercato-durable-work";
1
2
  import {
2
3
  mirrorRunStatus,
3
4
  outcomeOf,
@@ -67,10 +68,17 @@ function syncLockKey(integrationId, entityType, direction) {
67
68
  function syncIdempotencyKey(runId) {
68
69
  return `data_sync.run:${runId}`;
69
70
  }
71
+ async function cancelDurableJobForRun(deps, runId, scope, by) {
72
+ const job = await store.findByIdempotencyKey(deps.sql, scope, syncIdempotencyKey(runId));
73
+ if (!job) return "no_job";
74
+ await deps.durable.cancel(job.id, scope, by);
75
+ return "cancelled";
76
+ }
70
77
  export {
71
78
  DATA_SYNC_QUEUE,
72
79
  EXPORT_KIND,
73
80
  IMPORT_KIND,
81
+ cancelDurableJobForRun,
74
82
  dataSyncKinds,
75
83
  syncIdempotencyKey,
76
84
  syncLockKey
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/kinds/data-sync-run.ts"],
4
- "sourcesContent": ["// The job kinds a `data_sync` run becomes.\n//\n// A slice is one call into core's own engine, with the run service and the progress service\n// decorated for its duration. The engine does all the work it always did; what changes is where\n// its cursor commits go (through the lease fence), when it stops (at a batch boundary when the\n// slice budget is spent), and who writes the terminal state (the durable transition, in the\n// same transaction as the job's own).\n\nimport type { KindDefinition, SliceContext, SliceOutcome, SqlExecutor } from '@fullstackhouse/open-mercato-durable-work'\n\nimport {\n mirrorRunStatus,\n outcomeOf,\n recordSlice,\n reopenRun,\n replayFinalize,\n type FinalizeDeps,\n type ProgressServiceLike,\n type SyncRunServiceLike,\n type SyncScope,\n} from '../engine/durable-run'\n\nexport const IMPORT_KIND = 'data_sync.import'\nexport const EXPORT_KIND = 'data_sync.export'\nexport const DATA_SYNC_QUEUE = 'durable-work.data-sync'\n\nexport type SyncRunInput = {\n runId: string\n batchSize: number\n direction: 'import' | 'export'\n}\n\n/** What a slice needs from the host, resolved per slice from the container. */\nexport type SyncEngineLike = {\n runImport(runId: string, batchSize: number, scope: SyncScope): Promise<void>\n runExport(runId: string, batchSize: number, scope: SyncScope): Promise<void>\n}\n\nexport type DataSyncKindDeps = {\n /** Resolved per slice, because each slice runs on its own EntityManager. */\n resolve(): Promise<{\n engine: (services: { runService: SyncRunServiceLike; progressService: ProgressServiceLike }) => SyncEngineLike\n runService: SyncRunServiceLike\n progressService: ProgressServiceLike\n /** What core's `finalizeRun` would have done after the status write. */\n finalize: FinalizeDeps\n }>\n}\n\nasync function runSlice(ctx: SliceContext<SyncRunInput>, deps: DataSyncKindDeps): Promise<SliceOutcome> {\n const input = ctx.job.input as SyncRunInput\n const scope: SyncScope = { tenantId: ctx.scope.tenantId, organizationId: ctx.scope.organizationId }\n const { engine, runService, progressService } = await deps.resolve()\n\n const recorder = recordSlice(ctx, runService, progressService)\n const decorated = engine({ runService: recorder.runService, progressService: recorder.progressService })\n\n if (input.direction === 'export') await decorated.runExport(input.runId, input.batchSize, scope)\n else await decorated.runImport(input.runId, input.batchSize, scope)\n\n const after = await runService.getRun(input.runId, scope)\n return outcomeOf(recorder, input.runId, after?.status)\n}\n\n/**\n * Builds both kinds.\n *\n * `orphanPolicy: 'redrive'` is deliberate and is the one place the adopter asserts something\n * about the work rather than the mechanism: a sync run keeps a committed cursor, so re-running\n * it after a worker died resumes rather than repeats. That is what makes automatic recovery\n * safe here when the mechanism's default is to park and wait for a human.\n */\nexport function dataSyncKinds(deps: DataSyncKindDeps): KindDefinition<SyncRunInput, never>[] {\n const shared = {\n queue: DATA_SYNC_QUEUE,\n requiredFeatures: ['data_sync.run'],\n orphanPolicy: 'redrive' as const,\n // Long enough that a slow adapter page does not look like a dead worker, short enough that\n // a dead worker is noticed in about a minute.\n lease: { ttlMs: 60_000, sliceBudgetMs: 300_000, pendingTtlMs: 900_000 },\n\n async onTransition(job: { id: string; input: unknown; status: string; errorMessage: string | null }, _scope: unknown, tx: SqlExecutor) {\n const { runId } = job.input as SyncRunInput\n const status = job.status === 'completed' ? 'completed' : job.status === 'cancelled' ? 'cancelled' : 'failed'\n return mirrorRunStatus(tx, runId, status, job.errorMessage)\n },\n\n async onRedrive(job: { input: unknown }, _scope: unknown, tx: SqlExecutor) {\n return reopenRun(tx, (job.input as SyncRunInput).runId)\n },\n\n /**\n * The rest of core's `finalizeRun`, which core itself skips on every durable run.\n *\n * Without this a host is told the run is over by the `sync_runs` row alone: the progress\n * job an operator watches never resolves, the integration's health state never moves, and\n * `data_sync.run.completed` \u2014 a tenant webhook \u2014 is never dispatched. The decoration that\n * makes a run durable is supposed to be invisible to a host, and those three are exactly\n * how a host would notice.\n *\n * At-most-once and best-effort by the mechanism's contract, which is the same standing\n * these have in core: a webhook that fails there has never un-completed a run either.\n */\n async onAfterTransition(job: { id: string; input: unknown; status: string; errorMessage: string | null; createdBy: string | null }, scope: { tenantId: string; organizationId: string | null }) {\n const { runId } = job.input as SyncRunInput\n const status = job.status === 'completed' ? 'completed' : job.status === 'cancelled' ? 'cancelled' : 'failed'\n const { runService, finalize } = await deps.resolve()\n\n const run = (await runService.getRun(runId, scope)) as\n | (Parameters<typeof replayFinalize>[1] & Record<string, unknown>)\n | null\n // A run that has been hard-deleted since the job finished is not an error to report:\n // there is nothing left to resolve, and the job's own terminal state is already correct.\n if (!run) return\n\n await replayFinalize(finalize, run, status, job.errorMessage, scope, job.createdBy)\n },\n }\n\n return [\n { ...shared, kind: IMPORT_KIND, step: (ctx) => runSlice(ctx, deps) } as KindDefinition<SyncRunInput, never>,\n { ...shared, kind: EXPORT_KIND, step: (ctx) => runSlice(ctx, deps) } as KindDefinition<SyncRunInput, never>,\n ]\n}\n\n/** The single-runner key: one live run per integration, entity and direction. Two imports of\n * the same entity would interleave over one cursor. */\nexport function syncLockKey(integrationId: string, entityType: string, direction: string): string {\n return `data_sync:${integrationId}:${entityType}:${direction}`\n}\n\n/** Makes starting a run twice for the same run id return the first job rather than a second. */\nexport function syncIdempotencyKey(runId: string): string {\n return `data_sync.run:${runId}`\n}\n"],
5
- "mappings": "AAUA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAKK;AAEA,MAAM,cAAc;AACpB,MAAM,cAAc;AACpB,MAAM,kBAAkB;AAyB/B,eAAe,SAAS,KAAiC,MAA+C;AACtG,QAAM,QAAQ,IAAI,IAAI;AACtB,QAAM,QAAmB,EAAE,UAAU,IAAI,MAAM,UAAU,gBAAgB,IAAI,MAAM,eAAe;AAClG,QAAM,EAAE,QAAQ,YAAY,gBAAgB,IAAI,MAAM,KAAK,QAAQ;AAEnE,QAAM,WAAW,YAAY,KAAK,YAAY,eAAe;AAC7D,QAAM,YAAY,OAAO,EAAE,YAAY,SAAS,YAAY,iBAAiB,SAAS,gBAAgB,CAAC;AAEvG,MAAI,MAAM,cAAc,SAAU,OAAM,UAAU,UAAU,MAAM,OAAO,MAAM,WAAW,KAAK;AAAA,MAC1F,OAAM,UAAU,UAAU,MAAM,OAAO,MAAM,WAAW,KAAK;AAElE,QAAM,QAAQ,MAAM,WAAW,OAAO,MAAM,OAAO,KAAK;AACxD,SAAO,UAAU,UAAU,MAAM,OAAO,OAAO,MAAM;AACvD;AAUO,SAAS,cAAc,MAA+D;AAC3F,QAAM,SAAS;AAAA,IACb,OAAO;AAAA,IACP,kBAAkB,CAAC,eAAe;AAAA,IAClC,cAAc;AAAA;AAAA;AAAA,IAGd,OAAO,EAAE,OAAO,KAAQ,eAAe,KAAS,cAAc,IAAQ;AAAA,IAEtE,MAAM,aAAa,KAAkF,QAAiB,IAAiB;AACrI,YAAM,EAAE,MAAM,IAAI,IAAI;AACtB,YAAM,SAAS,IAAI,WAAW,cAAc,cAAc,IAAI,WAAW,cAAc,cAAc;AACrG,aAAO,gBAAgB,IAAI,OAAO,QAAQ,IAAI,YAAY;AAAA,IAC5D;AAAA,IAEA,MAAM,UAAU,KAAyB,QAAiB,IAAiB;AACzE,aAAO,UAAU,IAAK,IAAI,MAAuB,KAAK;AAAA,IACxD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAcA,MAAM,kBAAkB,KAA4G,OAA4D;AAC9L,YAAM,EAAE,MAAM,IAAI,IAAI;AACtB,YAAM,SAAS,IAAI,WAAW,cAAc,cAAc,IAAI,WAAW,cAAc,cAAc;AACrG,YAAM,EAAE,YAAY,SAAS,IAAI,MAAM,KAAK,QAAQ;AAEpD,YAAM,MAAO,MAAM,WAAW,OAAO,OAAO,KAAK;AAKjD,UAAI,CAAC,IAAK;AAEV,YAAM,eAAe,UAAU,KAAK,QAAQ,IAAI,cAAc,OAAO,IAAI,SAAS;AAAA,IACpF;AAAA,EACF;AAEA,SAAO;AAAA,IACL,EAAE,GAAG,QAAQ,MAAM,aAAa,MAAM,CAAC,QAAQ,SAAS,KAAK,IAAI,EAAE;AAAA,IACnE,EAAE,GAAG,QAAQ,MAAM,aAAa,MAAM,CAAC,QAAQ,SAAS,KAAK,IAAI,EAAE;AAAA,EACrE;AACF;AAIO,SAAS,YAAY,eAAuB,YAAoB,WAA2B;AAChG,SAAO,aAAa,aAAa,IAAI,UAAU,IAAI,SAAS;AAC9D;AAGO,SAAS,mBAAmB,OAAuB;AACxD,SAAO,iBAAiB,KAAK;AAC/B;",
4
+ "sourcesContent": ["// The job kinds a `data_sync` run becomes.\n//\n// A slice is one call into core's own engine, with the run service and the progress service\n// decorated for its duration. The engine does all the work it always did; what changes is where\n// its cursor commits go (through the lease fence), when it stops (at a batch boundary when the\n// slice budget is spent), and who writes the terminal state (the durable transition, in the\n// same transaction as the job's own).\n\nimport { store, type DurableJob, type KindDefinition, type Scope, type SliceContext, type SliceOutcome, type SqlExecutor } from '@fullstackhouse/open-mercato-durable-work'\n\nimport {\n mirrorRunStatus,\n outcomeOf,\n recordSlice,\n reopenRun,\n replayFinalize,\n type FinalizeDeps,\n type ProgressServiceLike,\n type SyncRunServiceLike,\n type SyncScope,\n} from '../engine/durable-run'\n\nexport const IMPORT_KIND = 'data_sync.import'\nexport const EXPORT_KIND = 'data_sync.export'\nexport const DATA_SYNC_QUEUE = 'durable-work.data-sync'\n\nexport type SyncRunInput = {\n runId: string\n batchSize: number\n direction: 'import' | 'export'\n}\n\n/** What a slice needs from the host, resolved per slice from the container. */\nexport type SyncEngineLike = {\n runImport(runId: string, batchSize: number, scope: SyncScope): Promise<void>\n runExport(runId: string, batchSize: number, scope: SyncScope): Promise<void>\n}\n\nexport type DataSyncKindDeps = {\n /** Resolved per slice, because each slice runs on its own EntityManager. */\n resolve(): Promise<{\n engine: (services: { runService: SyncRunServiceLike; progressService: ProgressServiceLike }) => SyncEngineLike\n runService: SyncRunServiceLike\n progressService: ProgressServiceLike\n /** What core's `finalizeRun` would have done after the status write. */\n finalize: FinalizeDeps\n }>\n}\n\nasync function runSlice(ctx: SliceContext<SyncRunInput>, deps: DataSyncKindDeps): Promise<SliceOutcome> {\n const input = ctx.job.input as SyncRunInput\n const scope: SyncScope = { tenantId: ctx.scope.tenantId, organizationId: ctx.scope.organizationId }\n const { engine, runService, progressService } = await deps.resolve()\n\n const recorder = recordSlice(ctx, runService, progressService)\n const decorated = engine({ runService: recorder.runService, progressService: recorder.progressService })\n\n if (input.direction === 'export') await decorated.runExport(input.runId, input.batchSize, scope)\n else await decorated.runImport(input.runId, input.batchSize, scope)\n\n const after = await runService.getRun(input.runId, scope)\n return outcomeOf(recorder, input.runId, after?.status)\n}\n\n/**\n * Builds both kinds.\n *\n * `orphanPolicy: 'redrive'` is deliberate and is the one place the adopter asserts something\n * about the work rather than the mechanism: a sync run keeps a committed cursor, so re-running\n * it after a worker died resumes rather than repeats. That is what makes automatic recovery\n * safe here when the mechanism's default is to park and wait for a human.\n */\nexport function dataSyncKinds(deps: DataSyncKindDeps): KindDefinition<SyncRunInput, never>[] {\n const shared = {\n queue: DATA_SYNC_QUEUE,\n requiredFeatures: ['data_sync.run'],\n orphanPolicy: 'redrive' as const,\n // Long enough that a slow adapter page does not look like a dead worker, short enough that\n // a dead worker is noticed in about a minute.\n lease: { ttlMs: 60_000, sliceBudgetMs: 300_000, pendingTtlMs: 900_000 },\n\n async onTransition(job: { id: string; input: unknown; status: string; errorMessage: string | null }, _scope: unknown, tx: SqlExecutor) {\n const { runId } = job.input as SyncRunInput\n const status = job.status === 'completed' ? 'completed' : job.status === 'cancelled' ? 'cancelled' : 'failed'\n return mirrorRunStatus(tx, runId, status, job.errorMessage)\n },\n\n async onRedrive(job: { input: unknown }, _scope: unknown, tx: SqlExecutor) {\n return reopenRun(tx, (job.input as SyncRunInput).runId)\n },\n\n /**\n * The rest of core's `finalizeRun`, which core itself skips on every durable run.\n *\n * Without this a host is told the run is over by the `sync_runs` row alone: the progress\n * job an operator watches never resolves, the integration's health state never moves, and\n * `data_sync.run.completed` \u2014 a tenant webhook \u2014 is never dispatched. The decoration that\n * makes a run durable is supposed to be invisible to a host, and those three are exactly\n * how a host would notice.\n *\n * At-most-once and best-effort by the mechanism's contract, which is the same standing\n * these have in core: a webhook that fails there has never un-completed a run either.\n */\n async onAfterTransition(job: { id: string; input: unknown; status: string; errorMessage: string | null; createdBy: string | null }, scope: { tenantId: string; organizationId: string | null }) {\n const { runId } = job.input as SyncRunInput\n const status = job.status === 'completed' ? 'completed' : job.status === 'cancelled' ? 'cancelled' : 'failed'\n const { runService, finalize } = await deps.resolve()\n\n const run = (await runService.getRun(runId, scope)) as\n | (Parameters<typeof replayFinalize>[1] & Record<string, unknown>)\n | null\n // A run that has been hard-deleted since the job finished is not an error to report:\n // there is nothing left to resolve, and the job's own terminal state is already correct.\n if (!run) return\n\n await replayFinalize(finalize, run, status, job.errorMessage, scope, job.createdBy)\n },\n }\n\n return [\n { ...shared, kind: IMPORT_KIND, step: (ctx) => runSlice(ctx, deps) } as KindDefinition<SyncRunInput, never>,\n { ...shared, kind: EXPORT_KIND, step: (ctx) => runSlice(ctx, deps) } as KindDefinition<SyncRunInput, never>,\n ]\n}\n\n/** The single-runner key: one live run per integration, entity and direction. Two imports of\n * the same entity would interleave over one cursor. */\nexport function syncLockKey(integrationId: string, entityType: string, direction: string): string {\n return `data_sync:${integrationId}:${entityType}:${direction}`\n}\n\n/** Makes starting a run twice for the same run id return the first job rather than a second. */\nexport function syncIdempotencyKey(runId: string): string {\n return `data_sync.run:${runId}`\n}\n\n\n/**\n * Tells the mechanism that a run an operator cancelled is cancelled.\n *\n * Core's cancel route writes `cancelled` straight onto the run and knows nothing about the job.\n * The run stops either way \u2014 the slice notices through the progress job's cancellation flag \u2014\n * but the *job* never learns, so `cancel_requested_at` is never set, the reconciler's cancelling\n * sweep has nothing to find, and a kind's `onCancel`, which is where external resources are\n * released, never runs. A job sitting between slices is worse: nothing stops it from being\n * delivered again.\n *\n * Returns `no_job` rather than throwing when there is nothing to cancel \u2014 a run started before\n * this package was adopted, or one whose job has been reaped. The operator's cancel succeeded;\n * there is simply nothing further to stop.\n */\nexport async function cancelDurableJobForRun(\n deps: {\n sql: SqlExecutor\n durable: { cancel(id: string, scope: Scope, by: string | null): Promise<DurableJob | null> }\n },\n runId: string,\n scope: Scope,\n by: string | null,\n): Promise<'cancelled' | 'no_job'> {\n const job = await store.findByIdempotencyKey(deps.sql, scope, syncIdempotencyKey(runId))\n if (!job) return 'no_job'\n await deps.durable.cancel(job.id, scope, by)\n return 'cancelled'\n}\n"],
5
+ "mappings": "AAQA,SAAS,aAAuH;AAEhI;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAKK;AAEA,MAAM,cAAc;AACpB,MAAM,cAAc;AACpB,MAAM,kBAAkB;AAyB/B,eAAe,SAAS,KAAiC,MAA+C;AACtG,QAAM,QAAQ,IAAI,IAAI;AACtB,QAAM,QAAmB,EAAE,UAAU,IAAI,MAAM,UAAU,gBAAgB,IAAI,MAAM,eAAe;AAClG,QAAM,EAAE,QAAQ,YAAY,gBAAgB,IAAI,MAAM,KAAK,QAAQ;AAEnE,QAAM,WAAW,YAAY,KAAK,YAAY,eAAe;AAC7D,QAAM,YAAY,OAAO,EAAE,YAAY,SAAS,YAAY,iBAAiB,SAAS,gBAAgB,CAAC;AAEvG,MAAI,MAAM,cAAc,SAAU,OAAM,UAAU,UAAU,MAAM,OAAO,MAAM,WAAW,KAAK;AAAA,MAC1F,OAAM,UAAU,UAAU,MAAM,OAAO,MAAM,WAAW,KAAK;AAElE,QAAM,QAAQ,MAAM,WAAW,OAAO,MAAM,OAAO,KAAK;AACxD,SAAO,UAAU,UAAU,MAAM,OAAO,OAAO,MAAM;AACvD;AAUO,SAAS,cAAc,MAA+D;AAC3F,QAAM,SAAS;AAAA,IACb,OAAO;AAAA,IACP,kBAAkB,CAAC,eAAe;AAAA,IAClC,cAAc;AAAA;AAAA;AAAA,IAGd,OAAO,EAAE,OAAO,KAAQ,eAAe,KAAS,cAAc,IAAQ;AAAA,IAEtE,MAAM,aAAa,KAAkF,QAAiB,IAAiB;AACrI,YAAM,EAAE,MAAM,IAAI,IAAI;AACtB,YAAM,SAAS,IAAI,WAAW,cAAc,cAAc,IAAI,WAAW,cAAc,cAAc;AACrG,aAAO,gBAAgB,IAAI,OAAO,QAAQ,IAAI,YAAY;AAAA,IAC5D;AAAA,IAEA,MAAM,UAAU,KAAyB,QAAiB,IAAiB;AACzE,aAAO,UAAU,IAAK,IAAI,MAAuB,KAAK;AAAA,IACxD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAcA,MAAM,kBAAkB,KAA4G,OAA4D;AAC9L,YAAM,EAAE,MAAM,IAAI,IAAI;AACtB,YAAM,SAAS,IAAI,WAAW,cAAc,cAAc,IAAI,WAAW,cAAc,cAAc;AACrG,YAAM,EAAE,YAAY,SAAS,IAAI,MAAM,KAAK,QAAQ;AAEpD,YAAM,MAAO,MAAM,WAAW,OAAO,OAAO,KAAK;AAKjD,UAAI,CAAC,IAAK;AAEV,YAAM,eAAe,UAAU,KAAK,QAAQ,IAAI,cAAc,OAAO,IAAI,SAAS;AAAA,IACpF;AAAA,EACF;AAEA,SAAO;AAAA,IACL,EAAE,GAAG,QAAQ,MAAM,aAAa,MAAM,CAAC,QAAQ,SAAS,KAAK,IAAI,EAAE;AAAA,IACnE,EAAE,GAAG,QAAQ,MAAM,aAAa,MAAM,CAAC,QAAQ,SAAS,KAAK,IAAI,EAAE;AAAA,EACrE;AACF;AAIO,SAAS,YAAY,eAAuB,YAAoB,WAA2B;AAChG,SAAO,aAAa,aAAa,IAAI,UAAU,IAAI,SAAS;AAC9D;AAGO,SAAS,mBAAmB,OAAuB;AACxD,SAAO,iBAAiB,KAAK;AAC/B;AAiBA,eAAsB,uBACpB,MAIA,OACA,OACA,IACiC;AACjC,QAAM,MAAM,MAAM,MAAM,qBAAqB,KAAK,KAAK,OAAO,mBAAmB,KAAK,CAAC;AACvF,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,KAAK,QAAQ,OAAO,IAAI,IAAI,OAAO,EAAE;AAC3C,SAAO;AACT;",
6
6
  "names": []
7
7
  }
@@ -7,7 +7,6 @@
7
7
  "api/mappings/route.ts": "5551bc5ff61dfebb",
8
8
  "api/options.ts": "bff527b4061b6c2b",
9
9
  "api/runs.ts": "a2024a96d896c814",
10
- "api/runs/[id]/cancel.ts": "1c99c66183d994ae",
11
10
  "api/runs/[id]/retry.ts": "cf6ea67efcebdeb9",
12
11
  "api/runs/[id]/route.ts": "2d0e39daef513f92",
13
12
  "api/schedules/[id]/route.ts": "af2b16bd45fd02b5",
@@ -50,6 +49,7 @@
50
49
  },
51
50
  "replaced": {
52
51
  "api/run.ts": "0eda5b6869a0b95d",
52
+ "api/runs/[id]/cancel.ts": "1c99c66183d994ae",
53
53
  "di.ts": "d351534bd20b2b29",
54
54
  "lib/start-run.ts": "74081590458b202b",
55
55
  "workers/sync-export.ts": "a23cad4d03dd42da",
@@ -1,10 +1,48 @@
1
- export * from "@open-mercato/core/modules/data_sync/api/runs/[id]/cancel";
2
- import { POST } from "@open-mercato/core/modules/data_sync/api/runs/[id]/cancel";
3
- import { openApi as coreOpenApi } from "@open-mercato/core/modules/data_sync/api/runs/[id]/cancel";
4
- const openApi = coreOpenApi;
1
+ import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
2
+ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
3
+ import { createLogger } from "@open-mercato/shared/lib/logger";
4
+ import {
5
+ POST as corePost,
6
+ openApi as coreOpenApi
7
+ } from "@open-mercato/core/modules/data_sync/api/runs/[id]/cancel";
8
+ import { cancelDurableJobForRun } from "../../../../../kinds/data-sync-run.js";
9
+ const logger = createLogger("data_sync").child({ component: "durable-cancel" });
10
+ const DURABLE_HEADER = "x-durable-work";
5
11
  const metadata = {
6
12
  POST: { requireAuth: true, requireFeatures: ["data_sync.run"] }
7
13
  };
14
+ const openApi = coreOpenApi;
15
+ async function POST(req, ctx) {
16
+ const response = await corePost(req, ctx);
17
+ if (response.status !== 200) return response;
18
+ try {
19
+ const rawParams = ctx.params && typeof ctx.params.then === "function" ? await ctx.params : ctx.params;
20
+ const runId = rawParams?.id;
21
+ const auth = await getAuthFromRequest(req);
22
+ if (!runId || !auth?.tenantId) {
23
+ response.headers.set(DURABLE_HEADER, "deferred");
24
+ return response;
25
+ }
26
+ const container = await createRequestContainer();
27
+ const outcome = await cancelDurableJobForRun(
28
+ {
29
+ sql: container.resolve("durableWorkSql"),
30
+ durable: container.resolve("durableWorkService")
31
+ },
32
+ runId,
33
+ { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
34
+ auth.sub ?? null
35
+ );
36
+ response.headers.set(DURABLE_HEADER, outcome);
37
+ } catch (error) {
38
+ logger.warn("Could not cancel the durable job for a cancelled run; the reconciler will settle it", {
39
+ err: error
40
+ });
41
+ response.headers.set(DURABLE_HEADER, "deferred");
42
+ response.headers.set(`${DURABLE_HEADER}-reason`, error instanceof Error ? error.message.slice(0, 200) : "unknown");
43
+ }
44
+ return response;
45
+ }
8
46
  export {
9
47
  POST,
10
48
  metadata,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/data_sync/api/runs/%5Bid%5D/cancel.ts"],
4
- "sourcesContent": ["// GENERATED by scripts/gen-mirror.mjs from @open-mercato/core@0.7.0 (api/runs/[id]/cancel.ts). Do not edit.\n// This package mirrors core's data_sync module file-for-file; see docs/adr/0006-drop-in-data-sync.md.\nexport * from '@open-mercato/core/modules/data_sync/api/runs/[id]/cancel'\nexport { POST } from '@open-mercato/core/modules/data_sync/api/runs/[id]/cancel'\nimport { openApi as coreOpenApi } from '@open-mercato/core/modules/data_sync/api/runs/[id]/cancel'\nexport const openApi = coreOpenApi\n// Copied from core: the generator reads this by AST and cannot follow a re-export.\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['data_sync.run'] },\n}\n"],
5
- "mappings": "AAEA,cAAc;AACd,SAAS,YAAY;AACrB,SAAS,WAAW,mBAAmB;AAChC,MAAM,UAAU;AAEhB,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,eAAe,EAAE;AAChE;",
4
+ "sourcesContent": ["// Core's cancel route, with the durable job told about it.\n//\n// Wrapped for the same reason as `api/run.ts`: core's route is the one an operator actually\n// reaches, and it knows nothing about durable work. It writes `cancelled` onto the run and\n// cancels the progress job \u2014 which does stop a slice that is running, because the slice checks\n// the progress job's cancellation flag at every batch boundary.\n//\n// What it does not do is tell the *job*. Left there, `cancel_requested_at` is never set, so the\n// reconciler's cancelling sweep has nothing to find and a kind's `onCancel` \u2014 where external\n// resources are released \u2014 never runs. A job sitting between slices is worse: nothing stops the\n// next delivery from starting another one.\n//\n// After core's call, not before: if the durable cancel ran first, the job could reach its\n// terminal state and mirror `cancelled` onto a run that core is still about to write, and the\n// operator's own request would be the one racing.\n//\n// Never fails the request. The operator asked to cancel a run and the run is cancelled; a\n// failure to also stop the job is reported in a header and repaired by the reconciler, which is\n// what it is for.\n\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n POST as corePost,\n openApi as coreOpenApi,\n} from '@open-mercato/core/modules/data_sync/api/runs/[id]/cancel'\nimport type { DurableWorkService, SqlExecutor } from '@fullstackhouse/open-mercato-durable-work'\n\nimport { cancelDurableJobForRun } from '../../../../../kinds/data-sync-run'\n\nconst logger = createLogger('data_sync').child({ component: 'durable-cancel' })\n\nconst DURABLE_HEADER = 'x-durable-work'\n\n// Copied from core: the generator reads this by AST and cannot follow a re-export.\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['data_sync.run'] },\n}\n\nexport const openApi = coreOpenApi\n\nexport async function POST(req: Request, ctx: { params?: Promise<{ id?: string }> | { id?: string } }) {\n const response = await corePost(req, ctx)\n if (response.status !== 200) return response\n\n try {\n const rawParams =\n ctx.params && typeof (ctx.params as Promise<unknown>).then === 'function'\n ? await (ctx.params as Promise<{ id?: string }>)\n : (ctx.params as { id?: string } | undefined)\n const runId = rawParams?.id\n const auth = await getAuthFromRequest(req)\n if (!runId || !auth?.tenantId) {\n response.headers.set(DURABLE_HEADER, 'deferred')\n return response\n }\n\n const container = await createRequestContainer()\n const outcome = await cancelDurableJobForRun(\n {\n sql: container.resolve('durableWorkSql') as SqlExecutor,\n durable: container.resolve('durableWorkService') as DurableWorkService,\n },\n runId,\n { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },\n auth.sub ?? null,\n )\n response.headers.set(DURABLE_HEADER, outcome)\n } catch (error) {\n logger.warn('Could not cancel the durable job for a cancelled run; the reconciler will settle it', {\n err: error as Error,\n })\n response.headers.set(DURABLE_HEADER, 'deferred')\n response.headers.set(`${DURABLE_HEADER}-reason`, error instanceof Error ? error.message.slice(0, 200) : 'unknown')\n }\n\n return response\n}\n"],
5
+ "mappings": "AAoBA,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAC7B;AAAA,EACE,QAAQ;AAAA,EACR,WAAW;AAAA,OACN;AAGP,SAAS,8BAA8B;AAEvC,MAAM,SAAS,aAAa,WAAW,EAAE,MAAM,EAAE,WAAW,iBAAiB,CAAC;AAE9E,MAAM,iBAAiB;AAGhB,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,eAAe,EAAE;AAChE;AAEO,MAAM,UAAU;AAEvB,eAAsB,KAAK,KAAc,KAA8D;AACrG,QAAM,WAAW,MAAM,SAAS,KAAK,GAAG;AACxC,MAAI,SAAS,WAAW,IAAK,QAAO;AAEpC,MAAI;AACF,UAAM,YACJ,IAAI,UAAU,OAAQ,IAAI,OAA4B,SAAS,aAC3D,MAAO,IAAI,SACV,IAAI;AACX,UAAM,QAAQ,WAAW;AACzB,UAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,QAAI,CAAC,SAAS,CAAC,MAAM,UAAU;AAC7B,eAAS,QAAQ,IAAI,gBAAgB,UAAU;AAC/C,aAAO;AAAA,IACT;AAEA,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,UAAU,MAAM;AAAA,MACpB;AAAA,QACE,KAAK,UAAU,QAAQ,gBAAgB;AAAA,QACvC,SAAS,UAAU,QAAQ,oBAAoB;AAAA,MACjD;AAAA,MACA;AAAA,MACA,EAAE,UAAU,KAAK,UAAU,gBAAgB,KAAK,SAAS,KAAK;AAAA,MAC9D,KAAK,OAAO;AAAA,IACd;AACA,aAAS,QAAQ,IAAI,gBAAgB,OAAO;AAAA,EAC9C,SAAS,OAAO;AACd,WAAO,KAAK,uFAAuF;AAAA,MACjG,KAAK;AAAA,IACP,CAAC;AACD,aAAS,QAAQ,IAAI,gBAAgB,UAAU;AAC/C,aAAS,QAAQ,IAAI,GAAG,cAAc,WAAW,iBAAiB,QAAQ,MAAM,QAAQ,MAAM,GAAG,GAAG,IAAI,SAAS;AAAA,EACnH;AAEA,SAAO;AACT;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/open-mercato-data-sync-durable",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Drop-in replacement for Open Mercato's core data_sync module that runs sync runs as durable at-least-once work (via @fullstackhouse/open-mercato-durable-work): survives deploys and worker kills, resumes from the committed cursor, retries transient errors at the batch, never leaves a run 'running' forever. Same tables, API, adapter contract, events and UI as core; swap one line in modules.ts.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -94,7 +94,7 @@
94
94
  }
95
95
  },
96
96
  "peerDependencies": {
97
- "@fullstackhouse/open-mercato-durable-work": "^0.2.1",
97
+ "@fullstackhouse/open-mercato-durable-work": "^0.2.2",
98
98
  "@mikro-orm/core": ">=7.0.0",
99
99
  "@mikro-orm/migrations": ">=7.0.0",
100
100
  "@mikro-orm/postgresql": ">=7.0.0",
@@ -109,7 +109,7 @@
109
109
  },
110
110
  "devDependencies": {
111
111
  "@eslint/js": "^9.0.0",
112
- "@fullstackhouse/open-mercato-durable-work": "^0.2.1",
112
+ "@fullstackhouse/open-mercato-durable-work": "^0.2.2",
113
113
  "@mikro-orm/core": "^7.1.8",
114
114
  "@mikro-orm/migrations": "^7.1.8",
115
115
  "@mikro-orm/postgresql": "^7.1.8",
@@ -192,6 +192,14 @@ export function outcomeOf(recorder: SliceRecorder, runId: string, runStatus?: st
192
192
  // as success would leave a job that says `completed` beside a run that says `failed`.
193
193
  // Committed batches are what distinguishes them: a slice that did real work and then found
194
194
  // the run terminal without recording anything did not simply arrive late.
195
+ // A cancelled run is neither of those: it says plainly who ended it. An operator cancels
196
+ // through core's route, which writes `cancelled` onto the run — and if the job was between
197
+ // slices at that moment, the next delivery starts a slice, core's engine returns early
198
+ // because the run is over, and nothing is captured. Draining here would complete the job
199
+ // beside a run that says `cancelled`, which is the disagreement the mirror exists to
200
+ // prevent. Checked before the seam test, because committed work does not make it a mystery.
201
+ if (runStatus === 'cancelled') return 'cancelled'
202
+
195
203
  if (recorder.committedBatches() > 0 && runStatus && runStatus !== 'running' && runStatus !== 'pending') {
196
204
  throw new SeamBrokenError(runId, `the run reached "${runStatus}" without the adopter recording it`)
197
205
  }
@@ -212,10 +220,17 @@ export async function mirrorRunStatus(
212
220
  // Fenced on the run still being open, so a run finished by another path is not overwritten —
213
221
  // "mirrored" means the domain row agrees, and a row that already disagrees for a good reason
214
222
  // must not be forced.
223
+ //
224
+ // `or status = $2` is what makes that precise rather than merely strict. A row already in the
225
+ // target status *agrees*; this writer simply was not the one that put it there. Treating that
226
+ // as no-match cost a staging environment twenty minutes: core's cancel route writes
227
+ // `cancelled` straight onto the run, so by the time the job mirrored its own cancellation the
228
+ // row already said so, the transition failed, and the only exit from `running` is that same
229
+ // mirror — so it retried until its budget was gone and stopped there.
215
230
  const result = await tx.query(
216
231
  `update sync_runs
217
232
  set status = $2, last_error = $3, updated_at = now()
218
- where id = $1 and deleted_at is null and status in ('pending','running')`,
233
+ where id = $1 and deleted_at is null and (status in ('pending','running') or status = $2)`,
219
234
  [runId, status, errorMessage],
220
235
  )
221
236
  return { matched: result.rowCount }
@@ -0,0 +1,57 @@
1
+ // What a finished slice reports back to the mechanism.
2
+ //
3
+ // The interesting cases are the ones where the slice recorded nothing. That happens when the
4
+ // run was already over before the slice began — and "already over" is not one situation. A run
5
+ // somebody cancelled is a different fact from a run core finalized behind the adopter's back,
6
+ // and reporting either as `drained` would leave a job claiming `completed` beside a run that
7
+ // says otherwise.
8
+
9
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import { SeamBrokenError } from '../modules/data_sync/lib/version-guard'
12
+ import { outcomeOf, type SliceRecorder } from './durable-run'
13
+
14
+ const recorder = (over: Partial<SliceRecorder> = {}): SliceRecorder =>
15
+ ({
16
+ captured: () => null,
17
+ stopReason: () => null,
18
+ committedBatches: () => 0,
19
+ runService: {} as never,
20
+ progressService: {} as never,
21
+ ...over,
22
+ }) as SliceRecorder
23
+
24
+ describe('outcomeOf, when the slice recorded no terminal transition', () => {
25
+ it('reports a run that somebody cancelled as cancelled, not drained', () => {
26
+ // An operator cancels through core's route, which writes `cancelled` onto the run. If the
27
+ // job was between slices at that moment, the next delivery starts a slice, core's engine
28
+ // returns early because the run is over, and nothing is captured. Calling that `drained`
29
+ // completes the job — leaving a job that says `completed` next to a run that says
30
+ // `cancelled`, which is exactly the disagreement the mirror exists to prevent.
31
+ expect(outcomeOf(recorder(), 'run-1', 'cancelled')).toBe('cancelled')
32
+ })
33
+
34
+ it('reports a cancelled run as cancelled even after committing batches', () => {
35
+ // Committed work does not make it a broken seam: the run's status says plainly who ended it.
36
+ expect(outcomeOf(recorder({ committedBatches: () => 12 }), 'run-1', 'cancelled')).toBe('cancelled')
37
+ })
38
+
39
+ it('still drains when the run is simply gone', () => {
40
+ expect(outcomeOf(recorder(), 'run-1', undefined)).toBe('drained')
41
+ })
42
+
43
+ it('still drains when it arrived late to a run that was already terminal', () => {
44
+ expect(outcomeOf(recorder(), 'run-1', 'completed')).toBe('drained')
45
+ })
46
+
47
+ it('still refuses to guess when core finalized a run this slice was working on', () => {
48
+ // The seam this package rests on has moved: real work was committed and the run reached a
49
+ // terminal state without the decorated `markStatus` seeing it.
50
+ expect(() => outcomeOf(recorder({ committedBatches: () => 3 }), 'run-1', 'failed')).toThrow(SeamBrokenError)
51
+ })
52
+
53
+ it('reports the reason the slice stopped, when it stopped deliberately', () => {
54
+ expect(outcomeOf(recorder({ stopReason: () => 'budget' }), 'run-1', 'running')).toBe('budget')
55
+ expect(outcomeOf(recorder({ stopReason: () => 'cancelled' }), 'run-1', 'running')).toBe('cancelled')
56
+ })
57
+ })
@@ -6,7 +6,7 @@
6
6
  // slice budget is spent), and who writes the terminal state (the durable transition, in the
7
7
  // same transaction as the job's own).
8
8
 
9
- import type { KindDefinition, SliceContext, SliceOutcome, SqlExecutor } from '@fullstackhouse/open-mercato-durable-work'
9
+ import { store, type DurableJob, type KindDefinition, type Scope, type SliceContext, type SliceOutcome, type SqlExecutor } from '@fullstackhouse/open-mercato-durable-work'
10
10
 
11
11
  import {
12
12
  mirrorRunStatus,
@@ -133,3 +133,33 @@ export function syncLockKey(integrationId: string, entityType: string, direction
133
133
  export function syncIdempotencyKey(runId: string): string {
134
134
  return `data_sync.run:${runId}`
135
135
  }
136
+
137
+
138
+ /**
139
+ * Tells the mechanism that a run an operator cancelled is cancelled.
140
+ *
141
+ * Core's cancel route writes `cancelled` straight onto the run and knows nothing about the job.
142
+ * The run stops either way — the slice notices through the progress job's cancellation flag —
143
+ * but the *job* never learns, so `cancel_requested_at` is never set, the reconciler's cancelling
144
+ * sweep has nothing to find, and a kind's `onCancel`, which is where external resources are
145
+ * released, never runs. A job sitting between slices is worse: nothing stops it from being
146
+ * delivered again.
147
+ *
148
+ * Returns `no_job` rather than throwing when there is nothing to cancel — a run started before
149
+ * this package was adopted, or one whose job has been reaped. The operator's cancel succeeded;
150
+ * there is simply nothing further to stop.
151
+ */
152
+ export async function cancelDurableJobForRun(
153
+ deps: {
154
+ sql: SqlExecutor
155
+ durable: { cancel(id: string, scope: Scope, by: string | null): Promise<DurableJob | null> }
156
+ },
157
+ runId: string,
158
+ scope: Scope,
159
+ by: string | null,
160
+ ): Promise<'cancelled' | 'no_job'> {
161
+ const job = await store.findByIdempotencyKey(deps.sql, scope, syncIdempotencyKey(runId))
162
+ if (!job) return 'no_job'
163
+ await deps.durable.cancel(job.id, scope, by)
164
+ return 'cancelled'
165
+ }
@@ -7,7 +7,6 @@
7
7
  "api/mappings/route.ts": "5551bc5ff61dfebb",
8
8
  "api/options.ts": "bff527b4061b6c2b",
9
9
  "api/runs.ts": "a2024a96d896c814",
10
- "api/runs/[id]/cancel.ts": "1c99c66183d994ae",
11
10
  "api/runs/[id]/retry.ts": "cf6ea67efcebdeb9",
12
11
  "api/runs/[id]/route.ts": "2d0e39daef513f92",
13
12
  "api/schedules/[id]/route.ts": "af2b16bd45fd02b5",
@@ -50,6 +49,7 @@
50
49
  },
51
50
  "replaced": {
52
51
  "api/run.ts": "0eda5b6869a0b95d",
52
+ "api/runs/[id]/cancel.ts": "1c99c66183d994ae",
53
53
  "di.ts": "d351534bd20b2b29",
54
54
  "lib/start-run.ts": "74081590458b202b",
55
55
  "workers/sync-export.ts": "a23cad4d03dd42da",
@@ -1,10 +1,79 @@
1
- // GENERATED by scripts/gen-mirror.mjs from @open-mercato/core@0.7.0 (api/runs/[id]/cancel.ts). Do not edit.
2
- // This package mirrors core's data_sync module file-for-file; see docs/adr/0006-drop-in-data-sync.md.
3
- export * from '@open-mercato/core/modules/data_sync/api/runs/[id]/cancel'
4
- export { POST } from '@open-mercato/core/modules/data_sync/api/runs/[id]/cancel'
5
- import { openApi as coreOpenApi } from '@open-mercato/core/modules/data_sync/api/runs/[id]/cancel'
6
- export const openApi = coreOpenApi
1
+ // Core's cancel route, with the durable job told about it.
2
+ //
3
+ // Wrapped for the same reason as `api/run.ts`: core's route is the one an operator actually
4
+ // reaches, and it knows nothing about durable work. It writes `cancelled` onto the run and
5
+ // cancels the progress job which does stop a slice that is running, because the slice checks
6
+ // the progress job's cancellation flag at every batch boundary.
7
+ //
8
+ // What it does not do is tell the *job*. Left there, `cancel_requested_at` is never set, so the
9
+ // reconciler's cancelling sweep has nothing to find and a kind's `onCancel` — where external
10
+ // resources are released — never runs. A job sitting between slices is worse: nothing stops the
11
+ // next delivery from starting another one.
12
+ //
13
+ // After core's call, not before: if the durable cancel ran first, the job could reach its
14
+ // terminal state and mirror `cancelled` onto a run that core is still about to write, and the
15
+ // operator's own request would be the one racing.
16
+ //
17
+ // Never fails the request. The operator asked to cancel a run and the run is cancelled; a
18
+ // failure to also stop the job is reported in a header and repaired by the reconciler, which is
19
+ // what it is for.
20
+
21
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
22
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
23
+ import { createLogger } from '@open-mercato/shared/lib/logger'
24
+ import {
25
+ POST as corePost,
26
+ openApi as coreOpenApi,
27
+ } from '@open-mercato/core/modules/data_sync/api/runs/[id]/cancel'
28
+ import type { DurableWorkService, SqlExecutor } from '@fullstackhouse/open-mercato-durable-work'
29
+
30
+ import { cancelDurableJobForRun } from '../../../../../kinds/data-sync-run'
31
+
32
+ const logger = createLogger('data_sync').child({ component: 'durable-cancel' })
33
+
34
+ const DURABLE_HEADER = 'x-durable-work'
35
+
7
36
  // Copied from core: the generator reads this by AST and cannot follow a re-export.
8
37
  export const metadata = {
9
38
  POST: { requireAuth: true, requireFeatures: ['data_sync.run'] },
10
39
  }
40
+
41
+ export const openApi = coreOpenApi
42
+
43
+ export async function POST(req: Request, ctx: { params?: Promise<{ id?: string }> | { id?: string } }) {
44
+ const response = await corePost(req, ctx)
45
+ if (response.status !== 200) return response
46
+
47
+ try {
48
+ const rawParams =
49
+ ctx.params && typeof (ctx.params as Promise<unknown>).then === 'function'
50
+ ? await (ctx.params as Promise<{ id?: string }>)
51
+ : (ctx.params as { id?: string } | undefined)
52
+ const runId = rawParams?.id
53
+ const auth = await getAuthFromRequest(req)
54
+ if (!runId || !auth?.tenantId) {
55
+ response.headers.set(DURABLE_HEADER, 'deferred')
56
+ return response
57
+ }
58
+
59
+ const container = await createRequestContainer()
60
+ const outcome = await cancelDurableJobForRun(
61
+ {
62
+ sql: container.resolve('durableWorkSql') as SqlExecutor,
63
+ durable: container.resolve('durableWorkService') as DurableWorkService,
64
+ },
65
+ runId,
66
+ { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
67
+ auth.sub ?? null,
68
+ )
69
+ response.headers.set(DURABLE_HEADER, outcome)
70
+ } catch (error) {
71
+ logger.warn('Could not cancel the durable job for a cancelled run; the reconciler will settle it', {
72
+ err: error as Error,
73
+ })
74
+ response.headers.set(DURABLE_HEADER, 'deferred')
75
+ response.headers.set(`${DURABLE_HEADER}-reason`, error instanceof Error ? error.message.slice(0, 200) : 'unknown')
76
+ }
77
+
78
+ return response
79
+ }