@fullstackhouse/open-mercato-data-sync-durable 0.1.3 → 0.1.4

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.
@@ -158,6 +158,7 @@ async function replayFinalize(deps, run, status, errorMessage, scope, userId) {
158
158
  if (enabled) {
159
159
  const log = status === "completed" ? {
160
160
  level: "info",
161
+ code: "data_sync.run_completed",
161
162
  message: "Sync run completed",
162
163
  payload: {
163
164
  operationalStatus: "completed",
@@ -170,10 +171,12 @@ async function replayFinalize(deps, run, status, errorMessage, scope, userId) {
170
171
  }
171
172
  } : status === "cancelled" ? {
172
173
  level: "warn",
174
+ code: "data_sync.run_cancelled",
173
175
  message: "Sync run cancelled",
174
176
  payload: { operationalStatus: "cancelled", summary: "The sync run was cancelled before completion." }
175
177
  } : {
176
178
  level: "error",
179
+ code: "data_sync.run_failed",
177
180
  message: errorMessage ?? "Sync run failed",
178
181
  payload: { operationalStatus: "failed", summary: errorMessage ?? "The sync run failed." }
179
182
  };
@@ -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 const log =\n status === 'completed'\n ? {\n level: 'info',\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 message: 'Sync run cancelled',\n payload: { operationalStatus: 'cancelled', summary: 'The sync run was cancelled before completion.' },\n }\n : {\n level: 'error',\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 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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",
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.1.3",
3
+ "version": "0.1.4",
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.1.3",
97
+ "@fullstackhouse/open-mercato-durable-work": "^0.1.4",
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.1.3",
112
+ "@fullstackhouse/open-mercato-durable-work": "^0.1.4",
113
113
  "@mikro-orm/core": "^7.1.8",
114
114
  "@mikro-orm/migrations": "^7.1.8",
115
115
  "@mikro-orm/postgresql": "^7.1.8",
@@ -324,10 +324,16 @@ export async function replayFinalize(
324
324
  }
325
325
 
326
326
  if (enabled) {
327
+ // `code` is core's own field on an integration log row, and core's `finalizeRun` leaves it
328
+ // unset. It is set here because a host that reports error rows outward — reading `code` as
329
+ // the fingerprint, which the integration log service does — would otherwise group every
330
+ // durable terminal failure under a generic fallback, losing the one attribute separating a
331
+ // dead sync run from any other integration error.
327
332
  const log =
328
333
  status === 'completed'
329
334
  ? {
330
335
  level: 'info',
336
+ code: 'data_sync.run_completed',
331
337
  message: 'Sync run completed',
332
338
  payload: {
333
339
  operationalStatus: 'completed',
@@ -342,11 +348,13 @@ export async function replayFinalize(
342
348
  : status === 'cancelled'
343
349
  ? {
344
350
  level: 'warn',
351
+ code: 'data_sync.run_cancelled',
345
352
  message: 'Sync run cancelled',
346
353
  payload: { operationalStatus: 'cancelled', summary: 'The sync run was cancelled before completion.' },
347
354
  }
348
355
  : {
349
356
  level: 'error',
357
+ code: 'data_sync.run_failed',
350
358
  message: errorMessage ?? 'Sync run failed',
351
359
  payload: { operationalStatus: 'failed', summary: errorMessage ?? 'The sync run failed.' },
352
360
  }
@@ -65,6 +65,10 @@ describe('replayFinalize', () => {
65
65
  )
66
66
  // Dispatched to tenant webhooks, so its absence is visible outside the app entirely.
67
67
  expect(d.emitEvent).toHaveBeenCalledWith('data_sync.run.completed', expect.objectContaining({ runId: 'run-1' }))
68
+ expect(d.integrationLogService.write).toHaveBeenCalledWith(
69
+ expect.objectContaining({ level: 'info', code: 'data_sync.run_completed' }),
70
+ scope,
71
+ )
68
72
  })
69
73
 
70
74
  it('fails the progress job and reports the error on failure', async () => {
@@ -81,8 +85,16 @@ describe('replayFinalize', () => {
81
85
  expect.objectContaining({ lastHealthStatus: 'unhealthy' }),
82
86
  scope,
83
87
  )
88
+ // `code` is the fingerprint a host groups error rows by — the integration log service
89
+ // reports every error row outward using it. Without it, a dead sync run is indistinguishable
90
+ // from any other integration error.
84
91
  expect(d.integrationLogService.write).toHaveBeenCalledWith(
85
- expect.objectContaining({ level: 'error', message: 'batch 39: timeout', runId: 'run-1' }),
92
+ expect.objectContaining({
93
+ level: 'error',
94
+ code: 'data_sync.run_failed',
95
+ message: 'batch 39: timeout',
96
+ runId: 'run-1',
97
+ }),
86
98
  scope,
87
99
  )
88
100
  // Only the failure event carries `error`; a subscriber switching on it would otherwise see