@pattern-stack/codegen 0.22.0 → 0.23.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/{chunk-NR7QQ6ZI.js → chunk-42763UEE.js} +2 -2
  3. package/dist/{chunk-6ECCJVYW.js → chunk-4M66MQYA.js} +44 -2
  4. package/dist/chunk-4M66MQYA.js.map +1 -0
  5. package/dist/{chunk-FNHNSFIJ.js → chunk-6XP2Q5SS.js} +2 -2
  6. package/dist/{chunk-VDL5CJ5C.js → chunk-7B7MMDOJ.js} +54 -1
  7. package/dist/chunk-7B7MMDOJ.js.map +1 -0
  8. package/dist/{chunk-NXHL5YII.js → chunk-7LKAMLV4.js} +4 -4
  9. package/dist/{chunk-6DQEIXYU.js → chunk-FIUC6QB5.js} +1 -1
  10. package/dist/chunk-FIUC6QB5.js.map +1 -0
  11. package/dist/{chunk-DB5UXJC3.js → chunk-PNCOUFFI.js} +4 -2
  12. package/dist/chunk-PNCOUFFI.js.map +1 -0
  13. package/dist/{chunk-QXVCRA23.js → chunk-SH76CFAY.js} +9 -4
  14. package/dist/chunk-SH76CFAY.js.map +1 -0
  15. package/dist/runtime/base-classes/index.js +17 -17
  16. package/dist/runtime/subsystems/auth/auth.module.js +3 -3
  17. package/dist/runtime/subsystems/auth/index.js +7 -7
  18. package/dist/runtime/subsystems/bridge/bridge.module.js +4 -4
  19. package/dist/runtime/subsystems/bridge/index.js +4 -4
  20. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +1 -1
  21. package/dist/runtime/subsystems/events/events.module.js +2 -2
  22. package/dist/runtime/subsystems/events/index.js +2 -2
  23. package/dist/runtime/subsystems/index.js +12 -12
  24. package/dist/runtime/subsystems/jobs/index.js +3 -3
  25. package/dist/runtime/subsystems/jobs/job-worker.d.ts +592 -4
  26. package/dist/runtime/subsystems/jobs/job-worker.js +3 -1
  27. package/dist/runtime/subsystems/jobs/job-worker.module.js +3 -3
  28. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +19 -0
  29. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +1 -1
  30. package/dist/src/cli/index.js +211 -60
  31. package/dist/src/cli/index.js.map +1 -1
  32. package/dist/src/index.d.ts +477 -1
  33. package/dist/src/index.js +1 -1
  34. package/package.json +1 -1
  35. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +23 -7
  36. package/runtime/subsystems/jobs/job-worker.module.ts +5 -0
  37. package/runtime/subsystems/jobs/job-worker.ts +126 -12
  38. package/runtime/subsystems/jobs/jobs-domain.module.ts +19 -0
  39. package/templates/entity/new/clean-lite-ps/prompt-extension.js +59 -10
  40. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +11 -0
  41. package/dist/chunk-6DQEIXYU.js.map +0 -1
  42. package/dist/chunk-6ECCJVYW.js.map +0 -1
  43. package/dist/chunk-DB5UXJC3.js.map +0 -1
  44. package/dist/chunk-QXVCRA23.js.map +0 -1
  45. package/dist/chunk-VDL5CJ5C.js.map +0 -1
  46. /package/dist/{chunk-NR7QQ6ZI.js.map → chunk-42763UEE.js.map} +0 -0
  47. /package/dist/{chunk-FNHNSFIJ.js.map → chunk-6XP2Q5SS.js.map} +0 -0
  48. /package/dist/{chunk-NXHL5YII.js.map → chunk-7LKAMLV4.js.map} +0 -0
@@ -2,11 +2,12 @@
2
2
  * JobWorker — backend-agnostic tick loop for the job orchestration domain
3
3
  * (ADR-022, JOB-3).
4
4
  *
5
- * One worker instance per active pool. On `onModuleInit` it starts two
6
- * intervals: the poll loop (claim → process → repeat) and the stale-claim
7
- * sweeper. On `onModuleDestroy` / SIGTERM it drains in-flight work and
8
- * releases still-`running` rows back to `pending` so a replacement worker
9
- * can resume with step memoization intact.
5
+ * One worker instance per active pool. On `onModuleInit` it starts three
6
+ * intervals: the poll loop (claim → process → repeat), the claim heartbeat
7
+ * (CLAIM-HB-1 renews `claimed_at` for in-flight runs so a long handler isn't
8
+ * swept), and the stale-claim sweeper. On `onModuleDestroy` / SIGTERM it drains
9
+ * in-flight work and releases still-`running` rows back to `pending` so a
10
+ * replacement worker can resume with step memoization intact.
10
11
  *
11
12
  * The claim query is the beating heart: `SELECT … FOR UPDATE SKIP LOCKED`
12
13
  * inside a single transaction. Multiple worker processes share the table
@@ -54,10 +55,29 @@ export interface JobWorkerOptions {
54
55
  /** Stale sweep interval in ms. Default 60_000. */
55
56
  staleSweeperIntervalMs?: number;
56
57
  /**
57
- * Threshold beyond which a `running` row is presumed stranded by a
58
- * crashed worker. Default 5 min. Must be >= max handler duration.
58
+ * Threshold beyond which a `running` row whose `claimed_at` has NOT been
59
+ * renewed is presumed stranded by a crashed worker, and the sweeper resets
60
+ * it to `pending`. Default 5 min.
61
+ *
62
+ * With the claim heartbeat (CLAIM-HB-1) in place this is a *liveness*
63
+ * threshold — a live worker bumps `claimed_at` every
64
+ * `claimHeartbeatIntervalMs`, so a long-running-but-alive handler is NEVER
65
+ * swept; only a row whose worker died (process crash/SIGKILL, no clean
66
+ * shutdown reset) ages past the threshold. It therefore no longer needs to
67
+ * be `>= 2× max handler duration` — it just needs to exceed a few missed
68
+ * heartbeats (default leaves a 3× heartbeat margin).
59
69
  */
60
70
  staleThresholdMs?: number;
71
+ /**
72
+ * CLAIM-HB-1 — interval at which this worker bumps `claimed_at = now()` for
73
+ * every run it currently holds in flight (one batched UPDATE). This is the
74
+ * lease renewal that keeps a legitimately long-running handler from being
75
+ * swept by `sweepStaleClaims`. Default `staleThresholdMs / 3` so a row
76
+ * survives up to two missed renewals before the sweeper acts. MUST be
77
+ * comfortably less than `staleThresholdMs` or live runs will be re-queued
78
+ * mid-flight.
79
+ */
80
+ claimHeartbeatIntervalMs?: number;
61
81
  /** Max ms to wait for in-flight drain on SIGTERM. Default 30_000. */
62
82
  shutdownTimeoutMs?: number;
63
83
  /**
@@ -78,6 +98,12 @@ const DEFAULT_POLL_INTERVAL_MS = 1_000;
78
98
  const DEFAULT_STALE_SWEEPER_INTERVAL_MS = 60_000;
79
99
  const DEFAULT_STALE_THRESHOLD_MS = 5 * 60_000;
80
100
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
101
+ /**
102
+ * CLAIM-HB-1 — the heartbeat fires at `staleThresholdMs / DIVISOR`, leaving
103
+ * `DIVISOR - 1` missed-renewal margin before the sweeper presumes the worker
104
+ * dead. 3 gives two missed beats of slack while keeping the renewal cheap.
105
+ */
106
+ const CLAIM_HEARTBEAT_DIVISOR = 3;
81
107
 
82
108
  const TERMINAL_STATUSES: JobRunRow['status'][] = [
83
109
  'completed',
@@ -172,6 +198,26 @@ export function buildStaleSweepQuery(
172
198
  .for('update', { skipLocked: true });
173
199
  }
174
200
 
201
+ /**
202
+ * CLAIM-HB-1 — build the heartbeat renewal UPDATE. Bumps `claimed_at = now()`
203
+ * (and `updated_at`) for the given run IDs, but ONLY rows still `status =
204
+ * 'running'`: a row this worker thinks it owns may have been swept and
205
+ * reclaimed by another worker (now running elsewhere), or already moved to a
206
+ * terminal state — the status guard makes the renewal a safe no-op in both
207
+ * cases rather than resurrecting a lease the worker no longer holds. Exported so
208
+ * tests can inspect `.toSQL()` without a live DB.
209
+ */
210
+ export function buildClaimRenewQuery(
211
+ db: DrizzleClient,
212
+ runIds: string[],
213
+ now: Date = new Date(),
214
+ ) {
215
+ return db
216
+ .update(jobRuns)
217
+ .set({ claimedAt: now, updatedAt: now })
218
+ .where(and(inArray(jobRuns.id, runIds), eq(jobRuns.status, 'running')));
219
+ }
220
+
175
221
  // ─── Error serialisation ───────────────────────────────────────────────────
176
222
 
177
223
  function serialiseError(err: unknown, attempt: number, retryable: boolean) {
@@ -191,14 +237,25 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
191
237
  private readonly logger = new Logger(JobWorker.name);
192
238
  private shuttingDown = false;
193
239
  private readonly inFlight = new Set<Promise<void>>();
240
+ /**
241
+ * CLAIM-HB-1 — the set of run IDs this worker currently has executing. The
242
+ * heartbeat renews `claimed_at` for exactly these; a run is added when its
243
+ * `processRun` is dispatched and removed when its execution settles (success,
244
+ * failure, retry-release, or concurrency-defer). Kept separate from
245
+ * `inFlight` (which tracks the wrapper Promises for drain) because the
246
+ * heartbeat needs the IDs, not the promises.
247
+ */
248
+ private readonly inFlightRunIds = new Set<string>();
194
249
  private pollTimer: ReturnType<typeof setInterval> | null = null;
195
250
  private sweeperTimer: ReturnType<typeof setInterval> | null = null;
251
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
196
252
  private sigtermHandled = false;
197
253
  private readonly sigtermHandler: () => void;
198
254
 
199
255
  private readonly pollIntervalMs: number;
200
256
  private readonly staleSweeperIntervalMs: number;
201
257
  private readonly staleThresholdMs: number;
258
+ private readonly claimHeartbeatIntervalMs: number;
202
259
  private readonly shutdownTimeoutMs: number;
203
260
 
204
261
  // LISTEN-NOTIFY-1 — dedicated listener + debounce state. `null` when
@@ -222,6 +279,12 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
222
279
  this.staleSweeperIntervalMs =
223
280
  options.staleSweeperIntervalMs ?? DEFAULT_STALE_SWEEPER_INTERVAL_MS;
224
281
  this.staleThresholdMs = options.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;
282
+ // CLAIM-HB-1 — default to a third of the stale threshold so a row tolerates
283
+ // two missed renewals before the sweeper acts. A consumer-supplied value is
284
+ // honored verbatim (it's their call if they want it tighter/looser).
285
+ this.claimHeartbeatIntervalMs =
286
+ options.claimHeartbeatIntervalMs ??
287
+ Math.max(1, Math.floor(this.staleThresholdMs / CLAIM_HEARTBEAT_DIVISOR));
225
288
  this.shutdownTimeoutMs =
226
289
  options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
227
290
  this.listenNotifyEnabled = options.listenNotify ?? false;
@@ -245,6 +308,12 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
245
308
  this.sweeperTimer = setInterval(() => {
246
309
  void this.sweepStaleClaims();
247
310
  }, this.staleSweeperIntervalMs);
311
+ // CLAIM-HB-1 — renew the claim lease on in-flight runs so a legitimately
312
+ // long-running handler is not swept mid-flight. No-ops cheaply (no UPDATE)
313
+ // when nothing is in flight.
314
+ this.heartbeatTimer = setInterval(() => {
315
+ void this.renewClaims();
316
+ }, this.claimHeartbeatIntervalMs);
248
317
  process.on('SIGTERM', this.sigtermHandler);
249
318
 
250
319
  // LISTEN-NOTIFY-1 — start the wake listener ALONGSIDE the poll timer (never
@@ -342,6 +411,10 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
342
411
  clearInterval(this.sweeperTimer);
343
412
  this.sweeperTimer = null;
344
413
  }
414
+ if (this.heartbeatTimer) {
415
+ clearInterval(this.heartbeatTimer);
416
+ this.heartbeatTimer = null;
417
+ }
345
418
  process.removeListener('SIGTERM', this.sigtermHandler);
346
419
 
347
420
  await this.drainInFlight();
@@ -406,11 +479,21 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
406
479
  if (!claimed) return;
407
480
 
408
481
  const run = claimed;
409
- const promise = this.processRun(run).catch((err) => {
410
- this.logger.error(
411
- `processRun(${run.id}) unhandled: ${(err as Error).message}`,
412
- );
413
- });
482
+ // CLAIM-HB-1 register the run as in-flight so the heartbeat renews its
483
+ // lease, and deregister the moment its execution settles (success, failure,
484
+ // retry-release, concurrency-defer every path out of processRun). Held in
485
+ // a `finally` so an unhandled throw can't strand the id in the renew set and
486
+ // keep bumping `claimed_at` for a run this worker no longer owns.
487
+ this.inFlightRunIds.add(run.id);
488
+ const promise = this.processRun(run)
489
+ .catch((err) => {
490
+ this.logger.error(
491
+ `processRun(${run.id}) unhandled: ${(err as Error).message}`,
492
+ );
493
+ })
494
+ .finally(() => {
495
+ this.inFlightRunIds.delete(run.id);
496
+ });
414
497
  this.inFlight.add(promise);
415
498
  promise.finally(() => {
416
499
  this.inFlight.delete(promise);
@@ -490,6 +573,37 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
490
573
  }
491
574
  }
492
575
 
576
+ // ============================================================================
577
+ // Claim heartbeat (CLAIM-HB-1)
578
+ // ============================================================================
579
+
580
+ /**
581
+ * Renew the claim lease on every run this worker currently has in flight by
582
+ * bumping `claimed_at = now()` in a single UPDATE. This is what keeps
583
+ * `sweepStaleClaims` from re-queueing a legitimately long-running handler:
584
+ * the sweeper only resets rows whose `claimed_at` has aged past the threshold,
585
+ * and a live worker keeps renewing. When the worker process dies, renewal
586
+ * stops, the row ages out, and the sweeper correctly recovers it — its
587
+ * documented "stranded by a crashed worker" intent.
588
+ *
589
+ * No-ops (no query) when nothing is in flight. The `status = 'running'` guard
590
+ * inside the UPDATE means a run that was swept-and-reclaimed elsewhere (or has
591
+ * already settled) is not touched.
592
+ */
593
+ async renewClaims(): Promise<void> {
594
+ if (this.shuttingDown) return;
595
+ const ids = [...this.inFlightRunIds];
596
+ if (ids.length === 0) return;
597
+ try {
598
+ await buildClaimRenewQuery(this.db, ids, new Date());
599
+ } catch (err) {
600
+ // Best-effort: a transient failure just means this beat was missed. The
601
+ // staleThreshold leaves several beats of slack before the sweeper acts,
602
+ // and the next beat retries.
603
+ this.logger.error(`renewClaims failed: ${(err as Error).message}`);
604
+ }
605
+ }
606
+
493
607
  // ============================================================================
494
608
  // processRun
495
609
  // ============================================================================
@@ -59,6 +59,25 @@ export interface DrizzleBackendExtensions {
59
59
  listenNotify?: boolean;
60
60
  /** Polling interval (ms). Default 1000. */
61
61
  pollIntervalMs?: number;
62
+ /**
63
+ * CLAIM-HB-1 — stale-claim sweep interval (ms). How often each worker scans
64
+ * for `running` rows whose lease has expired. Default 60_000.
65
+ */
66
+ staleSweeperIntervalMs?: number;
67
+ /**
68
+ * CLAIM-HB-1 — stale-claim threshold (ms). A `running` row whose `claimed_at`
69
+ * has not been renewed within this window is presumed stranded by a dead
70
+ * worker and reset to `pending`. A LIVE worker renews the lease every
71
+ * `claimHeartbeatIntervalMs`, so this only catches genuine crashes. Default
72
+ * 300_000 (5 min).
73
+ */
74
+ staleThresholdMs?: number;
75
+ /**
76
+ * CLAIM-HB-1 — claim heartbeat interval (ms). How often a worker bumps
77
+ * `claimed_at` for its in-flight runs to keep them from being swept. Must be
78
+ * comfortably below `staleThresholdMs`. Default `staleThresholdMs / 3`.
79
+ */
80
+ claimHeartbeatIntervalMs?: number;
62
81
  }
63
82
 
64
83
  export interface JobsDomainModuleOptions {
@@ -852,6 +852,29 @@ function processSearchQueries(queriesBlock, processedFields, belongsTo, entityNa
852
852
  // Integration write-surface derivation (#374)
853
853
  // ============================================================================
854
854
 
855
+ /**
856
+ * Shared delete-knob → softDelete boolean mapping rule (#490).
857
+ *
858
+ * Applied at BOTH derivations (repo config + sink body) so the two always agree:
859
+ * soft → true (set deletedAt)
860
+ * tombstone → false (null externalId/provider)
861
+ * absent → !!hasSoftDelete (preserve today's default)
862
+ * noop → !!hasSoftDelete (repo config irrelevant for noop — sink short-circuits)
863
+ *
864
+ * The sink only needs 'delegate' | 'noop' for its body decision; this helper
865
+ * is for the REPO config boolean. Mirrored verbatim in buildSinkInput caller
866
+ * (adapter-emission-generator.ts) for the contract test (spec Tests §3d).
867
+ *
868
+ * @param {'soft'|'tombstone'|'noop'|undefined} deleteKnob
869
+ * @param {boolean} hasSoftDelete
870
+ * @returns {boolean}
871
+ */
872
+ export function resolveSoftDeleteBoolean(deleteKnob, hasSoftDelete) {
873
+ if (deleteKnob === 'soft') return true;
874
+ if (deleteKnob === 'tombstone') return false;
875
+ return !!hasSoftDelete; // absent OR noop → preserve current default
876
+ }
877
+
855
878
  /**
856
879
  * Pre-compute the inbound-integration write surface for a `pattern: Integrated` entity.
857
880
  * Keeps the EJS thin + unit-testable: the template hand-emits the integrationConfig
@@ -866,14 +889,27 @@ function processSearchQueries(queriesBlock, processedFields, belongsTo, entityNa
866
889
  * @param {boolean} hasTimestamps
867
890
  * @param {boolean} eavEnabled
868
891
  * @param {boolean} hasSoftDelete
892
+ * @param {object} [fields] raw entity fields (for FK strict detection)
893
+ * @param {object} [sinkPolicy] integration.sink knobs {delete?, exclude_fields?}
869
894
  */
870
- export function buildIntegrationSurface(patternName, processedFields, belongsTo, hasTimestamps, eavEnabled, hasSoftDelete, fields) {
895
+ export function buildIntegrationSurface(patternName, processedFields, belongsTo, hasTimestamps, eavEnabled, hasSoftDelete, fields, sinkPolicy) {
871
896
  if (patternName !== 'Integrated') return null;
872
897
 
898
+ // Per-field exclusion (#490): drop declared-excluded fields from copy-through.
899
+ // Exclusion targets copy-through scalars only (FK columns and user_id are
900
+ // rejected at schema validation — the schema superRefine guards both).
901
+ // Match on snake_case `name` (how processedFields.name is keyed) so a
902
+ // multi-word field like `conversation_external_id` matches correctly.
903
+ const excludeSet = new Set(sinkPolicy?.exclude_fields ?? []);
904
+
873
905
  // Copy-through columns: every non-FK declared field. external_id_tracking
874
906
  // columns (external_id/provider/provider_metadata) are injected by the
875
907
  // behavior, NOT present in processedFields, so they're already excluded.
876
- const writeColumns = processedFields.map((f) => f.camelName);
908
+ // Excluded fields (#490) are also dropped here — they are removed from the
909
+ // copy-through write surface so integrationUpsertOne never clobbers them.
910
+ const writeColumns = processedFields
911
+ .filter((f) => !excludeSet.has(f.name))
912
+ .map((f) => f.camelName);
877
913
 
878
914
  // FK resolvers — one per belongs_to. writeKey = `${relationKey}ExternalId`
879
915
  // (Decision 4). refTable is the string 'self' for self-FKs, else the parent
@@ -896,12 +932,15 @@ export function buildIntegrationSurface(patternName, processedFields, belongsTo,
896
932
  importPath: rel.importPath,
897
933
  }));
898
934
 
899
- // Projection columns: id + externalId + copy-through + local FK columns +
900
- // timestamps. Omits provider/provider_metadata.
935
+ // Projection columns: id + externalId + ALL copy-through columns + local FK
936
+ // columns + timestamps. Omits provider/provider_metadata.
937
+ // Projection keeps excluded fields (#490) — exclusion is write-surface only.
938
+ // The find VIEW also keeps them (bare passthroughs); diff-soundness holds via
939
+ // the differ's `key in incoming` guard, not by omitting them from the view.
901
940
  const projectionColumns = [
902
941
  'id',
903
942
  'externalId',
904
- ...writeColumns,
943
+ ...processedFields.map((f) => f.camelName),
905
944
  ...belongsTo.map((rel) => rel.camelField),
906
945
  ...(hasTimestamps ? ['createdAt', 'updatedAt'] : []),
907
946
  ];
@@ -909,20 +948,26 @@ export function buildIntegrationSurface(patternName, processedFields, belongsTo,
909
948
  // The integrationConfig object literal the template hand-emits. fkResolvers carry a
910
949
  // sentinel so the template can swap `refTable` to either 'self' or the live
911
950
  // table identifier.
951
+ // softDelete: use resolveSoftDeleteBoolean (delete knob takes precedence over
952
+ // !!hasSoftDelete default; noop/absent both preserve !!hasSoftDelete, spec §Delete).
912
953
  const integrationConfig = {
913
954
  conflictTarget: ['provider', 'externalId'],
914
955
  writeColumns,
915
956
  projectionColumns,
916
957
  eav: !!eavEnabled,
917
- softDelete: !!hasSoftDelete,
958
+ softDelete: resolveSoftDeleteBoolean(sinkPolicy?.delete, hasSoftDelete),
918
959
  };
919
960
 
920
961
  // TIntegrationWrite fields: externalId:string, copy-through (typed, nullable-aware),
921
962
  // one `<writeKey>?: string | null` per FK, fields?: Record<string, unknown>.
922
- const writeFields = processedFields.map((f) => ({
923
- camelName: f.camelName,
924
- tsType: f.nullable ? `${f.tsType} | null` : f.tsType,
925
- }));
963
+ // Excluded fields (#490) are dropped from writeFields too — same exclusion set
964
+ // as writeColumns. The projection keeps them (projectionFields below).
965
+ const writeFields = processedFields
966
+ .filter((f) => !excludeSet.has(f.name))
967
+ .map((f) => ({
968
+ camelName: f.camelName,
969
+ tsType: f.nullable ? `${f.tsType} | null` : f.tsType,
970
+ }));
926
971
  const writeFkFields = fkResolvers.map((fk) => ({
927
972
  name: fk.writeKey,
928
973
  tsType: 'string | null',
@@ -1336,6 +1381,9 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
1336
1381
  }));
1337
1382
 
1338
1383
  // Integration write-surface derivation (#374) — null unless pattern: Integrated.
1384
+ // Pass sinkPolicy (#490) so the delete knob and exclude_fields knob are applied
1385
+ // at this derivation (repo config) as well as buildSinkInput (sink derivation).
1386
+ const sinkPolicy = definition.integration?.sink ?? null;
1339
1387
  const integrationSurface = buildIntegrationSurface(
1340
1388
  patternName,
1341
1389
  nonFkFields,
@@ -1344,6 +1392,7 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
1344
1392
  eavEnabled,
1345
1393
  hasSoftDelete,
1346
1394
  fields,
1395
+ sinkPolicy,
1347
1396
  );
1348
1397
 
1349
1398
  // EVT-7: emits locals flow through from baseLocals (prompt.js computed them
@@ -30,6 +30,17 @@ jobs:
30
30
  # # are simply never received and the worker
31
31
  # # degrades to polling.
32
32
  poll_interval_ms: 1000 # interval-poll heartbeat (the wake fallback)
33
+ # ── Claim lease / heartbeat (CLAIM-HB-1) ──
34
+ # A live worker renews `claimed_at` for its in-flight runs every
35
+ # `claim_heartbeat_interval_ms`, so a legitimately long-running handler is
36
+ # NEVER swept; only a row whose worker died ages past
37
+ # `stale_threshold_ms` and is reset to `pending` by the sweeper. Raise the
38
+ # threshold only if you expect worker-crash recovery to wait longer; the
39
+ # heartbeat (not the threshold) is what protects long handlers.
40
+ # stale_threshold_ms: 300000 # dead-worker recovery window (5 min)
41
+ # stale_sweeper_interval_ms: 60000 # how often the sweeper scans
42
+ # claim_heartbeat_interval_ms: 100000 # lease renewal cadence
43
+ # # (default = stale_threshold_ms / 3)
33
44
  # bullmq: # Example shape for Phase 6+ BullMQ backend.
34
45
  # bull_board: # Mount Bull Board admin UI.
35
46
  # enabled: true
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/jobs/jobs-domain.module.ts"],"sourcesContent":["/**\n * JobsDomainModule — `DynamicModule.forRoot({ backend })` factory wiring\n * the three jobs-domain protocol tokens to a backend implementation\n * (ADR-022, JOB-5).\n *\n * Mirrors `EventsModule.forRoot()` exactly:\n * - `global: true` so consumer entity modules don't have to import this\n * individually — `JOB_ORCHESTRATOR` / `JOB_RUN_SERVICE` /\n * `JOB_STEP_SERVICE` are available project-wide.\n * - One backend at a time (Drizzle for production, Memory for tests).\n *\n * Backend swappability follows the core/extension protocol from CLAUDE.md:\n * the three tokens are the **core contract**; backend-specific tunables\n * live under `extensions.<backend>` so opting into a feature is explicit\n * and the type system reserves the slot.\n */\nimport { Module, type DynamicModule, type Provider } from '@nestjs/common';\nimport { DRIZZLE } from '../../constants/tokens';\nimport {\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n JOBS_MULTI_TENANT,\n JOBS_LISTEN_NOTIFY,\n} from './jobs-domain.tokens';\nimport { DrizzleJobOrchestrator } from './job-orchestrator.drizzle-backend';\nimport { DrizzleJobRunService } from './job-run-service.drizzle-backend';\nimport { DrizzleJobStepService } from './job-step-service.drizzle-backend';\n// #6 — `BullMQJobOrchestrator` is lazy-loaded only when `backend: 'bullmq'`\n// is selected. The backend file is filtered out of drizzle/memory installs\n// (see `backendFileFilter`); a non-literal dynamic import below sidesteps\n// consumer-side tsc resolution of an absent file.\nimport { MemoryJobOrchestrator } from './job-orchestrator.memory-backend';\nimport { MemoryJobRunService } from './job-run-service.memory-backend';\nimport { MemoryJobStepService } from './job-step-service.memory-backend';\nimport { MemoryJobStore } from './memory-job-store';\nimport {\n BULLMQ_CONNECTION,\n BULLMQ_RESOLVED_CONFIG,\n resolveBullMqConfig,\n type BullMqExtensionsConfig,\n} from './bullmq.config';\n\n/**\n * Drizzle backend extensions surface (LISTEN-NOTIFY-1 wires both fields).\n *\n * - `listenNotify` → provided as `JOBS_LISTEN_NOTIFY` so the orchestrator emits\n * in-tx `pg_notify` on enqueue, and threaded into each spawned `JobWorker`\n * (which holds the listener connection). Off by default.\n * - `pollIntervalMs` → threaded into the spawned `JobWorker`'s\n * `JobWorkerOptions.pollIntervalMs` (the worker already honored this; it just\n * never received a config value). Default 1000.\n *\n * Both run ALONGSIDE interval polling — `listenNotify` only adds an early wake;\n * polling remains the durability heartbeat.\n */\nexport interface DrizzleBackendExtensions {\n /** Use Postgres LISTEN/NOTIFY to wake the polling loop. Default false. */\n listenNotify?: boolean;\n /** Polling interval (ms). Default 1000. */\n pollIntervalMs?: number;\n}\n\nexport interface JobsDomainModuleOptions {\n backend: 'drizzle' | 'memory' | 'bullmq';\n /**\n * Backend-specific extensions. Only the matching backend's extensions\n * are read at boot; non-matching keys are ignored. This is the\n * core/extension protocol surface — see CLAUDE.md.\n */\n extensions?: {\n drizzle?: DrizzleBackendExtensions;\n /**\n * BullMQ backend extensions (BULLMQ-1). Snake_case mirrors the YAML\n * under `jobs.extensions.bullmq`. `redis_url` falls back to\n * `process.env.REDIS_URL` then `redis://localhost:6379`.\n */\n bullmq?: BullMqExtensionsConfig;\n };\n /** Multi-tenancy opt-in. Wired by JOB-8; module signature stays stable. */\n multiTenant?: boolean;\n}\n\n@Module({})\nexport class JobsDomainModule {\n static forRoot(opts: JobsDomainModuleOptions): DynamicModule {\n const multiTenant = opts.multiTenant ?? false;\n // LISTEN-NOTIFY-1 — drizzle-only extension. `listen_notify` is meaningless\n // for memory (no DB) and redundant for bullmq (native wakeups); only the\n // drizzle backend's orchestrator reads it.\n const listenNotify =\n opts.backend === 'drizzle' && Boolean(opts.extensions?.drizzle?.listenNotify);\n\n const providers: Provider[] = [\n // JOB-8 — boolean provider consumed by the four service-layer backends.\n // Always provided (even when `multiTenant === false`) so `@Inject`\n // always resolves; backends short-circuit the enforcement path when\n // the value is `false`. See `jobs-domain.tokens.ts` for the claim-loop\n // cross-tenant-by-design decision.\n { provide: JOBS_MULTI_TENANT, useValue: multiTenant },\n // LISTEN-NOTIFY-1 — always provided so the orchestrator's `@Inject`\n // resolves; the orchestrator skips the `pg_notify` emit when `false`.\n { provide: JOBS_LISTEN_NOTIFY, useValue: listenNotify },\n ];\n\n if (opts.backend === 'memory') {\n // The store is a plain class — wired as a singleton `useValue` so\n // unit tests can pull it out via `.get(MemoryJobStore)` for direct\n // assertions (matches the access pattern in JOB-4 specs).\n const store = new MemoryJobStore();\n providers.push({ provide: MemoryJobStore, useValue: store });\n providers.push(MemoryJobStepService);\n providers.push({ provide: JOB_STEP_SERVICE, useExisting: MemoryJobStepService });\n providers.push(MemoryJobOrchestrator);\n providers.push({ provide: JOB_ORCHESTRATOR, useExisting: MemoryJobOrchestrator });\n providers.push(MemoryJobRunService);\n providers.push({ provide: JOB_RUN_SERVICE, useExisting: MemoryJobRunService });\n } else if (opts.backend === 'bullmq') {\n // BULLMQ-1 — BullMQ orchestrator over a Postgres source of truth. The\n // run/step services stay Drizzle (domain reads + `listForScope` are\n // Postgres queries, unchanged per spec). Only the orchestrator's\n // claim/dispatch half swaps to BullMQ.\n //\n // #6 — the bullmq backend module is filtered out of drizzle/memory\n // installs (no `bullmq` peer dep, no consumer-side tsc compile of an\n // unused file). The factory below dynamic-imports it via a non-literal\n // specifier so TS treats the module type as `any` and never tries to\n // resolve the absent file on a drizzle/memory consumer.\n const resolved = resolveBullMqConfig(opts.extensions?.bullmq);\n providers.push({ provide: BULLMQ_CONNECTION, useValue: resolved.connection });\n providers.push({ provide: BULLMQ_RESOLVED_CONFIG, useValue: resolved });\n providers.push({\n provide: JOB_ORCHESTRATOR,\n useFactory: async (...args: unknown[]): Promise<object> => {\n const specifier = './job-orchestrator.bullmq-backend';\n const mod = (await import(specifier)) as {\n BullMQJobOrchestrator: new (...args: unknown[]) => object;\n };\n return new mod.BullMQJobOrchestrator(...args);\n },\n // The bullmq orchestrator constructor mirrors DrizzleJobOrchestrator's\n // injection list: DRIZZLE + JOBS_MULTI_TENANT + the resolved BullMQ\n // tokens. Importing token references would force a static dep on the\n // tokens file in this module's import graph; using the existing\n // symbols already in scope is sufficient.\n inject: [DRIZZLE, JOBS_MULTI_TENANT, BULLMQ_CONNECTION, BULLMQ_RESOLVED_CONFIG],\n });\n providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });\n providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });\n } else {\n providers.push({ provide: JOB_ORCHESTRATOR, useClass: DrizzleJobOrchestrator });\n providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });\n providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });\n }\n\n const exports = [\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n JOBS_MULTI_TENANT,\n JOBS_LISTEN_NOTIFY,\n ];\n // BULLMQ-1 — only export the BullMQ tokens when they were actually\n // provided. Nest throws \"exported but not provided\" otherwise. Exported so\n // JobWorkerModule (which imports this module) can read the resolved\n // connection/config to spawn BullMQ workers.\n if (opts.backend === 'bullmq') {\n exports.push(BULLMQ_CONNECTION, BULLMQ_RESOLVED_CONFIG);\n }\n\n return {\n module: JobsDomainModule,\n global: true,\n providers,\n exports,\n };\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgBA,SAAS,cAAiD;AAoEnD,IAAM,mBAAN,MAAuB;AAAA,EAC5B,OAAO,QAAQ,MAA8C;AAC3D,UAAM,cAAc,KAAK,eAAe;AAIxC,UAAM,eACJ,KAAK,YAAY,aAAa,QAAQ,KAAK,YAAY,SAAS,YAAY;AAE9E,UAAM,YAAwB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAM5B,EAAE,SAAS,mBAAmB,UAAU,YAAY;AAAA;AAAA;AAAA,MAGpD,EAAE,SAAS,oBAAoB,UAAU,aAAa;AAAA,IACxD;AAEA,QAAI,KAAK,YAAY,UAAU;AAI7B,YAAM,QAAQ,IAAI,eAAe;AACjC,gBAAU,KAAK,EAAE,SAAS,gBAAgB,UAAU,MAAM,CAAC;AAC3D,gBAAU,KAAK,oBAAoB;AACnC,gBAAU,KAAK,EAAE,SAAS,kBAAkB,aAAa,qBAAqB,CAAC;AAC/E,gBAAU,KAAK,qBAAqB;AACpC,gBAAU,KAAK,EAAE,SAAS,kBAAkB,aAAa,sBAAsB,CAAC;AAChF,gBAAU,KAAK,mBAAmB;AAClC,gBAAU,KAAK,EAAE,SAAS,iBAAiB,aAAa,oBAAoB,CAAC;AAAA,IAC/E,WAAW,KAAK,YAAY,UAAU;AAWpC,YAAM,WAAW,oBAAoB,KAAK,YAAY,MAAM;AAC5D,gBAAU,KAAK,EAAE,SAAS,mBAAmB,UAAU,SAAS,WAAW,CAAC;AAC5E,gBAAU,KAAK,EAAE,SAAS,wBAAwB,UAAU,SAAS,CAAC;AACtE,gBAAU,KAAK;AAAA,QACb,SAAS;AAAA,QACT,YAAY,UAAU,SAAqC;AACzD,gBAAM,YAAY;AAClB,gBAAM,MAAO,MAAM,OAAO;AAG1B,iBAAO,IAAI,IAAI,sBAAsB,GAAG,IAAI;AAAA,QAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAMA,QAAQ,CAAC,SAAS,mBAAmB,mBAAmB,sBAAsB;AAAA,MAChF,CAAC;AACD,gBAAU,KAAK,EAAE,SAAS,iBAAiB,UAAU,qBAAqB,CAAC;AAC3E,gBAAU,KAAK,EAAE,SAAS,kBAAkB,UAAU,sBAAsB,CAAC;AAAA,IAC/E,OAAO;AACL,gBAAU,KAAK,EAAE,SAAS,kBAAkB,UAAU,uBAAuB,CAAC;AAC9E,gBAAU,KAAK,EAAE,SAAS,iBAAiB,UAAU,qBAAqB,CAAC;AAC3E,gBAAU,KAAK,EAAE,SAAS,kBAAkB,UAAU,sBAAsB,CAAC;AAAA,IAC/E;AAEA,UAAM,UAAU;AAAA,MACd;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAKA,QAAI,KAAK,YAAY,UAAU;AAC7B,cAAQ,KAAK,mBAAmB,sBAAsB;AAAA,IACxD;AAEA,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;AA7Fa,mBAAN;AAAA,EADN,OAAO,CAAC,CAAC;AAAA,GACG;","names":[]}