@fullstackhouse/open-mercato-data-sync-durable 0.1.0 → 0.1.1

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.
@@ -23,8 +23,10 @@ function recordSlice(ctx, runService, progressService) {
23
23
  * Core's `finalizeRun` compares what comes back to what it asked for, and stays silent
24
24
  * when they differ — the branch it has for "another worker already finalized this". So
25
25
  * this both captures the outcome and suppresses the progress write, the operational log
26
- * and the lifecycle event, leaving all three to the durable terminal transition, which
27
- * writes them in the same transaction as the job's own terminal state.
26
+ * and the lifecycle event. The run's terminal status is then written by `onTransition`,
27
+ * in the same transaction as the job's own; the three suppressed side effects are
28
+ * replayed by `replayFinalize` after that commit, since an event inside a transaction
29
+ * that can still roll back is a lie waiting to happen.
28
30
  */
29
31
  async markStatus(runId, status, scope, error) {
30
32
  if (status === "completed" || status === "failed" || status === "cancelled") {
@@ -120,11 +122,81 @@ async function reopenRun(tx, runId) {
120
122
  );
121
123
  return { matched: result.rowCount };
122
124
  }
125
+ async function replayFinalize(deps, run, status, errorMessage, scope, userId) {
126
+ const progressScope = { tenantId: scope.tenantId, organizationId: scope.organizationId, userId: userId ?? void 0 };
127
+ const enabled = deps.operationalTelemetry(run.integrationId);
128
+ if (run.progressJobId) {
129
+ const progress = deps.progressService;
130
+ if (status === "completed") {
131
+ await progress.completeJob?.(
132
+ run.progressJobId,
133
+ {
134
+ resultSummary: {
135
+ createdCount: run.createdCount,
136
+ updatedCount: run.updatedCount,
137
+ skippedCount: run.skippedCount,
138
+ failedCount: run.failedCount,
139
+ batchesCompleted: run.batchesCompleted
140
+ }
141
+ },
142
+ progressScope
143
+ );
144
+ } else if (status === "failed") {
145
+ await progress.failJob?.(run.progressJobId, { errorMessage: errorMessage ?? "Sync run failed" }, progressScope);
146
+ } else {
147
+ await progress.markCancelled?.(run.progressJobId, progressScope);
148
+ }
149
+ }
150
+ const health = status === "completed" ? "healthy" : status === "cancelled" ? "degraded" : "unhealthy";
151
+ if (enabled && deps.integrationStateService) {
152
+ await deps.integrationStateService.upsert(
153
+ run.integrationId,
154
+ { lastHealthStatus: health, lastHealthCheckedAt: /* @__PURE__ */ new Date() },
155
+ scope
156
+ );
157
+ }
158
+ if (enabled) {
159
+ const log = status === "completed" ? {
160
+ level: "info",
161
+ message: "Sync run completed",
162
+ payload: {
163
+ operationalStatus: "completed",
164
+ summary: `Sync completed with ${run.createdCount ?? 0} created, ${run.updatedCount ?? 0} updated, ${run.failedCount ?? 0} failed.`,
165
+ createdCount: run.createdCount,
166
+ updatedCount: run.updatedCount,
167
+ skippedCount: run.skippedCount,
168
+ failedCount: run.failedCount,
169
+ batchesCompleted: run.batchesCompleted
170
+ }
171
+ } : status === "cancelled" ? {
172
+ level: "warn",
173
+ message: "Sync run cancelled",
174
+ payload: { operationalStatus: "cancelled", summary: "The sync run was cancelled before completion." }
175
+ } : {
176
+ level: "error",
177
+ message: errorMessage ?? "Sync run failed",
178
+ payload: { operationalStatus: "failed", summary: errorMessage ?? "The sync run failed." }
179
+ };
180
+ await deps.integrationLogService.write({ integrationId: run.integrationId, runId: run.id, ...log }, scope);
181
+ }
182
+ await deps.emitEvent(`data_sync.run.${status}`, {
183
+ runId: run.id,
184
+ integrationId: run.integrationId,
185
+ entityType: run.entityType,
186
+ direction: run.direction,
187
+ // Only the failure event carries this, and a subscriber that switches on it would see
188
+ // every durable failure as an unexplained one if it were dropped.
189
+ ...status === "failed" ? { error: errorMessage ?? null } : {},
190
+ tenantId: scope.tenantId,
191
+ organizationId: scope.organizationId
192
+ });
193
+ }
123
194
  export {
124
195
  SyncRunFailedError,
125
196
  mirrorRunStatus,
126
197
  outcomeOf,
127
198
  recordSlice,
128
- reopenRun
199
+ reopenRun,
200
+ replayFinalize
129
201
  };
130
202
  //# sourceMappingURL=durable-run.js.map
@@ -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, leaving all three to the durable terminal transition, which\n * writes them in the same transaction as the job's own terminal state.\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"],
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 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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",
6
6
  "names": []
7
7
  }
@@ -2,7 +2,8 @@ import {
2
2
  mirrorRunStatus,
3
3
  outcomeOf,
4
4
  recordSlice,
5
- reopenRun
5
+ reopenRun,
6
+ replayFinalize
6
7
  } from "../engine/durable-run.js";
7
8
  const IMPORT_KIND = "data_sync.import";
8
9
  const EXPORT_KIND = "data_sync.export";
@@ -33,6 +34,26 @@ function dataSyncKinds(deps) {
33
34
  },
34
35
  async onRedrive(job, _scope, tx) {
35
36
  return reopenRun(tx, job.input.runId);
37
+ },
38
+ /**
39
+ * The rest of core's `finalizeRun`, which core itself skips on every durable run.
40
+ *
41
+ * Without this a host is told the run is over by the `sync_runs` row alone: the progress
42
+ * job an operator watches never resolves, the integration's health state never moves, and
43
+ * `data_sync.run.completed` — a tenant webhook — is never dispatched. The decoration that
44
+ * makes a run durable is supposed to be invisible to a host, and those three are exactly
45
+ * how a host would notice.
46
+ *
47
+ * At-most-once and best-effort by the mechanism's contract, which is the same standing
48
+ * these have in core: a webhook that fails there has never un-completed a run either.
49
+ */
50
+ async onAfterTransition(job, scope) {
51
+ const { runId } = job.input;
52
+ const status = job.status === "completed" ? "completed" : job.status === "cancelled" ? "cancelled" : "failed";
53
+ const { runService, finalize } = await deps.resolve();
54
+ const run = await runService.getRun(runId, scope);
55
+ if (!run) return;
56
+ await replayFinalize(finalize, run, status, job.errorMessage, scope, job.createdBy);
36
57
  }
37
58
  };
38
59
  return [
@@ -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 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 }>\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 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,OAIK;AAEA,MAAM,cAAc;AACpB,MAAM,cAAc;AACpB,MAAM,kBAAkB;AAuB/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,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 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;",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,7 @@
1
1
  import { register as registerCore } from "@open-mercato/core/modules/data_sync/di";
2
2
  import { createSyncEngine } from "@open-mercato/core/modules/data_sync/lib/sync-engine";
3
+ import { getDataSyncAdapter, resolveProviderKey } from "@open-mercato/core/modules/data_sync/lib/adapter-registry";
4
+ import { emitDataSyncEvent } from "@open-mercato/core/modules/data_sync/events";
3
5
  import { registry } from "@fullstackhouse/open-mercato-durable-work";
4
6
  import { dataSyncKinds } from "../../kinds/data-sync-run.js";
5
7
  const KINDS = dataSyncKinds({
@@ -7,16 +9,28 @@ const KINDS = dataSyncKinds({
7
9
  const { createRequestContainer } = await import("@open-mercato/shared/lib/di/container");
8
10
  const container = await createRequestContainer();
9
11
  const em = container.resolve("em");
12
+ const progressService = container.resolve("progressService");
13
+ const integrationLogService = container.resolve("integrationLogService");
14
+ const integrationStateService = container.resolve("integrationStateService");
10
15
  return {
11
16
  runService: container.resolve("dataSyncRunService"),
12
- progressService: container.resolve("progressService"),
13
- engine: ({ runService, progressService }) => createSyncEngine({
17
+ progressService,
18
+ // Resolved from core's own registry and core's own event bus, so the replayed tail is
19
+ // core's behaviour rather than a second opinion about what it should have been.
20
+ finalize: {
21
+ progressService,
22
+ integrationLogService,
23
+ integrationStateService,
24
+ operationalTelemetry: (integrationId) => getDataSyncAdapter(resolveProviderKey(integrationId))?.operationalTelemetry === true,
25
+ emitEvent: (name, payload) => emitDataSyncEvent(name, payload)
26
+ },
27
+ engine: ({ runService, progressService: progressService2 }) => createSyncEngine({
14
28
  em,
15
29
  syncRunService: runService,
16
30
  integrationCredentialsService: container.resolve("integrationCredentialsService"),
17
- integrationLogService: container.resolve("integrationLogService"),
18
- integrationStateService: container.resolve("integrationStateService"),
19
- progressService
31
+ integrationLogService,
32
+ integrationStateService,
33
+ progressService: progressService2
20
34
  })
21
35
  };
22
36
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/data_sync/di.ts"],
4
- "sourcesContent": ["// Core's DI, plus the two job kinds a run becomes.\n//\n// Core's registrations are reused as they are \u2014 the engine, the run service, the mapping and\n// schedule services are all core's, unchanged. The decoration that makes a run durable happens\n// per slice, inside the kind, not here (see docs/adr/0004): a container-level decoration would\n// apply to every caller of the engine, including ones not running under a lease.\n\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { register as registerCore } from '@open-mercato/core/modules/data_sync/di'\nimport { createSyncEngine } from '@open-mercato/core/modules/data_sync/lib/sync-engine'\nimport { registry } from '@fullstackhouse/open-mercato-durable-work'\n\nimport { dataSyncKinds } from '../../kinds/data-sync-run'\n\n/**\n * Built once, at module scope, not once per container.\n *\n * `register` runs on every container build \u2014 which in a web process means every request \u2014 and\n * the registry refuses two different handlers under one kind id, because silently accepting\n * the second would make \"which code is running this job?\" depend on request ordering. Kinds\n * created per call would be a different handler every time, so the second request would throw.\n *\n * Each slice resolves its own container instead, which it needs anyway: slices run in the\n * worker process, outside any request, and each needs its own EntityManager rather than one\n * captured from whichever request happened to build the registry first.\n */\nconst KINDS = dataSyncKinds({\n resolve: async () => {\n const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n return {\n runService: container.resolve('dataSyncRunService'),\n progressService: container.resolve('progressService'),\n engine: ({ runService, progressService }) =>\n createSyncEngine({\n em,\n syncRunService: runService as never,\n integrationCredentialsService: container.resolve('integrationCredentialsService'),\n integrationLogService: container.resolve('integrationLogService'),\n integrationStateService: container.resolve('integrationStateService'),\n progressService: progressService as never,\n }),\n }\n },\n})\n\nexport function register(container: AppContainer) {\n registerCore(container)\n // Idempotent: the same handler objects every time, so re-registering is a no-op. The web\n // process needs them to re-drive and cancel, the worker to run slices, and the reconciler to\n // know whether a job has a handler at all.\n for (const kind of KINDS) registry.register(kind)\n}\n\nexport default register\n"],
5
- "mappings": "AASA,SAAS,YAAY,oBAAoB;AACzC,SAAS,wBAAwB;AACjC,SAAS,gBAAgB;AAEzB,SAAS,qBAAqB;AAc9B,MAAM,QAAQ,cAAc;AAAA,EAC1B,SAAS,YAAY;AACnB,UAAM,EAAE,uBAAuB,IAAI,MAAM,OAAO,uCAAuC;AACvF,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,KAAK,UAAU,QAAuB,IAAI;AAChD,WAAO;AAAA,MACL,YAAY,UAAU,QAAQ,oBAAoB;AAAA,MAClD,iBAAiB,UAAU,QAAQ,iBAAiB;AAAA,MACpD,QAAQ,CAAC,EAAE,YAAY,gBAAgB,MACrC,iBAAiB;AAAA,QACf;AAAA,QACA,gBAAgB;AAAA,QAChB,+BAA+B,UAAU,QAAQ,+BAA+B;AAAA,QAChF,uBAAuB,UAAU,QAAQ,uBAAuB;AAAA,QAChE,yBAAyB,UAAU,QAAQ,yBAAyB;AAAA,QACpE;AAAA,MACF,CAAC;AAAA,IACL;AAAA,EACF;AACF,CAAC;AAEM,SAAS,SAAS,WAAyB;AAChD,eAAa,SAAS;AAItB,aAAW,QAAQ,MAAO,UAAS,SAAS,IAAI;AAClD;AAEA,IAAO,aAAQ;",
6
- "names": []
4
+ "sourcesContent": ["// Core's DI, plus the two job kinds a run becomes.\n//\n// Core's registrations are reused as they are \u2014 the engine, the run service, the mapping and\n// schedule services are all core's, unchanged. The decoration that makes a run durable happens\n// per slice, inside the kind, not here (see docs/adr/0004): a container-level decoration would\n// apply to every caller of the engine, including ones not running under a lease.\n\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { register as registerCore } from '@open-mercato/core/modules/data_sync/di'\nimport { createSyncEngine } from '@open-mercato/core/modules/data_sync/lib/sync-engine'\nimport { getDataSyncAdapter, resolveProviderKey } from '@open-mercato/core/modules/data_sync/lib/adapter-registry'\nimport { emitDataSyncEvent } from '@open-mercato/core/modules/data_sync/events'\nimport { registry } from '@fullstackhouse/open-mercato-durable-work'\n\nimport { dataSyncKinds } from '../../kinds/data-sync-run'\n\n/**\n * Built once, at module scope, not once per container.\n *\n * `register` runs on every container build \u2014 which in a web process means every request \u2014 and\n * the registry refuses two different handlers under one kind id, because silently accepting\n * the second would make \"which code is running this job?\" depend on request ordering. Kinds\n * created per call would be a different handler every time, so the second request would throw.\n *\n * Each slice resolves its own container instead, which it needs anyway: slices run in the\n * worker process, outside any request, and each needs its own EntityManager rather than one\n * captured from whichever request happened to build the registry first.\n */\nconst KINDS = dataSyncKinds({\n resolve: async () => {\n const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n const progressService = container.resolve('progressService')\n const integrationLogService = container.resolve('integrationLogService')\n const integrationStateService = container.resolve('integrationStateService')\n return {\n runService: container.resolve('dataSyncRunService'),\n progressService,\n // Resolved from core's own registry and core's own event bus, so the replayed tail is\n // core's behaviour rather than a second opinion about what it should have been.\n finalize: {\n progressService,\n integrationLogService,\n integrationStateService,\n operationalTelemetry: (integrationId: string) =>\n getDataSyncAdapter(resolveProviderKey(integrationId))?.operationalTelemetry === true,\n emitEvent: (name, payload) => emitDataSyncEvent(name as never, payload as never),\n },\n engine: ({ runService, progressService }) =>\n createSyncEngine({\n em,\n syncRunService: runService as never,\n integrationCredentialsService: container.resolve('integrationCredentialsService'),\n integrationLogService,\n integrationStateService,\n progressService: progressService as never,\n }),\n }\n },\n})\n\nexport function register(container: AppContainer) {\n registerCore(container)\n // Idempotent: the same handler objects every time, so re-registering is a no-op. The web\n // process needs them to re-drive and cancel, the worker to run slices, and the reconciler to\n // know whether a job has a handler at all.\n for (const kind of KINDS) registry.register(kind)\n}\n\nexport default register\n"],
5
+ "mappings": "AASA,SAAS,YAAY,oBAAoB;AACzC,SAAS,wBAAwB;AACjC,SAAS,oBAAoB,0BAA0B;AACvD,SAAS,yBAAyB;AAClC,SAAS,gBAAgB;AAEzB,SAAS,qBAAqB;AAc9B,MAAM,QAAQ,cAAc;AAAA,EAC1B,SAAS,YAAY;AACnB,UAAM,EAAE,uBAAuB,IAAI,MAAM,OAAO,uCAAuC;AACvF,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,KAAK,UAAU,QAAuB,IAAI;AAChD,UAAM,kBAAkB,UAAU,QAAQ,iBAAiB;AAC3D,UAAM,wBAAwB,UAAU,QAAQ,uBAAuB;AACvE,UAAM,0BAA0B,UAAU,QAAQ,yBAAyB;AAC3E,WAAO;AAAA,MACL,YAAY,UAAU,QAAQ,oBAAoB;AAAA,MAClD;AAAA;AAAA;AAAA,MAGA,UAAU;AAAA,QACR;AAAA,QACA;AAAA,QACA;AAAA,QACA,sBAAsB,CAAC,kBACrB,mBAAmB,mBAAmB,aAAa,CAAC,GAAG,yBAAyB;AAAA,QAClF,WAAW,CAAC,MAAM,YAAY,kBAAkB,MAAe,OAAgB;AAAA,MACjF;AAAA,MACA,QAAQ,CAAC,EAAE,YAAY,iBAAAA,iBAAgB,MACrC,iBAAiB;AAAA,QACf;AAAA,QACA,gBAAgB;AAAA,QAChB,+BAA+B,UAAU,QAAQ,+BAA+B;AAAA,QAChF;AAAA,QACA;AAAA,QACA,iBAAiBA;AAAA,MACnB,CAAC;AAAA,IACL;AAAA,EACF;AACF,CAAC;AAEM,SAAS,SAAS,WAAyB;AAChD,eAAa,SAAS;AAItB,aAAW,QAAQ,MAAO,UAAS,SAAS,IAAI;AAClD;AAEA,IAAO,aAAQ;",
6
+ "names": ["progressService"]
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.0",
3
+ "version": "0.1.1",
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",
@@ -32,7 +32,7 @@
32
32
  "gen:mirror": "node scripts/gen-mirror.mjs",
33
33
  "prebuild": "node scripts/gen-mirror.mjs --check",
34
34
  "build": "node build.mjs",
35
- "prepack": "yarn build",
35
+ "prepack": "node scripts/gen-mirror.mjs --check && node build.mjs",
36
36
  "watch": "node watch.mjs",
37
37
  "test": "vitest run",
38
38
  "test:watch": "vitest",
@@ -94,7 +94,7 @@
94
94
  }
95
95
  },
96
96
  "peerDependencies": {
97
- "@fullstackhouse/open-mercato-durable-work": "^0.1.0",
97
+ "@fullstackhouse/open-mercato-durable-work": "^0.1.1",
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.0",
112
+ "@fullstackhouse/open-mercato-durable-work": "^0.1.1",
113
113
  "@mikro-orm/core": "^7.1.8",
114
114
  "@mikro-orm/migrations": "^7.1.8",
115
115
  "@mikro-orm/postgresql": "^7.1.8",
@@ -59,6 +59,37 @@ describe('the seams the durable adopter decorates', () => {
59
59
  expect(engine).toMatch(/if \(run\.status !== status\) \{/)
60
60
  })
61
61
 
62
+ it('still ends finalizeRun with exactly the three side effects the adopter replays', () => {
63
+ // The skipped tail. Core stops at its "another worker already finalized this" branch on
64
+ // every durable run, so everything after it is ours to replay — the progress job, the
65
+ // operational writes and the lifecycle event (see `replayFinalize`).
66
+ //
67
+ // Asserted here because the failure mode is silent in both directions: a side effect
68
+ // upstream *adds* to this tail would simply never happen on a durable run, and nothing in
69
+ // a host would report it missing. That is precisely how the first three came to be dropped
70
+ // for a whole release.
71
+ const start = engine.indexOf('async function finalizeRun')
72
+ // Bounded at the next declaration: unbounded, this runs to the end of the file and sweeps
73
+ // up `runImport`/`runExport`, which make the same kinds of call for their own reasons.
74
+ const next = engine.indexOf('\n return {', start + 1)
75
+ const tail = engine.slice(start, next === -1 ? undefined : next)
76
+
77
+ for (const call of ['progressService.completeJob(', 'progressService.failJob(', 'progressService.markCancelled(']) {
78
+ expect({ call, present: tail.includes(call) }).toEqual({ call, present: true })
79
+ }
80
+ expect(tail).toMatch(/emitDataSyncEvent\('data_sync\.run\.completed'/)
81
+ expect(tail).toMatch(/emitDataSyncEvent\('data_sync\.run\.failed'/)
82
+ expect(tail).toMatch(/emitDataSyncEvent\('data_sync\.run\.cancelled'/)
83
+
84
+ // The gate on the two operational writes, and deliberately not on the event.
85
+ expect(tail).toMatch(/enabled: operationalTelemetry/)
86
+
87
+ // A fourth kind of side effect in the tail means `replayFinalize` is now incomplete. The
88
+ // count is the tripwire: it is meant to be re-read, not bumped.
89
+ const awaited = tail.match(/await (progressService|updateOperationalState|writeOperationalLog|emitDataSyncEvent)\b/g) ?? []
90
+ expect(awaited.length).toBe(12)
91
+ })
92
+
62
93
  it('still asks whether to cancel at every batch boundary', () => {
63
94
  // The hand-back point. Without it a slice has nowhere to stop, and the mechanism's
64
95
  // headline property — a deploy does not kill a multi-day run — is gone.
@@ -85,8 +85,10 @@ export function recordSlice(
85
85
  * Core's `finalizeRun` compares what comes back to what it asked for, and stays silent
86
86
  * when they differ — the branch it has for "another worker already finalized this". So
87
87
  * this both captures the outcome and suppresses the progress write, the operational log
88
- * and the lifecycle event, leaving all three to the durable terminal transition, which
89
- * writes them in the same transaction as the job's own terminal state.
88
+ * and the lifecycle event. The run's terminal status is then written by `onTransition`,
89
+ * in the same transaction as the job's own; the three suppressed side effects are
90
+ * replayed by `replayFinalize` after that commit, since an event inside a transaction
91
+ * that can still roll back is a lie waiting to happen.
90
92
  */
91
93
  async markStatus(runId, status, scope, error) {
92
94
  if (status === 'completed' || status === 'failed' || status === 'cancelled') {
@@ -229,3 +231,138 @@ export async function reopenRun(tx: SqlExecutor, runId: string): Promise<{ match
229
231
  )
230
232
  return { matched: result.rowCount }
231
233
  }
234
+
235
+ /**
236
+ * The services core's `finalizeRun` reaches for once the status is written.
237
+ *
238
+ * Named separately from the slice's dependencies because these are needed at a different
239
+ * moment: the slice runs under a lease, this runs after the job's terminal transition has
240
+ * committed, on whatever worker got there.
241
+ */
242
+ export type FinalizeDeps = {
243
+ progressService: ProgressServiceLike
244
+ integrationLogService: { write(entry: Record<string, unknown>, scope: SyncScope): Promise<unknown> }
245
+ integrationStateService?: {
246
+ upsert(integrationId: string, patch: Record<string, unknown>, scope: SyncScope): Promise<unknown>
247
+ } | null
248
+ /** `adapter.operationalTelemetry === true`, which is core's own gate on the two writes below. */
249
+ operationalTelemetry(integrationId: string): boolean
250
+ emitEvent(name: string, payload: Record<string, unknown>): Promise<void>
251
+ }
252
+
253
+ /** The run fields core's tail reads. */
254
+ export type FinalizedRun = {
255
+ id: string
256
+ integrationId: string
257
+ entityType: string
258
+ direction: string
259
+ progressJobId?: string | null
260
+ createdCount?: number
261
+ updatedCount?: number
262
+ skippedCount?: number
263
+ failedCount?: number
264
+ batchesCompleted?: number
265
+ }
266
+
267
+ /**
268
+ * Everything core's `finalizeRun` does after the status write, done here instead.
269
+ *
270
+ * Core stops at its "another worker already finalized this" branch on every durable run — that
271
+ * is deliberate, and it is what lets the durable transition own the terminal state (see
272
+ * `markStatus` above). But stopping there also skips the three things that came after it: the
273
+ * progress job is never resolved, the operational log never written, and the lifecycle event
274
+ * never emitted. Left unreplayed, a host loses the progress indicator an operator watches, the
275
+ * integration health state, and `data_sync.run.completed` — which is dispatched to tenant
276
+ * webhooks, so its absence is visible outside the app entirely.
277
+ *
278
+ * This runs after the commit, not inside it: events and enqueues must not sit in a transaction
279
+ * that can still roll back, and the mechanism gives after-commit hooks at-most-once semantics
280
+ * for exactly this. A throw here is logged and dropped rather than retried — the same standing
281
+ * core's own tail has, where a failed webhook has never un-completed a run.
282
+ */
283
+ export async function replayFinalize(
284
+ deps: FinalizeDeps,
285
+ run: FinalizedRun,
286
+ status: SyncTerminalStatus,
287
+ errorMessage: string | null,
288
+ scope: SyncScope,
289
+ userId: string | null,
290
+ ): Promise<void> {
291
+ const progressScope = { tenantId: scope.tenantId, organizationId: scope.organizationId, userId: userId ?? undefined }
292
+ const enabled = deps.operationalTelemetry(run.integrationId)
293
+
294
+ if (run.progressJobId) {
295
+ const progress = deps.progressService as unknown as Record<string, ((...args: unknown[]) => Promise<unknown>) | undefined>
296
+ if (status === 'completed') {
297
+ await progress.completeJob?.(
298
+ run.progressJobId,
299
+ {
300
+ resultSummary: {
301
+ createdCount: run.createdCount,
302
+ updatedCount: run.updatedCount,
303
+ skippedCount: run.skippedCount,
304
+ failedCount: run.failedCount,
305
+ batchesCompleted: run.batchesCompleted,
306
+ },
307
+ },
308
+ progressScope,
309
+ )
310
+ } else if (status === 'failed') {
311
+ await progress.failJob?.(run.progressJobId, { errorMessage: errorMessage ?? 'Sync run failed' }, progressScope)
312
+ } else {
313
+ await progress.markCancelled?.(run.progressJobId, progressScope)
314
+ }
315
+ }
316
+
317
+ const health = status === 'completed' ? 'healthy' : status === 'cancelled' ? 'degraded' : 'unhealthy'
318
+ if (enabled && deps.integrationStateService) {
319
+ await deps.integrationStateService.upsert(
320
+ run.integrationId,
321
+ { lastHealthStatus: health, lastHealthCheckedAt: new Date() },
322
+ scope,
323
+ )
324
+ }
325
+
326
+ if (enabled) {
327
+ const log =
328
+ status === 'completed'
329
+ ? {
330
+ level: 'info',
331
+ message: 'Sync run completed',
332
+ payload: {
333
+ operationalStatus: 'completed',
334
+ summary: `Sync completed with ${run.createdCount ?? 0} created, ${run.updatedCount ?? 0} updated, ${run.failedCount ?? 0} failed.`,
335
+ createdCount: run.createdCount,
336
+ updatedCount: run.updatedCount,
337
+ skippedCount: run.skippedCount,
338
+ failedCount: run.failedCount,
339
+ batchesCompleted: run.batchesCompleted,
340
+ },
341
+ }
342
+ : status === 'cancelled'
343
+ ? {
344
+ level: 'warn',
345
+ message: 'Sync run cancelled',
346
+ payload: { operationalStatus: 'cancelled', summary: 'The sync run was cancelled before completion.' },
347
+ }
348
+ : {
349
+ level: 'error',
350
+ message: errorMessage ?? 'Sync run failed',
351
+ payload: { operationalStatus: 'failed', summary: errorMessage ?? 'The sync run failed.' },
352
+ }
353
+
354
+ await deps.integrationLogService.write({ integrationId: run.integrationId, runId: run.id, ...log }, scope)
355
+ }
356
+
357
+ await deps.emitEvent(`data_sync.run.${status}`, {
358
+ runId: run.id,
359
+ integrationId: run.integrationId,
360
+ entityType: run.entityType,
361
+ direction: run.direction,
362
+ // Only the failure event carries this, and a subscriber that switches on it would see
363
+ // every durable failure as an unexplained one if it were dropped.
364
+ ...(status === 'failed' ? { error: errorMessage ?? null } : {}),
365
+ tenantId: scope.tenantId,
366
+ organizationId: scope.organizationId,
367
+ })
368
+ }
@@ -0,0 +1,131 @@
1
+ // What core's `finalizeRun` does after the status write, and what we do instead.
2
+ //
3
+ // These exist because the gap they close was invisible for a whole release: core's engine skips
4
+ // its own tail on every durable run — deliberately, that is the seam this package rests on — and
5
+ // nothing noticed that the three side effects it skipped were never replayed. The e2e specs
6
+ // asserted that a run *becomes* a durable job; none drove one to terminal, so the progress job
7
+ // left dangling and the undelivered `data_sync.run.completed` webhook showed up nowhere.
8
+
9
+ import { describe, expect, it, vi } from 'vitest'
10
+
11
+ import { replayFinalize, type FinalizeDeps, type FinalizedRun } from './durable-run'
12
+
13
+ const run: FinalizedRun = {
14
+ id: 'run-1',
15
+ integrationId: 'example_sync',
16
+ entityType: 'example.record',
17
+ direction: 'import',
18
+ progressJobId: 'pj-1',
19
+ createdCount: 3,
20
+ updatedCount: 2,
21
+ skippedCount: 1,
22
+ failedCount: 0,
23
+ batchesCompleted: 4,
24
+ }
25
+
26
+ const scope = { tenantId: 't1', organizationId: 'o1' }
27
+
28
+ function deps(overrides: Partial<FinalizeDeps> = {}) {
29
+ const progressService = {
30
+ completeJob: vi.fn().mockResolvedValue(undefined),
31
+ failJob: vi.fn().mockResolvedValue(undefined),
32
+ markCancelled: vi.fn().mockResolvedValue(undefined),
33
+ isCancellationRequested: vi.fn().mockResolvedValue(false),
34
+ }
35
+ const integrationLogService = { write: vi.fn().mockResolvedValue(undefined) }
36
+ const integrationStateService = { upsert: vi.fn().mockResolvedValue(undefined) }
37
+ const emitEvent = vi.fn().mockResolvedValue(undefined)
38
+ const value: FinalizeDeps = {
39
+ progressService: progressService as never,
40
+ integrationLogService,
41
+ integrationStateService,
42
+ operationalTelemetry: () => true,
43
+ emitEvent,
44
+ ...overrides,
45
+ }
46
+ return { deps: value, progressService, integrationLogService, integrationStateService, emitEvent }
47
+ }
48
+
49
+ describe('replayFinalize', () => {
50
+ it('resolves the progress job, records health and emits the lifecycle event on completion', async () => {
51
+ const d = deps()
52
+ await replayFinalize(d.deps, run, 'completed', null, scope, 'user-1')
53
+
54
+ // The progress job is what an operator watches in the top bar. Left pending, a finished
55
+ // backfill reads as still running forever.
56
+ expect(d.progressService.completeJob).toHaveBeenCalledWith(
57
+ 'pj-1',
58
+ { resultSummary: { createdCount: 3, updatedCount: 2, skippedCount: 1, failedCount: 0, batchesCompleted: 4 } },
59
+ { tenantId: 't1', organizationId: 'o1', userId: 'user-1' },
60
+ )
61
+ expect(d.integrationStateService.upsert).toHaveBeenCalledWith(
62
+ 'example_sync',
63
+ expect.objectContaining({ lastHealthStatus: 'healthy' }),
64
+ scope,
65
+ )
66
+ // Dispatched to tenant webhooks, so its absence is visible outside the app entirely.
67
+ expect(d.emitEvent).toHaveBeenCalledWith('data_sync.run.completed', expect.objectContaining({ runId: 'run-1' }))
68
+ })
69
+
70
+ it('fails the progress job and reports the error on failure', async () => {
71
+ const d = deps()
72
+ await replayFinalize(d.deps, run, 'failed', 'batch 39: timeout', scope, null)
73
+
74
+ expect(d.progressService.failJob).toHaveBeenCalledWith(
75
+ 'pj-1',
76
+ { errorMessage: 'batch 39: timeout' },
77
+ expect.objectContaining({ userId: undefined }),
78
+ )
79
+ expect(d.integrationStateService.upsert).toHaveBeenCalledWith(
80
+ 'example_sync',
81
+ expect.objectContaining({ lastHealthStatus: 'unhealthy' }),
82
+ scope,
83
+ )
84
+ expect(d.integrationLogService.write).toHaveBeenCalledWith(
85
+ expect.objectContaining({ level: 'error', message: 'batch 39: timeout', runId: 'run-1' }),
86
+ scope,
87
+ )
88
+ // Only the failure event carries `error`; a subscriber switching on it would otherwise see
89
+ // every durable failure as an unexplained one.
90
+ expect(d.emitEvent).toHaveBeenCalledWith(
91
+ 'data_sync.run.failed',
92
+ expect.objectContaining({ runId: 'run-1', error: 'batch 39: timeout' }),
93
+ )
94
+ })
95
+
96
+ it('marks the progress job cancelled and degrades health on cancellation', async () => {
97
+ const d = deps()
98
+ await replayFinalize(d.deps, run, 'cancelled', null, scope, null)
99
+
100
+ expect(d.progressService.markCancelled).toHaveBeenCalledWith('pj-1', expect.anything())
101
+ expect(d.integrationStateService.upsert).toHaveBeenCalledWith(
102
+ 'example_sync',
103
+ expect.objectContaining({ lastHealthStatus: 'degraded' }),
104
+ scope,
105
+ )
106
+ expect(d.emitEvent).toHaveBeenCalledWith('data_sync.run.cancelled', expect.anything())
107
+ })
108
+
109
+ it('honours the adapter opt-out for the operational writes, but never for the event', async () => {
110
+ // `operationalTelemetry` is an adapter's choice about how chatty its log is. It has never
111
+ // been a choice about whether the run's completion is observable, and core gates only the
112
+ // two operational writes on it — so the event must still go out.
113
+ const d = deps({ operationalTelemetry: () => false })
114
+ await replayFinalize(d.deps, run, 'completed', null, scope, null)
115
+
116
+ expect(d.integrationLogService.write).not.toHaveBeenCalled()
117
+ expect(d.integrationStateService.upsert).not.toHaveBeenCalled()
118
+ expect(d.progressService.completeJob).toHaveBeenCalled()
119
+ expect(d.emitEvent).toHaveBeenCalledWith('data_sync.run.completed', expect.anything())
120
+ })
121
+
122
+ it('does nothing with a progress job when the run never had one', async () => {
123
+ // `createProgressJob: false` is a supported way to start a run, and core guards its whole
124
+ // progress branch on `run.progressJobId`.
125
+ const d = deps()
126
+ await replayFinalize(d.deps, { ...run, progressJobId: null }, 'completed', null, scope, null)
127
+
128
+ expect(d.progressService.completeJob).not.toHaveBeenCalled()
129
+ expect(d.emitEvent).toHaveBeenCalled()
130
+ })
131
+ })
@@ -13,6 +13,8 @@ import {
13
13
  outcomeOf,
14
14
  recordSlice,
15
15
  reopenRun,
16
+ replayFinalize,
17
+ type FinalizeDeps,
16
18
  type ProgressServiceLike,
17
19
  type SyncRunServiceLike,
18
20
  type SyncScope,
@@ -40,6 +42,8 @@ export type DataSyncKindDeps = {
40
42
  engine: (services: { runService: SyncRunServiceLike; progressService: ProgressServiceLike }) => SyncEngineLike
41
43
  runService: SyncRunServiceLike
42
44
  progressService: ProgressServiceLike
45
+ /** What core's `finalizeRun` would have done after the status write. */
46
+ finalize: FinalizeDeps
43
47
  }>
44
48
  }
45
49
 
@@ -84,6 +88,33 @@ export function dataSyncKinds(deps: DataSyncKindDeps): KindDefinition<SyncRunInp
84
88
  async onRedrive(job: { input: unknown }, _scope: unknown, tx: SqlExecutor) {
85
89
  return reopenRun(tx, (job.input as SyncRunInput).runId)
86
90
  },
91
+
92
+ /**
93
+ * The rest of core's `finalizeRun`, which core itself skips on every durable run.
94
+ *
95
+ * Without this a host is told the run is over by the `sync_runs` row alone: the progress
96
+ * job an operator watches never resolves, the integration's health state never moves, and
97
+ * `data_sync.run.completed` — a tenant webhook — is never dispatched. The decoration that
98
+ * makes a run durable is supposed to be invisible to a host, and those three are exactly
99
+ * how a host would notice.
100
+ *
101
+ * At-most-once and best-effort by the mechanism's contract, which is the same standing
102
+ * these have in core: a webhook that fails there has never un-completed a run either.
103
+ */
104
+ async onAfterTransition(job: { id: string; input: unknown; status: string; errorMessage: string | null; createdBy: string | null }, scope: { tenantId: string; organizationId: string | null }) {
105
+ const { runId } = job.input as SyncRunInput
106
+ const status = job.status === 'completed' ? 'completed' : job.status === 'cancelled' ? 'cancelled' : 'failed'
107
+ const { runService, finalize } = await deps.resolve()
108
+
109
+ const run = (await runService.getRun(runId, scope)) as
110
+ | (Parameters<typeof replayFinalize>[1] & Record<string, unknown>)
111
+ | null
112
+ // A run that has been hard-deleted since the job finished is not an error to report:
113
+ // there is nothing left to resolve, and the job's own terminal state is already correct.
114
+ if (!run) return
115
+
116
+ await replayFinalize(finalize, run, status, job.errorMessage, scope, job.createdBy)
117
+ },
87
118
  }
88
119
 
89
120
  return [
@@ -9,6 +9,8 @@ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
9
9
  import type { EntityManager } from '@mikro-orm/postgresql'
10
10
  import { register as registerCore } from '@open-mercato/core/modules/data_sync/di'
11
11
  import { createSyncEngine } from '@open-mercato/core/modules/data_sync/lib/sync-engine'
12
+ import { getDataSyncAdapter, resolveProviderKey } from '@open-mercato/core/modules/data_sync/lib/adapter-registry'
13
+ import { emitDataSyncEvent } from '@open-mercato/core/modules/data_sync/events'
12
14
  import { registry } from '@fullstackhouse/open-mercato-durable-work'
13
15
 
14
16
  import { dataSyncKinds } from '../../kinds/data-sync-run'
@@ -30,16 +32,29 @@ const KINDS = dataSyncKinds({
30
32
  const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
31
33
  const container = await createRequestContainer()
32
34
  const em = container.resolve<EntityManager>('em')
35
+ const progressService = container.resolve('progressService')
36
+ const integrationLogService = container.resolve('integrationLogService')
37
+ const integrationStateService = container.resolve('integrationStateService')
33
38
  return {
34
39
  runService: container.resolve('dataSyncRunService'),
35
- progressService: container.resolve('progressService'),
40
+ progressService,
41
+ // Resolved from core's own registry and core's own event bus, so the replayed tail is
42
+ // core's behaviour rather than a second opinion about what it should have been.
43
+ finalize: {
44
+ progressService,
45
+ integrationLogService,
46
+ integrationStateService,
47
+ operationalTelemetry: (integrationId: string) =>
48
+ getDataSyncAdapter(resolveProviderKey(integrationId))?.operationalTelemetry === true,
49
+ emitEvent: (name, payload) => emitDataSyncEvent(name as never, payload as never),
50
+ },
36
51
  engine: ({ runService, progressService }) =>
37
52
  createSyncEngine({
38
53
  em,
39
54
  syncRunService: runService as never,
40
55
  integrationCredentialsService: container.resolve('integrationCredentialsService'),
41
- integrationLogService: container.resolve('integrationLogService'),
42
- integrationStateService: container.resolve('integrationStateService'),
56
+ integrationLogService,
57
+ integrationStateService,
43
58
  progressService: progressService as never,
44
59
  }),
45
60
  }
@@ -1,6 +0,0 @@
1
- export * from "@open-mercato/core/modules/data_sync/lib/abandoned-run";
2
- import { failAbandonedRun } from "@open-mercato/core/modules/data_sync/lib/abandoned-run";
3
- export {
4
- failAbandonedRun
5
- };
6
- //# sourceMappingURL=abandoned-run.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../../src/modules/data_sync/lib/abandoned-run.ts"],
4
- "sourcesContent": ["// GENERATED by scripts/gen-mirror.mjs from @open-mercato/core@0.7.1-develop.7135.1.19bf969756 (lib/abandoned-run.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/lib/abandoned-run'\nexport { failAbandonedRun } from '@open-mercato/core/modules/data_sync/lib/abandoned-run'\n"],
5
- "mappings": "AAEA,cAAc;AACd,SAAS,wBAAwB;",
6
- "names": []
7
- }