@pattern-stack/codegen 0.17.0 → 0.17.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.
- package/CHANGELOG.md +68 -0
- package/consumer-skills/integration/audit-and-detection.md +29 -4
- package/dist/{chunk-UTNWFHJF.js → chunk-4PFF3ED4.js} +4 -4
- package/dist/{chunk-CO6LUM72.js → chunk-7P5ODGLA.js} +34 -2
- package/dist/chunk-7P5ODGLA.js.map +1 -0
- package/dist/{chunk-CDLWYZVQ.js → chunk-BHZP6LOV.js} +7 -7
- package/dist/{chunk-4MVGAMUA.js → chunk-BK5ICA2F.js} +4 -4
- package/dist/{chunk-BULPAAD3.js → chunk-DUMI2J5M.js} +42 -11
- package/dist/chunk-DUMI2J5M.js.map +1 -0
- package/dist/{chunk-RHYNACZS.js → chunk-EJBK7I4F.js} +3 -3
- package/dist/{chunk-OTR44OH6.js → chunk-FVNAU7VO.js} +30 -9
- package/dist/chunk-FVNAU7VO.js.map +1 -0
- package/dist/{chunk-OITTYGJS.js → chunk-FWRL7BZ5.js} +4 -4
- package/dist/{chunk-3MAZ4TQH.js → chunk-HOIRY5XP.js} +13 -13
- package/dist/{chunk-GJDEPTPY.js → chunk-HPS554L4.js} +10 -10
- package/dist/{chunk-P3AYBRP6.js → chunk-JA7GJDNI.js} +16 -10
- package/dist/chunk-JA7GJDNI.js.map +1 -0
- package/dist/{chunk-36U5UGIO.js → chunk-JEINYUJH.js} +8 -5
- package/dist/chunk-JEINYUJH.js.map +1 -0
- package/dist/{chunk-K2I6XIK5.js → chunk-KSTZIULO.js} +4 -4
- package/dist/{chunk-Z7PQCAVK.js → chunk-LQ6PYFU6.js} +4 -4
- package/dist/{chunk-L3VJ47BU.js → chunk-PSDVGPQR.js} +5 -5
- package/dist/{chunk-DTXH24LR.js → chunk-SFQRETXJ.js} +2 -2
- package/dist/{chunk-NXNVTXKG.js → chunk-SGSWVNNB.js} +5 -5
- package/dist/{chunk-7LKAMLV4.js → chunk-T6SCOJF4.js} +4 -4
- package/dist/{chunk-OGIZXGPY.js → chunk-TDEHU73T.js} +4 -4
- package/dist/{chunk-3VEVGL74.js → chunk-VNBC3VXM.js} +4 -4
- package/dist/{chunk-DCCZB4UC.js → chunk-XWBK3XJK.js} +4 -4
- package/dist/{chunk-4GLNY5V6.js → chunk-Y7GDG744.js} +5 -5
- package/dist/{chunk-SR7F3TJY.js → chunk-YK5JEVLX.js} +4 -4
- package/dist/{job-orchestrator.protocol-DubMVbm9.d.ts → job-orchestrator.protocol-ZuJ3ow-O.d.ts} +77 -3
- package/dist/runtime/base-classes/index.js +19 -19
- package/dist/runtime/shared/openapi/index.js +7 -7
- package/dist/runtime/shared/openapi/registry.js +2 -2
- package/dist/runtime/subsystems/auth/auth.module.js +2 -2
- package/dist/runtime/subsystems/auth/index.js +5 -5
- package/dist/runtime/subsystems/bridge/bridge-delivery-handler.d.ts +1 -1
- package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +2 -2
- package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +4 -4
- package/dist/runtime/subsystems/bridge/bridge.module.d.ts +1 -1
- package/dist/runtime/subsystems/bridge/bridge.module.js +15 -15
- package/dist/runtime/subsystems/bridge/event-flow.service.d.ts +1 -1
- package/dist/runtime/subsystems/bridge/index.d.ts +1 -1
- package/dist/runtime/subsystems/bridge/index.js +17 -17
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/events/event-bus.memory-backend.js +2 -2
- package/dist/runtime/subsystems/events/events.module.js +4 -4
- package/dist/runtime/subsystems/events/index.js +4 -4
- package/dist/runtime/subsystems/index.d.ts +1 -1
- package/dist/runtime/subsystems/index.js +90 -90
- package/dist/runtime/subsystems/integration/deep-equal.differ.d.ts +19 -0
- package/dist/runtime/subsystems/integration/deep-equal.differ.js +1 -1
- package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
- package/dist/runtime/subsystems/integration/index.js +24 -24
- package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/integration/integration.module.d.ts +20 -0
- package/dist/runtime/subsystems/integration/integration.module.js +6 -6
- package/dist/runtime/subsystems/jobs/index.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/index.js +32 -32
- package/dist/runtime/subsystems/jobs/job-handler.base.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-handler.base.js +11 -3
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +5 -4
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +2 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.d.ts +11 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +2 -2
- package/dist/runtime/subsystems/jobs/job-orchestrator.protocol.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +2 -2
- package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.js +2 -2
- package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.js +10 -10
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +8 -8
- package/dist/runtime/subsystems/jobs/jobs-errors.d.ts +1 -1
- package/dist/runtime/subsystems/observability/index.d.ts +1 -1
- package/dist/runtime/subsystems/observability/observability.protocol.d.ts +1 -1
- package/dist/runtime/subsystems/observability/observability.service.d.ts +1 -1
- package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +1 -1
- package/dist/runtime/subsystems/observability/reporters/index.d.ts +1 -1
- package/dist/runtime/subsystems/storage/index.js +4 -4
- package/dist/runtime/subsystems/storage/storage.module.js +2 -2
- package/dist/src/cli/index.js +34 -12
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.js +9 -9
- package/package.json +1 -1
- package/runtime/subsystems/integration/deep-equal.differ.ts +34 -5
- package/runtime/subsystems/integration/integration.module.ts +26 -2
- package/runtime/subsystems/jobs/job-handler.base.ts +115 -2
- package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +43 -16
- package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +58 -18
- package/templates/subsystem/integration-config/codegen-config-integration-block.ejs.t +17 -0
- package/dist/chunk-36U5UGIO.js.map +0 -1
- package/dist/chunk-BULPAAD3.js.map +0 -1
- package/dist/chunk-CO6LUM72.js.map +0 -1
- package/dist/chunk-OTR44OH6.js.map +0 -1
- package/dist/chunk-P3AYBRP6.js.map +0 -1
- /package/dist/{chunk-UTNWFHJF.js.map → chunk-4PFF3ED4.js.map} +0 -0
- /package/dist/{chunk-CDLWYZVQ.js.map → chunk-BHZP6LOV.js.map} +0 -0
- /package/dist/{chunk-4MVGAMUA.js.map → chunk-BK5ICA2F.js.map} +0 -0
- /package/dist/{chunk-RHYNACZS.js.map → chunk-EJBK7I4F.js.map} +0 -0
- /package/dist/{chunk-OITTYGJS.js.map → chunk-FWRL7BZ5.js.map} +0 -0
- /package/dist/{chunk-3MAZ4TQH.js.map → chunk-HOIRY5XP.js.map} +0 -0
- /package/dist/{chunk-GJDEPTPY.js.map → chunk-HPS554L4.js.map} +0 -0
- /package/dist/{chunk-K2I6XIK5.js.map → chunk-KSTZIULO.js.map} +0 -0
- /package/dist/{chunk-Z7PQCAVK.js.map → chunk-LQ6PYFU6.js.map} +0 -0
- /package/dist/{chunk-L3VJ47BU.js.map → chunk-PSDVGPQR.js.map} +0 -0
- /package/dist/{chunk-DTXH24LR.js.map → chunk-SFQRETXJ.js.map} +0 -0
- /package/dist/{chunk-NXNVTXKG.js.map → chunk-SGSWVNNB.js.map} +0 -0
- /package/dist/{chunk-7LKAMLV4.js.map → chunk-T6SCOJF4.js.map} +0 -0
- /package/dist/{chunk-OGIZXGPY.js.map → chunk-TDEHU73T.js.map} +0 -0
- /package/dist/{chunk-3VEVGL74.js.map → chunk-VNBC3VXM.js.map} +0 -0
- /package/dist/{chunk-DCCZB4UC.js.map → chunk-XWBK3XJK.js.map} +0 -0
- /package/dist/{chunk-4GLNY5V6.js.map → chunk-Y7GDG744.js.map} +0 -0
- /package/dist/{chunk-SR7F3TJY.js.map → chunk-YK5JEVLX.js.map} +0 -0
package/dist/src/index.js
CHANGED
|
@@ -46,25 +46,25 @@ import {
|
|
|
46
46
|
validatePatternProject
|
|
47
47
|
} from "../chunk-IOQMMH6C.js";
|
|
48
48
|
import "../chunk-KVOWSC5S.js";
|
|
49
|
-
import "../chunk-
|
|
49
|
+
import "../chunk-JA7GJDNI.js";
|
|
50
50
|
import "../chunk-PRWIX6UW.js";
|
|
51
|
-
import "../chunk-
|
|
51
|
+
import "../chunk-AHV4GDYM.js";
|
|
52
|
+
import "../chunk-YK5JEVLX.js";
|
|
52
53
|
import "../chunk-EO2QPOKH.js";
|
|
53
54
|
import "../chunk-SQDOBLBP.js";
|
|
54
|
-
import "../chunk-
|
|
55
|
+
import "../chunk-4KNXX6TI.js";
|
|
56
|
+
import "../chunk-3CJFPU6Q.js";
|
|
57
|
+
import "../chunk-TDEHU73T.js";
|
|
55
58
|
import "../chunk-LG57S2SC.js";
|
|
56
|
-
import "../chunk-
|
|
59
|
+
import "../chunk-XWBK3XJK.js";
|
|
60
|
+
import "../chunk-S7C6TIIF.js";
|
|
57
61
|
import "../chunk-MZ6GV4YF.js";
|
|
58
62
|
import "../chunk-HNWZFNKP.js";
|
|
59
|
-
import "../chunk-AHV4GDYM.js";
|
|
60
63
|
import "../chunk-43SBT72G.js";
|
|
61
64
|
import "../chunk-4MF3HKJA.js";
|
|
62
65
|
import "../chunk-TIZXQU26.js";
|
|
63
|
-
import "../chunk-
|
|
66
|
+
import "../chunk-JEINYUJH.js";
|
|
64
67
|
import "../chunk-5TK7MEN4.js";
|
|
65
|
-
import "../chunk-4KNXX6TI.js";
|
|
66
|
-
import "../chunk-3CJFPU6Q.js";
|
|
67
|
-
import "../chunk-S7C6TIIF.js";
|
|
68
68
|
import "../chunk-U64T4YZE.js";
|
|
69
69
|
import "../chunk-2E224ZSN.js";
|
|
70
70
|
export {
|
package/package.json
CHANGED
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
* `id`, `createdAt`, `updatedAt`, `deletedAt`, `type`,
|
|
13
13
|
* `lastModifiedAt`, `fields`, `providerMetadata`
|
|
14
14
|
* (`fields` is the EAV bag — it's diffed by the sink's EAV dual-write
|
|
15
|
-
* path, not at the canonical-record layer.)
|
|
15
|
+
* path, not at the canonical-record layer.) Consumers augment the list via
|
|
16
|
+
* `options.ignore` and — when a default is domain data for their entity —
|
|
17
|
+
* REMOVE a default via `options.unignore` (e.g. an entity whose
|
|
18
|
+
* `deletedAt` is a vendor-observed retraction tombstone, not row metadata;
|
|
19
|
+
* see `DeepEqualDifferOptions.unignore`).
|
|
16
20
|
*
|
|
17
21
|
* 2. **`providerChangedFields` hint (CDC)** — when present, restricts the
|
|
18
22
|
* comparison to the hinted field set. The hint is advisory; fields in
|
|
@@ -75,6 +79,26 @@ export interface DeepEqualDifferOptions {
|
|
|
75
79
|
* merged (not replaced) with `DEFAULT_IGNORE_FIELDS`.
|
|
76
80
|
*/
|
|
77
81
|
readonly ignore?: readonly string[];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Field names to REMOVE from the default ignore list — the inverse of
|
|
85
|
+
* `ignore`. Use this to declare that a normally-metadata column is in fact
|
|
86
|
+
* DOMAIN DATA for this entity and must register as a field change.
|
|
87
|
+
*
|
|
88
|
+
* The canonical case (swe-brain ADR-0008 §1, the gap this knob closes):
|
|
89
|
+
* `deletedAt` is in `DEFAULT_IGNORE_FIELDS` because most sinks stamp it as
|
|
90
|
+
* row metadata sinks own unconditionally. But an entity with
|
|
91
|
+
* `softDelete: false` and a domain-owned `deleted_at` carries the
|
|
92
|
+
* vendor-observed retraction tombstone ON the canonical record (a Slack
|
|
93
|
+
* `message_deleted` → `deletedAt`). Without un-ignoring it, the tombstone
|
|
94
|
+
* overlay diffs to `'noop'`, the upsert is skipped, and `deleted_at` never
|
|
95
|
+
* lands. `unignore: ['deletedAt']` makes the differ treat it as domain data.
|
|
96
|
+
*
|
|
97
|
+
* Applied AFTER `ignore` is merged, so `unignore` wins on a field listed in
|
|
98
|
+
* both. Subtracting a field not in the (merged) ignore set is a harmless
|
|
99
|
+
* no-op. Does not touch `DEFAULT_IGNORE_FIELDS` for any other instance.
|
|
100
|
+
*/
|
|
101
|
+
readonly unignore?: readonly string[];
|
|
78
102
|
}
|
|
79
103
|
|
|
80
104
|
@Injectable()
|
|
@@ -84,11 +108,16 @@ export class DeepEqualDiffer<T extends Record<string, unknown>>
|
|
|
84
108
|
private readonly ignore: ReadonlySet<string>;
|
|
85
109
|
|
|
86
110
|
constructor(opts: DeepEqualDifferOptions = {}) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
111
|
+
const merged = new Set<string>(DEFAULT_IGNORE_FIELDS);
|
|
112
|
+
if (opts.ignore) {
|
|
113
|
+
for (const field of opts.ignore) merged.add(field);
|
|
114
|
+
}
|
|
115
|
+
// `unignore` is subtracted last so it wins over a field that also appears
|
|
116
|
+
// in `ignore` or the defaults — "this column is domain data here."
|
|
117
|
+
if (opts.unignore) {
|
|
118
|
+
for (const field of opts.unignore) merged.delete(field);
|
|
91
119
|
}
|
|
120
|
+
this.ignore = merged;
|
|
92
121
|
}
|
|
93
122
|
|
|
94
123
|
diff(
|
|
@@ -73,7 +73,7 @@ import { MemoryCursorStore } from './integration-cursor-store.memory-backend';
|
|
|
73
73
|
import { MemoryRunRecorder } from './integration-run-recorder.memory-backend';
|
|
74
74
|
import { PostgresCursorStore } from './integration-cursor-store.drizzle-backend';
|
|
75
75
|
import { DrizzleIntegrationRunRecorder } from './integration-run-recorder.drizzle-backend';
|
|
76
|
-
import { DeepEqualDiffer } from './deep-equal.differ';
|
|
76
|
+
import { DeepEqualDiffer, type DeepEqualDifferOptions } from './deep-equal.differ';
|
|
77
77
|
|
|
78
78
|
export interface IntegrationModuleOptions {
|
|
79
79
|
/**
|
|
@@ -100,6 +100,24 @@ export interface IntegrationModuleOptions {
|
|
|
100
100
|
* Defaults to `false`.
|
|
101
101
|
*/
|
|
102
102
|
multiTenant?: boolean;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Default-differ configuration (DIFFER-UNIGNORE, 0.17.1). Threaded into the
|
|
106
|
+
* `DeepEqualDiffer` bound to `INTEGRATION_FIELD_DIFFER`. Omit for the
|
|
107
|
+
* historical behaviour (the default ignore list, unchanged).
|
|
108
|
+
*
|
|
109
|
+
* Mirrors `DeepEqualDifferOptions`:
|
|
110
|
+
* - `ignore` — extra field names to ignore (merged with the defaults).
|
|
111
|
+
* - `unignore` — default-ignored field names to RE-include as domain data
|
|
112
|
+
* (e.g. `['deletedAt']` for an entity whose `deletedAt` is a
|
|
113
|
+
* vendor-observed retraction tombstone, not row metadata — swe-brain
|
|
114
|
+
* ADR-0008 §1). Subtracted after the merge, so it wins.
|
|
115
|
+
*
|
|
116
|
+
* A feature module that binds its own `IFieldDiffer<T>` to
|
|
117
|
+
* `INTEGRATION_FIELD_DIFFER` overrides this entirely (per-entity escape hatch
|
|
118
|
+
* unchanged).
|
|
119
|
+
*/
|
|
120
|
+
differ?: DeepEqualDifferOptions;
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
@Module({})
|
|
@@ -112,7 +130,13 @@ export class IntegrationModule {
|
|
|
112
130
|
{ provide: INTEGRATION_MULTI_TENANT, useValue: multiTenant },
|
|
113
131
|
// Default differ — consumers can override by binding a different
|
|
114
132
|
// `IFieldDiffer<T>` to `INTEGRATION_FIELD_DIFFER` in their feature module.
|
|
115
|
-
|
|
133
|
+
// DIFFER-UNIGNORE: `options.differ` (ignore/unignore) is threaded here so
|
|
134
|
+
// a consumer can declare a default-ignored column (e.g. `deletedAt`) as
|
|
135
|
+
// domain data for their entities without binding a bespoke differ.
|
|
136
|
+
{
|
|
137
|
+
provide: INTEGRATION_FIELD_DIFFER,
|
|
138
|
+
useValue: new DeepEqualDiffer(options.differ ?? {}),
|
|
139
|
+
},
|
|
116
140
|
];
|
|
117
141
|
|
|
118
142
|
const backendProviders: Provider[] =
|
|
@@ -42,13 +42,33 @@ export interface RetryPolicy {
|
|
|
42
42
|
nonRetryableErrors?: string[];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Concurrency lane key (JOB-FN-KEY, 0.16.2).
|
|
47
|
+
*
|
|
48
|
+
* Two authoring forms, both honored end-to-end (the typed function form was
|
|
49
|
+
* previously dropped to `null` at registration — see `upsertJobRows` — so
|
|
50
|
+
* `collisionMode` silently never engaged):
|
|
51
|
+
*
|
|
52
|
+
* - **`string`** — a `{{field}}` template evaluated against the start
|
|
53
|
+
* payload by `evaluateKeyTemplate` (single-key substitution, no dotted
|
|
54
|
+
* paths). Persisted verbatim to `job.concurrency_key_template`.
|
|
55
|
+
* - **`(input) => string`** — an arbitrary function of the input. Persisted
|
|
56
|
+
* as the `FN_KEY_SENTINEL` marker so the definition-hash gate stays stable
|
|
57
|
+
* and the collision path engages; `start()` re-resolves the live function
|
|
58
|
+
* from `JOB_HANDLER_REGISTRY` and evaluates it against the payload.
|
|
59
|
+
*
|
|
60
|
+
* Both forms produce a per-lane key; same key + in-flight incumbent ⇒
|
|
61
|
+
* `collisionMode` ('queue' | 'reject' | 'replace') decides.
|
|
62
|
+
*/
|
|
63
|
+
export type JobKeySelector<TInput> = string | ((input: TInput) => string);
|
|
64
|
+
|
|
45
65
|
export interface ConcurrencyPolicy<TInput> {
|
|
46
|
-
key:
|
|
66
|
+
key: JobKeySelector<TInput>;
|
|
47
67
|
collisionMode: 'queue' | 'reject' | 'replace';
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
export interface DedupePolicy<TInput> {
|
|
51
|
-
key:
|
|
71
|
+
key: JobKeySelector<TInput>;
|
|
52
72
|
windowMs: number;
|
|
53
73
|
}
|
|
54
74
|
|
|
@@ -245,3 +265,96 @@ export namespace HandlerRegistry {
|
|
|
245
265
|
return JOB_HANDLER_REGISTRY.get(type);
|
|
246
266
|
}
|
|
247
267
|
}
|
|
268
|
+
|
|
269
|
+
// ─── Key resolution (JOB-FN-KEY, 0.16.2) ────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Sentinel persisted to `job.concurrency_key_template` / `dedupe_key_template`
|
|
273
|
+
* when the authored `key` is a function rather than a `{{field}}` template.
|
|
274
|
+
*
|
|
275
|
+
* Why a sentinel (not `null`): the collision/dedupe paths in both backends gate
|
|
276
|
+
* on `definition.concurrencyKeyTemplate != null`. A function key persisted as
|
|
277
|
+
* `null` (the pre-0.16.2 bug) left those columns empty, so `collisionMode` /
|
|
278
|
+
* the dedupe window never engaged — the job ran with NO key. A stable sentinel
|
|
279
|
+
* keeps the column non-null (path engages) AND keeps the definition-hash gate
|
|
280
|
+
* (`upsertJobRows`' `IS DISTINCT FROM` clause) stable across boots, since the
|
|
281
|
+
* function identity itself can't be hashed. `start()` detects the sentinel and
|
|
282
|
+
* re-resolves the live function from `JOB_HANDLER_REGISTRY`.
|
|
283
|
+
*
|
|
284
|
+
* Chosen as an angle-bracketed token so it can never collide with a real
|
|
285
|
+
* `{{field}}` template (which never contains a literal `<`).
|
|
286
|
+
*/
|
|
287
|
+
export const FN_KEY_SENTINEL = '<fn>';
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Registration-time projection: collapse an authored `JobKeySelector` to the
|
|
291
|
+
* string stored in the `job` definition row. A string template is stored
|
|
292
|
+
* verbatim; a function is stored as `FN_KEY_SENTINEL`; absence stays `null`.
|
|
293
|
+
*/
|
|
294
|
+
export function keySelectorToTemplate(
|
|
295
|
+
key: JobKeySelector<unknown> | undefined,
|
|
296
|
+
): string | null {
|
|
297
|
+
if (typeof key === 'string') return key;
|
|
298
|
+
if (typeof key === 'function') return FN_KEY_SENTINEL;
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Which meta policy a key belongs to — selects the live fn at `start()`. */
|
|
303
|
+
export type KeyKind = 'concurrency' | 'dedupe';
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* `start()`-time resolution shared by every backend. Turns the persisted
|
|
307
|
+
* template column into the concrete per-run key for the given payload.
|
|
308
|
+
*
|
|
309
|
+
* - `template == null` → `null` (no key; caller skips the collision/dedupe path).
|
|
310
|
+
* - `template === FN_KEY_SENTINEL` → look the live `@JobHandler` meta up in
|
|
311
|
+
* `JOB_HANDLER_REGISTRY`, pull `meta[kind].key`, and invoke it against the
|
|
312
|
+
* payload. The registry is the runtime source of truth (the worker already
|
|
313
|
+
* resolves handler classes the same way), so the function survives the DB
|
|
314
|
+
* round-trip even though it can't be persisted.
|
|
315
|
+
* - otherwise → a `{{field}}` template, evaluated via the injected
|
|
316
|
+
* `evaluateTemplate` (each backend passes its own copy to avoid a runtime
|
|
317
|
+
* import cycle).
|
|
318
|
+
*
|
|
319
|
+
* Throws `JobKeyFunctionUnavailableError` if the sentinel is present but no
|
|
320
|
+
* live function can be found (e.g. the registry was reset, or a function key
|
|
321
|
+
* was persisted by a newer build and read by an older one). Failing loud beats
|
|
322
|
+
* silently degrading to no-key — the exact regression this fix exists to kill.
|
|
323
|
+
*/
|
|
324
|
+
export function resolveJobKey(
|
|
325
|
+
kind: KeyKind,
|
|
326
|
+
type: string,
|
|
327
|
+
template: string | null,
|
|
328
|
+
payload: Record<string, unknown>,
|
|
329
|
+
evaluateTemplate: (template: string, payload: Record<string, unknown>) => string,
|
|
330
|
+
): string | null {
|
|
331
|
+
if (template == null) return null;
|
|
332
|
+
if (template !== FN_KEY_SENTINEL) return evaluateTemplate(template, payload);
|
|
333
|
+
|
|
334
|
+
const meta = JOB_HANDLER_REGISTRY.get(type)?.meta;
|
|
335
|
+
const key = (meta?.[kind] as { key?: unknown } | undefined)?.key;
|
|
336
|
+
if (typeof key !== 'function') {
|
|
337
|
+
throw new JobKeyFunctionUnavailableError(type, kind);
|
|
338
|
+
}
|
|
339
|
+
return (key as (input: unknown) => string)(payload);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Raised when a `${FN_KEY_SENTINEL}` template is read but the live function
|
|
344
|
+
* key is missing from `JOB_HANDLER_REGISTRY`. Kept here (not in `jobs-errors`)
|
|
345
|
+
* so `job-handler.base` stays import-cycle-free.
|
|
346
|
+
*/
|
|
347
|
+
export class JobKeyFunctionUnavailableError extends Error {
|
|
348
|
+
constructor(
|
|
349
|
+
readonly jobType: string,
|
|
350
|
+
readonly kind: KeyKind,
|
|
351
|
+
) {
|
|
352
|
+
super(
|
|
353
|
+
`[jobs] ${kind} key for job '${jobType}' was persisted as a function ` +
|
|
354
|
+
`sentinel ('${FN_KEY_SENTINEL}') but no live function is registered ` +
|
|
355
|
+
`for it. The @JobHandler must be imported before start() so its meta ` +
|
|
356
|
+
`is in JOB_HANDLER_REGISTRY.`,
|
|
357
|
+
);
|
|
358
|
+
this.name = 'JobKeyFunctionUnavailableError';
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -36,6 +36,11 @@ import {
|
|
|
36
36
|
import { jobSteps } from './job-orchestration.schema';
|
|
37
37
|
import { JOBS_MULTI_TENANT, JOBS_LISTEN_NOTIFY } from './jobs-domain.tokens';
|
|
38
38
|
import { JOBS_WAKE_CHANNEL, pgNotify } from './pg-notify';
|
|
39
|
+
import {
|
|
40
|
+
keySelectorToTemplate,
|
|
41
|
+
resolveJobKey,
|
|
42
|
+
type JobKeySelector,
|
|
43
|
+
} from './job-handler.base';
|
|
39
44
|
|
|
40
45
|
/**
|
|
41
46
|
* Terminal statuses — transitions into these are final. Used by `cancel`
|
|
@@ -140,9 +145,17 @@ export class DrizzleJobOrchestrator implements IJobOrchestrator {
|
|
|
140
145
|
if (!def) throw new JobTypeNotFoundError(type);
|
|
141
146
|
const definition = def as JobDefinitionRow;
|
|
142
147
|
|
|
143
|
-
// 1b. Dedupe check.
|
|
148
|
+
// 1b. Dedupe check. JOB-FN-KEY: `resolveJobKey` honors both the `{{field}}`
|
|
149
|
+
// template AND a function key persisted as `FN_KEY_SENTINEL` (re-resolved
|
|
150
|
+
// live from JOB_HANDLER_REGISTRY).
|
|
144
151
|
if (definition.dedupeKeyTemplate && definition.dedupeWindowMs) {
|
|
145
|
-
const dedupeKey =
|
|
152
|
+
const dedupeKey = resolveJobKey(
|
|
153
|
+
'dedupe',
|
|
154
|
+
type,
|
|
155
|
+
definition.dedupeKeyTemplate,
|
|
156
|
+
payload,
|
|
157
|
+
evaluateKeyTemplate,
|
|
158
|
+
) as string;
|
|
146
159
|
const windowStart = new Date(Date.now() - definition.dedupeWindowMs);
|
|
147
160
|
const existing = await client
|
|
148
161
|
.select()
|
|
@@ -166,10 +179,15 @@ export class DrizzleJobOrchestrator implements IJobOrchestrator {
|
|
|
166
179
|
// 1c. Concurrency collision check.
|
|
167
180
|
let concurrencyKey: string | null = null;
|
|
168
181
|
if (definition.concurrencyKeyTemplate) {
|
|
169
|
-
|
|
182
|
+
// Non-null cast: the branch guard proves the template is present, so the
|
|
183
|
+
// resolver never returns null here (it only nulls on a null template).
|
|
184
|
+
concurrencyKey = resolveJobKey(
|
|
185
|
+
'concurrency',
|
|
186
|
+
type,
|
|
170
187
|
definition.concurrencyKeyTemplate,
|
|
171
188
|
payload,
|
|
172
|
-
|
|
189
|
+
evaluateKeyTemplate,
|
|
190
|
+
) as string;
|
|
173
191
|
const inFlight = await client
|
|
174
192
|
.select()
|
|
175
193
|
.from(jobRuns)
|
|
@@ -223,10 +241,13 @@ export class DrizzleJobOrchestrator implements IJobOrchestrator {
|
|
|
223
241
|
rootRunId = parent.rootRunId;
|
|
224
242
|
}
|
|
225
243
|
|
|
226
|
-
const dedupeKey =
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
244
|
+
const dedupeKey = resolveJobKey(
|
|
245
|
+
'dedupe',
|
|
246
|
+
type,
|
|
247
|
+
definition.dedupeKeyTemplate,
|
|
248
|
+
payload,
|
|
249
|
+
evaluateKeyTemplate,
|
|
250
|
+
);
|
|
230
251
|
|
|
231
252
|
const [inserted] = await client
|
|
232
253
|
.insert(jobRuns)
|
|
@@ -460,17 +481,23 @@ export class DrizzleJobOrchestrator implements IJobOrchestrator {
|
|
|
460
481
|
backoff: 'fixed' as const,
|
|
461
482
|
baseMs: 0,
|
|
462
483
|
};
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
484
|
+
// JOB-FN-KEY (0.16.2): both authored key forms are honored. A `{{field}}`
|
|
485
|
+
// string is persisted verbatim; a function is persisted as
|
|
486
|
+
// `FN_KEY_SENTINEL` (non-null so the collision/dedupe path engages, and
|
|
487
|
+
// hash-stable so the definition-hash gate doesn't churn on every boot —
|
|
488
|
+
// the function identity can't be hashed). `start()` re-resolves the live
|
|
489
|
+
// function from `JOB_HANDLER_REGISTRY`. The pre-0.16.2 `typeof === string
|
|
490
|
+
// ? … : null` dropped function keys to null, so `collisionMode` silently
|
|
491
|
+
// never engaged.
|
|
492
|
+
const concurrencyKeyTemplateStr = keySelectorToTemplate(
|
|
493
|
+
meta.concurrency?.key as JobKeySelector<unknown> | undefined,
|
|
494
|
+
);
|
|
467
495
|
const collisionMode =
|
|
468
496
|
(meta.concurrency?.collisionMode as JobDefinitionRow['collisionMode']) ??
|
|
469
497
|
'queue';
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
typeof dedupeKeyTemplate === 'string' ? dedupeKeyTemplate : null;
|
|
498
|
+
const dedupeKeyTemplateStr = keySelectorToTemplate(
|
|
499
|
+
meta.dedupe?.key as JobKeySelector<unknown> | undefined,
|
|
500
|
+
);
|
|
474
501
|
const dedupeWindowMs = meta.dedupe?.windowMs ?? null;
|
|
475
502
|
const timeoutMs = meta.timeoutMs ?? null;
|
|
476
503
|
const replayFrom = meta.replayFrom ?? 'last_checkpoint';
|
|
@@ -32,11 +32,17 @@ import type {
|
|
|
32
32
|
JobContext,
|
|
33
33
|
JobHandlerBase,
|
|
34
34
|
JobHandlerMeta,
|
|
35
|
+
JobKeySelector,
|
|
35
36
|
RetryPolicy,
|
|
36
37
|
SpawnChildOptions,
|
|
37
38
|
StepOptions,
|
|
38
39
|
} from './job-handler.base';
|
|
39
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
ParentClosePolicy,
|
|
42
|
+
keySelectorToTemplate,
|
|
43
|
+
FN_KEY_SENTINEL,
|
|
44
|
+
JobKeyFunctionUnavailableError,
|
|
45
|
+
} from './job-handler.base';
|
|
40
46
|
import {
|
|
41
47
|
JobCollisionError,
|
|
42
48
|
JobNotReplayableError,
|
|
@@ -176,10 +182,9 @@ export class MemoryJobOrchestrator implements IJobOrchestrator {
|
|
|
176
182
|
meta: JobHandlerMeta<TInput>,
|
|
177
183
|
handlerClass: new (...args: unknown[]) => JobHandlerBase<TInput>,
|
|
178
184
|
): void {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
(meta.dedupe as { key?: string } | undefined)?.key ?? null;
|
|
185
|
+
// JOB-FN-KEY (0.16.2): mirror the Drizzle backend — collapse a function
|
|
186
|
+
// key to `FN_KEY_SENTINEL` so the def row stays non-null (collision/dedupe
|
|
187
|
+
// path engages); `start()` re-resolves the live fn from `handlerRegistry`.
|
|
183
188
|
const dedupeWindowMs = meta.dedupe?.windowMs ?? null;
|
|
184
189
|
const now = new Date();
|
|
185
190
|
|
|
@@ -194,13 +199,15 @@ export class MemoryJobOrchestrator implements IJobOrchestrator {
|
|
|
194
199
|
baseMs: 0,
|
|
195
200
|
},
|
|
196
201
|
timeoutMs: meta.timeoutMs ?? null,
|
|
197
|
-
concurrencyKeyTemplate:
|
|
198
|
-
|
|
202
|
+
concurrencyKeyTemplate: keySelectorToTemplate(
|
|
203
|
+
meta.concurrency?.key as JobKeySelector<unknown> | undefined,
|
|
204
|
+
),
|
|
199
205
|
collisionMode:
|
|
200
206
|
(meta.concurrency?.collisionMode as JobDefinitionRow['collisionMode']) ??
|
|
201
207
|
'queue',
|
|
202
|
-
dedupeKeyTemplate:
|
|
203
|
-
|
|
208
|
+
dedupeKeyTemplate: keySelectorToTemplate(
|
|
209
|
+
meta.dedupe?.key as JobKeySelector<unknown> | undefined,
|
|
210
|
+
),
|
|
204
211
|
dedupeWindowMs,
|
|
205
212
|
priorityDefault: 0,
|
|
206
213
|
replayFrom: meta.replayFrom ?? 'last_checkpoint',
|
|
@@ -223,6 +230,34 @@ export class MemoryJobOrchestrator implements IJobOrchestrator {
|
|
|
223
230
|
return this.handlerRegistry.get(type);
|
|
224
231
|
}
|
|
225
232
|
|
|
233
|
+
/**
|
|
234
|
+
* JOB-FN-KEY (0.16.2): resolve a persisted key template against the payload.
|
|
235
|
+
* Delegates to the shared `resolveJobKey`, but binds the FUNCTION-sentinel
|
|
236
|
+
* lookup to THIS backend's local `handlerRegistry` (the memory backend keeps
|
|
237
|
+
* its own meta map seeded by `registerHandler`, distinct from the global
|
|
238
|
+
* `JOB_HANDLER_REGISTRY` that the Drizzle/worker path uses — memory tests
|
|
239
|
+
* register handlers directly, never via the `@JobHandler` decorator). The
|
|
240
|
+
* `evaluateTemplate` callback handles the ordinary `{{field}}` path.
|
|
241
|
+
*/
|
|
242
|
+
private resolveKey(
|
|
243
|
+
kind: 'concurrency' | 'dedupe',
|
|
244
|
+
type: string,
|
|
245
|
+
template: string | null,
|
|
246
|
+
payload: Record<string, unknown>,
|
|
247
|
+
): string | null {
|
|
248
|
+
if (template == null) return null;
|
|
249
|
+
if (template !== FN_KEY_SENTINEL) {
|
|
250
|
+
return evaluateKeyTemplate(template, payload);
|
|
251
|
+
}
|
|
252
|
+
const key = (this.handlerRegistry.get(type)?.meta?.[kind] as
|
|
253
|
+
| { key?: unknown }
|
|
254
|
+
| undefined)?.key;
|
|
255
|
+
if (typeof key !== 'function') {
|
|
256
|
+
throw new JobKeyFunctionUnavailableError(type, kind);
|
|
257
|
+
}
|
|
258
|
+
return (key as (input: unknown) => string)(payload);
|
|
259
|
+
}
|
|
260
|
+
|
|
226
261
|
/**
|
|
227
262
|
* Boot-time upsert per `IJobOrchestrator.upsertJobRows`. Memory backend
|
|
228
263
|
* just funnels each entry through `registerHandler`. The validator is
|
|
@@ -270,11 +305,10 @@ export class MemoryJobOrchestrator implements IJobOrchestrator {
|
|
|
270
305
|
if (!definition) throw new JobTypeNotFoundError(type);
|
|
271
306
|
|
|
272
307
|
// 1. Dedupe — return existing non-excluded run within the window.
|
|
308
|
+
// JOB-FN-KEY: `resolveKey` honors the `{{field}}` template AND a function
|
|
309
|
+
// key persisted as `FN_KEY_SENTINEL` (re-resolved from `handlerRegistry`).
|
|
273
310
|
if (definition.dedupeKeyTemplate && definition.dedupeWindowMs) {
|
|
274
|
-
const dedupeKey =
|
|
275
|
-
definition.dedupeKeyTemplate,
|
|
276
|
-
payload,
|
|
277
|
-
);
|
|
311
|
+
const dedupeKey = this.resolveKey('dedupe', type, definition.dedupeKeyTemplate, payload) as string;
|
|
278
312
|
const windowStart = Date.now() - definition.dedupeWindowMs;
|
|
279
313
|
const existing = this.findDedupeCandidate(type, dedupeKey, windowStart);
|
|
280
314
|
if (existing) return existing;
|
|
@@ -284,10 +318,13 @@ export class MemoryJobOrchestrator implements IJobOrchestrator {
|
|
|
284
318
|
let concurrencyKey: string | null = null;
|
|
285
319
|
let queueBlockedBy: string | null = null;
|
|
286
320
|
if (definition.concurrencyKeyTemplate) {
|
|
287
|
-
|
|
321
|
+
// Non-null cast: the branch guard proves the template is present.
|
|
322
|
+
concurrencyKey = this.resolveKey(
|
|
323
|
+
'concurrency',
|
|
324
|
+
type,
|
|
288
325
|
definition.concurrencyKeyTemplate,
|
|
289
326
|
payload,
|
|
290
|
-
);
|
|
327
|
+
) as string;
|
|
291
328
|
const incumbent = this.findInFlightByConcurrencyKey(concurrencyKey);
|
|
292
329
|
if (incumbent) {
|
|
293
330
|
switch (definition.collisionMode) {
|
|
@@ -331,9 +368,12 @@ export class MemoryJobOrchestrator implements IJobOrchestrator {
|
|
|
331
368
|
// 4. Compute dedupe key for the persisted row (separate from dedupe
|
|
332
369
|
// short-circuit above — we store it even when no prior run matched
|
|
333
370
|
// so future dedupe checks see it).
|
|
334
|
-
const dedupeKey =
|
|
335
|
-
|
|
336
|
-
|
|
371
|
+
const dedupeKey = this.resolveKey(
|
|
372
|
+
'dedupe',
|
|
373
|
+
type,
|
|
374
|
+
definition.dedupeKeyTemplate,
|
|
375
|
+
payload,
|
|
376
|
+
);
|
|
337
377
|
|
|
338
378
|
const now = new Date();
|
|
339
379
|
const runAt = queueBlockedBy
|
|
@@ -27,3 +27,20 @@ integration:
|
|
|
27
27
|
# Enabling post-install requires a reinstall (`subsystem install integration
|
|
28
28
|
# --force --force-config`) plus an Atlas migration.
|
|
29
29
|
multi_tenant: false
|
|
30
|
+
|
|
31
|
+
# ── Default-differ tuning (DIFFER-UNIGNORE) ──
|
|
32
|
+
# Threaded into the `DeepEqualDiffer` bound to INTEGRATION_FIELD_DIFFER.
|
|
33
|
+
# Off-by-default — omit the whole `differ:` block for the historical
|
|
34
|
+
# behaviour (the built-in ignore list, unchanged). A feature module that
|
|
35
|
+
# binds its own `IFieldDiffer<T>` overrides this entirely.
|
|
36
|
+
#
|
|
37
|
+
# differ:
|
|
38
|
+
# # Extra field names to ALSO ignore (merged with the defaults).
|
|
39
|
+
# ignore: [internalSeq]
|
|
40
|
+
# # Default-ignored field names to RE-INCLUDE as domain data. The canonical
|
|
41
|
+
# # case: an entity with `softDelete: false` whose `deletedAt` carries a
|
|
42
|
+
# # vendor-observed retraction tombstone ON the canonical record. Without
|
|
43
|
+
# # this the tombstone overlay diffs to 'noop', the upsert is skipped, and
|
|
44
|
+
# # `deleted_at` never lands. `unignore` is subtracted after `ignore`, so it
|
|
45
|
+
# # wins on a field listed in both.
|
|
46
|
+
# unignore: [deletedAt]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../runtime/subsystems/integration/deep-equal.differ.ts"],"sourcesContent":["/**\n * DeepEqualDiffer — default `IFieldDiffer<T>` for the integration subsystem (SYNC-5).\n *\n * Walks every field of `incoming` against `existing`, emitting a structured\n * per-field diff (`{ from, to }`) for every field whose value changed.\n * Returns `'noop'` when the record is unchanged.\n *\n * Design decisions (extracted from the upstream consumer + HS-9 findings):\n *\n * 1. **Ignore list** — row metadata that sinks/services stamp unconditionally\n * so upstream cannot reasonably disagree:\n * `id`, `createdAt`, `updatedAt`, `deletedAt`, `type`,\n * `lastModifiedAt`, `fields`, `providerMetadata`\n * (`fields` is the EAV bag — it's diffed by the sink's EAV dual-write\n * path, not at the canonical-record layer.)\n *\n * 2. **`providerChangedFields` hint (CDC)** — when present, restricts the\n * comparison to the hinted field set. The hint is advisory; fields in\n * the ignore list are still filtered out even when hinted. Provider\n * hints are field-NAME-level; they don't override the ignore rules.\n *\n * 3. **Date → ISO string** — `Date` instances are normalized to\n * `toISOString()` before comparison. Sinks return `Date` from the DB\n * driver; adapters typically deliver strings. Direct `===` would\n * always say \"changed.\"\n *\n * 4. **Decimal-string vs number** — Postgres `numeric` columns return as\n * strings through Drizzle; adapters deliver numbers. When one side is a\n * number and the other is a numeric string that parses to the same\n * number, they're equal. The normalizer does NOT coerce non-numeric\n * strings, and it preserves zero-vs-null distinction.\n *\n * 5. **null-existing path** — `diff(null, incoming)` produces a full\n * created-shape diff (`{from: null, to: <value>}` for every non-ignored\n * field). Orchestrator sees this and records `operation: 'created'`.\n */\nimport { Injectable } from '@nestjs/common';\nimport type {\n DiffResult,\n FieldDiff,\n IFieldDiffer,\n} from './integration-field-diff.protocol';\n\n/**\n * Default ignore list. Keep in integration with consumer canonical-record shapes —\n * adding a row-metadata field here means no integration will ever mark it changed.\n *\n * Includes the columns contributed by the `external_id_tracking` behavior\n * (`external_id`/`externalId`, `provider`, `provider_metadata`/`providerMetadata`).\n * These are integration-tracking metadata, not domain attributes: they ride on the\n * canonical record but must never register as a field change (the external id\n * is the record's identity, not a mutable value). Listed in both snake_case\n * and camelCase so the differ ignores them regardless of the consumer's\n * canonical projection casing.\n */\nconst DEFAULT_IGNORE_FIELDS: ReadonlySet<string> = new Set([\n 'id',\n 'createdAt',\n 'updatedAt',\n 'deletedAt',\n 'type',\n 'lastModifiedAt',\n 'fields',\n 'external_id',\n 'externalId',\n 'provider',\n 'provider_metadata',\n 'providerMetadata',\n]);\n\nexport interface DeepEqualDifferOptions {\n /**\n * Extra field names to ignore in addition to the defaults. Consumers can\n * pass `['integration_version']` etc. to augment the base list; values here are\n * merged (not replaced) with `DEFAULT_IGNORE_FIELDS`.\n */\n readonly ignore?: readonly string[];\n}\n\n@Injectable()\nexport class DeepEqualDiffer<T extends Record<string, unknown>>\n implements IFieldDiffer<T>\n{\n private readonly ignore: ReadonlySet<string>;\n\n constructor(opts: DeepEqualDifferOptions = {}) {\n if (opts.ignore && opts.ignore.length > 0) {\n this.ignore = new Set([...DEFAULT_IGNORE_FIELDS, ...opts.ignore]);\n } else {\n this.ignore = DEFAULT_IGNORE_FIELDS;\n }\n }\n\n diff(\n existing: T | null,\n incoming: T,\n providerChangedFields?: string[],\n ): DiffResult {\n // Created-shape: every non-ignored field becomes `{from: null, to}`.\n if (existing === null) {\n const out: FieldDiff = {};\n for (const key of Object.keys(incoming)) {\n if (this.ignore.has(key)) continue;\n const value = (incoming as Record<string, unknown>)[key];\n // Skip fields that are themselves null/undefined — a created record\n // doesn't need to declare \"this field is null now\" for every\n // untouched column.\n if (value === null || value === undefined) continue;\n out[key] = { from: null, to: value };\n }\n return Object.keys(out).length === 0 ? 'noop' : out;\n }\n\n // Field set to compare. `providerChangedFields` narrows to a hint set;\n // ignored fields are filtered out regardless of hint.\n const candidates = new Set<string>();\n if (providerChangedFields && providerChangedFields.length > 0) {\n for (const key of providerChangedFields) {\n if (!this.ignore.has(key)) candidates.add(key);\n }\n } else {\n for (const key of Object.keys(incoming)) {\n if (!this.ignore.has(key)) candidates.add(key);\n }\n // Also include keys that exist on existing but not on incoming —\n // e.g. a field that was cleared. This would otherwise be missed when\n // incoming carries an undefined column we drop from the iteration.\n for (const key of Object.keys(existing)) {\n if (this.ignore.has(key)) continue;\n if (!(key in (incoming as Record<string, unknown>))) continue;\n candidates.add(key);\n }\n }\n\n const out: FieldDiff = {};\n for (const key of candidates) {\n const before = (existing as Record<string, unknown>)[key];\n const after = (incoming as Record<string, unknown>)[key];\n if (!isEqual(before, after)) {\n out[key] = { from: before ?? null, to: after ?? null };\n }\n }\n\n return Object.keys(out).length === 0 ? 'noop' : out;\n }\n}\n\n// ─── equality helpers ───────────────────────────────────────────────────────\n\n/**\n * Field-level equality with the canonical-integration normalizations:\n * - Date → toISOString (adapters deliver strings)\n * - numeric-string vs number → numeric equality when both parse\n * - deep equality for plain objects/arrays (single-level is enough for\n * canonical records; nested records travel as jsonb columns where the\n * sink already owns the comparison)\n */\nfunction isEqual(a: unknown, b: unknown): boolean {\n if (a === b) return true;\n\n const na = normalize(a);\n const nb = normalize(b);\n if (na === nb) return true;\n\n // After normalization: both may still be non-primitive objects.\n if (\n typeof na === 'object' &&\n typeof nb === 'object' &&\n na !== null &&\n nb !== null\n ) {\n return deepEqualObject(na as Record<string, unknown>, nb as Record<string, unknown>);\n }\n\n // Numeric string ↔ number: when one side is a number and the other is a\n // string that parses to the same finite number.\n const numericEqual = maybeNumericEqual(na, nb) || maybeNumericEqual(nb, na);\n return numericEqual;\n}\n\nfunction normalize(value: unknown): unknown {\n if (value instanceof Date) return value.toISOString();\n return value;\n}\n\nfunction maybeNumericEqual(a: unknown, b: unknown): boolean {\n // a is string-shape, b is number — parse a and compare. Only when the\n // string looks numeric AND the parse round-trips (no silent NaN pass-\n // through on non-numeric strings).\n if (typeof a !== 'string' || typeof b !== 'number') return false;\n if (a.trim() === '') return false;\n const parsed = Number(a);\n if (!Number.isFinite(parsed)) return false;\n return parsed === b;\n}\n\nfunction deepEqualObject(\n a: Record<string, unknown>,\n b: Record<string, unknown>,\n): boolean {\n if (Array.isArray(a) !== Array.isArray(b)) return false;\n const aKeys = Object.keys(a);\n const bKeys = Object.keys(b);\n if (aKeys.length !== bKeys.length) return false;\n for (const key of aKeys) {\n if (!(key in b)) return false;\n if (!isEqual(a[key], b[key])) return false;\n }\n return true;\n}\n"],"mappings":"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","names":["out"]}
|