@pattern-stack/codegen 0.17.0 → 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +104 -0
- package/consumer-skills/integration/audit-and-detection.md +29 -4
- package/dist/{chunk-GJDEPTPY.js → chunk-235ZMMJR.js} +8 -8
- package/dist/{chunk-DTXH24LR.js → chunk-65MO75WM.js} +9 -9
- package/dist/{chunk-5RT7JGKT.js → chunk-7OVCARTQ.js} +4 -4
- package/dist/{chunk-CO6LUM72.js → chunk-7P5ODGLA.js} +34 -2
- package/dist/chunk-7P5ODGLA.js.map +1 -0
- package/dist/{chunk-P3AYBRP6.js → chunk-ATVGYF3D.js} +11 -5
- package/dist/chunk-ATVGYF3D.js.map +1 -0
- package/dist/{chunk-3MAZ4TQH.js → chunk-AZLUWG5S.js} +9 -9
- package/dist/{chunk-UTNWFHJF.js → chunk-B34G6PHD.js} +10 -10
- package/dist/{chunk-CDLWYZVQ.js → chunk-BHZP6LOV.js} +7 -7
- package/dist/{chunk-W2UIDI3R.js → chunk-CLWBNXKF.js} +4 -4
- package/dist/{chunk-OTR44OH6.js → chunk-E6PLM6QG.js} +34 -13
- package/dist/chunk-E6PLM6QG.js.map +1 -0
- package/dist/{chunk-43SBT72G.js → chunk-I6UXRJ3Q.js} +4 -4
- 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-L3VJ47BU.js → chunk-KZDHMZ45.js} +6 -6
- package/dist/{chunk-RHYNACZS.js → chunk-OZEPJGMA.js} +2 -2
- package/dist/{chunk-MYQIQ27N.js → chunk-Q6LRJ4VI.js} +51 -2
- package/dist/chunk-Q6LRJ4VI.js.map +1 -0
- package/dist/{chunk-NXNVTXKG.js → chunk-R6F6KFIL.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-OITTYGJS.js → chunk-VDL5CJ5C.js} +24 -14
- package/dist/chunk-VDL5CJ5C.js.map +1 -0
- package/dist/{chunk-3VEVGL74.js → chunk-VNBC3VXM.js} +4 -4
- package/dist/{chunk-BULPAAD3.js → chunk-VQOAATIG.js} +42 -11
- package/dist/chunk-VQOAATIG.js.map +1 -0
- package/dist/{chunk-E45CSC33.js → chunk-XKWOJZZ4.js} +2 -2
- 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 +12 -12
- package/dist/runtime/shared/openapi/index.js +5 -5
- package/dist/runtime/shared/openapi/registry.js +2 -2
- package/dist/runtime/subsystems/auth/auth.module.js +3 -3
- package/dist/runtime/subsystems/auth/index.js +12 -12
- package/dist/runtime/subsystems/bridge/bridge-delivery-handler.d.ts +1 -1
- package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +3 -3
- package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +3 -3
- package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +7 -7
- package/dist/runtime/subsystems/bridge/bridge.module.d.ts +1 -1
- package/dist/runtime/subsystems/bridge/bridge.module.js +19 -19
- package/dist/runtime/subsystems/bridge/event-flow.service.d.ts +1 -1
- package/dist/runtime/subsystems/bridge/event-flow.service.js +2 -2
- package/dist/runtime/subsystems/bridge/index.d.ts +1 -1
- package/dist/runtime/subsystems/bridge/index.js +21 -21
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +4 -4
- package/dist/runtime/subsystems/events/events.module.js +5 -5
- package/dist/runtime/subsystems/events/index.js +7 -7
- package/dist/runtime/subsystems/index.d.ts +1 -1
- package/dist/runtime/subsystems/index.js +100 -100
- package/dist/runtime/subsystems/integration/build-change-source.js +2 -2
- 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 +39 -39
- 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 +27 -27
- 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 +6 -5
- 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 +3 -2
- 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 +9 -1
- package/dist/runtime/subsystems/jobs/job-worker.js +3 -3
- package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.js +11 -11
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -9
- package/dist/runtime/subsystems/jobs/jobs-errors.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/pg-notify.d.ts +25 -1
- package/dist/runtime/subsystems/jobs/pg-notify.js +1 -1
- package/dist/runtime/subsystems/observability/index.d.ts +1 -1
- package/dist/runtime/subsystems/observability/index.js +3 -3
- package/dist/runtime/subsystems/observability/observability.module.js +3 -3
- 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/observability.service.js +2 -2
- 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 +1 -1
- package/dist/runtime/subsystems/storage/storage.module.js +1 -1
- package/dist/src/cli/index.js +38 -16
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.js +13 -13
- 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/runtime/subsystems/jobs/job-worker.ts +29 -11
- package/runtime/subsystems/jobs/pg-notify.ts +63 -3
- 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-MYQIQ27N.js.map +0 -1
- package/dist/chunk-OITTYGJS.js.map +0 -1
- package/dist/chunk-OTR44OH6.js.map +0 -1
- package/dist/chunk-P3AYBRP6.js.map +0 -1
- /package/dist/{chunk-GJDEPTPY.js.map → chunk-235ZMMJR.js.map} +0 -0
- /package/dist/{chunk-DTXH24LR.js.map → chunk-65MO75WM.js.map} +0 -0
- /package/dist/{chunk-5RT7JGKT.js.map → chunk-7OVCARTQ.js.map} +0 -0
- /package/dist/{chunk-3MAZ4TQH.js.map → chunk-AZLUWG5S.js.map} +0 -0
- /package/dist/{chunk-UTNWFHJF.js.map → chunk-B34G6PHD.js.map} +0 -0
- /package/dist/{chunk-CDLWYZVQ.js.map → chunk-BHZP6LOV.js.map} +0 -0
- /package/dist/{chunk-W2UIDI3R.js.map → chunk-CLWBNXKF.js.map} +0 -0
- /package/dist/{chunk-43SBT72G.js.map → chunk-I6UXRJ3Q.js.map} +0 -0
- /package/dist/{chunk-K2I6XIK5.js.map → chunk-KSTZIULO.js.map} +0 -0
- /package/dist/{chunk-L3VJ47BU.js.map → chunk-KZDHMZ45.js.map} +0 -0
- /package/dist/{chunk-RHYNACZS.js.map → chunk-OZEPJGMA.js.map} +0 -0
- /package/dist/{chunk-NXNVTXKG.js.map → chunk-R6F6KFIL.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-E45CSC33.js.map → chunk-XKWOJZZ4.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-
|
|
50
|
-
import "../chunk-
|
|
51
|
-
import "../chunk-SR7F3TJY.js";
|
|
49
|
+
import "../chunk-ATVGYF3D.js";
|
|
50
|
+
import "../chunk-YK5JEVLX.js";
|
|
52
51
|
import "../chunk-EO2QPOKH.js";
|
|
53
|
-
import "../chunk-
|
|
54
|
-
import "../chunk-
|
|
55
|
-
import "../chunk-LG57S2SC.js";
|
|
56
|
-
import "../chunk-DCCZB4UC.js";
|
|
57
|
-
import "../chunk-MZ6GV4YF.js";
|
|
58
|
-
import "../chunk-HNWZFNKP.js";
|
|
52
|
+
import "../chunk-PRWIX6UW.js";
|
|
53
|
+
import "../chunk-XWBK3XJK.js";
|
|
59
54
|
import "../chunk-AHV4GDYM.js";
|
|
60
|
-
import "../chunk-
|
|
61
|
-
import "../chunk-
|
|
62
|
-
import "../chunk-TIZXQU26.js";
|
|
63
|
-
import "../chunk-36U5UGIO.js";
|
|
55
|
+
import "../chunk-SQDOBLBP.js";
|
|
56
|
+
import "../chunk-JEINYUJH.js";
|
|
64
57
|
import "../chunk-5TK7MEN4.js";
|
|
65
58
|
import "../chunk-4KNXX6TI.js";
|
|
66
59
|
import "../chunk-3CJFPU6Q.js";
|
|
60
|
+
import "../chunk-TDEHU73T.js";
|
|
67
61
|
import "../chunk-S7C6TIIF.js";
|
|
62
|
+
import "../chunk-MZ6GV4YF.js";
|
|
63
|
+
import "../chunk-LG57S2SC.js";
|
|
64
|
+
import "../chunk-HNWZFNKP.js";
|
|
65
|
+
import "../chunk-I6UXRJ3Q.js";
|
|
66
|
+
import "../chunk-TIZXQU26.js";
|
|
67
|
+
import "../chunk-4MF3HKJA.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
|
|
@@ -317,6 +317,17 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
|
|
|
317
317
|
}
|
|
318
318
|
|
|
319
319
|
async onModuleDestroy(): Promise<void> {
|
|
320
|
+
// LISTEN-NOTIFY-2 — release the listener connection on EVERY destroy path,
|
|
321
|
+
// including the `shuttingDown` early-return below (reached when SIGTERM and
|
|
322
|
+
// Nest's onModuleDestroy both fire) and a destroy with no prior SIGTERM.
|
|
323
|
+
// Previously this lived only on the first (non-shuttingDown) branch, so a
|
|
324
|
+
// double-fire — or a stop() that raced the listener's own in-flight
|
|
325
|
+
// connect() — left a `LISTEN codegen_jobs_wake` socket open past
|
|
326
|
+
// `app.close()`, hanging the process. `PgNotifyListener.stop()` is itself
|
|
327
|
+
// idempotent + connect-race-safe (LISTEN-NOTIFY-2). Best-effort; a failure
|
|
328
|
+
// here doesn't block the drain.
|
|
329
|
+
await this.stopNotifyListener();
|
|
330
|
+
|
|
320
331
|
if (this.shuttingDown) {
|
|
321
332
|
// Still drain, but don't tear intervals down twice.
|
|
322
333
|
await this.drainInFlight();
|
|
@@ -333,17 +344,6 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
|
|
|
333
344
|
}
|
|
334
345
|
process.removeListener('SIGTERM', this.sigtermHandler);
|
|
335
346
|
|
|
336
|
-
// LISTEN-NOTIFY-1 — release the listener connection so the process can exit
|
|
337
|
-
// cleanly. Best-effort; a failure here doesn't block the drain.
|
|
338
|
-
if (this.notifyListener) {
|
|
339
|
-
try {
|
|
340
|
-
await this.notifyListener.stop();
|
|
341
|
-
} catch (err) {
|
|
342
|
-
this.logger.error(`notify listener stop failed: ${(err as Error).message}`);
|
|
343
|
-
}
|
|
344
|
-
this.notifyListener = null;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
347
|
await this.drainInFlight();
|
|
348
348
|
|
|
349
349
|
// Any rows still `running` past timeout → release back to pending.
|
|
@@ -370,6 +370,24 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
|
|
|
370
370
|
]);
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
/**
|
|
374
|
+
* LISTEN-NOTIFY-2 — stop + drop the wake listener. Idempotent: a second call
|
|
375
|
+
* (SIGTERM + Nest destroy) finds `notifyListener` already null and no-ops.
|
|
376
|
+
* `PgNotifyListener.stop()` is itself race-safe against an in-flight
|
|
377
|
+
* `connect()`, so even a destroy that arrives microseconds after `start()`
|
|
378
|
+
* releases the listener socket rather than leaking it.
|
|
379
|
+
*/
|
|
380
|
+
private async stopNotifyListener(): Promise<void> {
|
|
381
|
+
const listener = this.notifyListener;
|
|
382
|
+
if (!listener) return;
|
|
383
|
+
this.notifyListener = null;
|
|
384
|
+
try {
|
|
385
|
+
await listener.stop();
|
|
386
|
+
} catch (err) {
|
|
387
|
+
this.logger.error(`notify listener stop failed: ${(err as Error).message}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
373
391
|
// ============================================================================
|
|
374
392
|
// Poll loop
|
|
375
393
|
// ============================================================================
|