@pattern-stack/codegen 0.21.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.
- package/CHANGELOG.md +33 -0
- package/README.md +5 -1
- package/dist/{chunk-3A34R6CI.js → chunk-3VEVGL74.js} +4 -4
- package/dist/{chunk-G3IKPDTP.js → chunk-42763UEE.js} +2 -2
- package/dist/{chunk-524YKITE.js → chunk-4M66MQYA.js} +50 -6
- package/dist/chunk-4M66MQYA.js.map +1 -0
- package/dist/{chunk-EEJC66ZF.js → chunk-6XP2Q5SS.js} +3 -3
- package/dist/{chunk-6CJRZHV4.js → chunk-7B7MMDOJ.js} +57 -4
- package/dist/chunk-7B7MMDOJ.js.map +1 -0
- package/dist/{chunk-NXHL5YII.js → chunk-7LKAMLV4.js} +4 -4
- package/dist/{chunk-7625PLY7.js → chunk-COGHTKXY.js} +4 -4
- package/dist/{chunk-GV337QP3.js → chunk-E5FJWOMP.js} +7 -7
- package/dist/{chunk-TKU6VYG3.js → chunk-E6PLM6QG.js} +6 -6
- package/dist/{chunk-GMRTI7AK.js → chunk-FIUC6QB5.js} +3 -3
- package/dist/chunk-FIUC6QB5.js.map +1 -0
- package/dist/{chunk-YXI7K4MJ.js → chunk-PNCOUFFI.js} +7 -5
- package/dist/chunk-PNCOUFFI.js.map +1 -0
- package/dist/{chunk-MBFSG4KQ.js → chunk-SH76CFAY.js} +9 -4
- package/dist/chunk-SH76CFAY.js.map +1 -0
- package/dist/runtime/subsystems/auth/auth.module.js +3 -3
- package/dist/runtime/subsystems/auth/index.js +7 -7
- package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +1 -1
- package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +4 -4
- package/dist/runtime/subsystems/bridge/bridge.module.js +10 -10
- package/dist/runtime/subsystems/bridge/index.js +13 -13
- package/dist/runtime/subsystems/cache/cache.module.js +2 -2
- package/dist/runtime/subsystems/cache/index.js +4 -4
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/events/events.module.js +3 -3
- package/dist/runtime/subsystems/events/index.js +5 -5
- package/dist/runtime/subsystems/index.js +48 -48
- package/dist/runtime/subsystems/jobs/index.js +18 -18
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +5 -5
- package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +3 -3
- package/dist/runtime/subsystems/jobs/job-worker.d.ts +592 -4
- package/dist/runtime/subsystems/jobs/job-worker.js +4 -2
- package/dist/runtime/subsystems/jobs/job-worker.module.js +6 -6
- package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +19 -0
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +4 -4
- 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 +226 -65
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.d.ts +477 -1
- package/dist/src/index.js +1 -1
- package/package.json +1 -1
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +23 -7
- package/runtime/subsystems/jobs/job-worker.module.ts +5 -0
- package/runtime/subsystems/jobs/job-worker.ts +126 -12
- package/runtime/subsystems/jobs/jobs-domain.module.ts +19 -0
- package/templates/entity/new/clean-lite-ps/prompt-extension.js +59 -10
- package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +11 -0
- package/dist/chunk-524YKITE.js.map +0 -1
- package/dist/chunk-6CJRZHV4.js.map +0 -1
- package/dist/chunk-GMRTI7AK.js.map +0 -1
- package/dist/chunk-MBFSG4KQ.js.map +0 -1
- package/dist/chunk-YXI7K4MJ.js.map +0 -1
- /package/dist/{chunk-3A34R6CI.js.map → chunk-3VEVGL74.js.map} +0 -0
- /package/dist/{chunk-G3IKPDTP.js.map → chunk-42763UEE.js.map} +0 -0
- /package/dist/{chunk-EEJC66ZF.js.map → chunk-6XP2Q5SS.js.map} +0 -0
- /package/dist/{chunk-NXHL5YII.js.map → chunk-7LKAMLV4.js.map} +0 -0
- /package/dist/{chunk-7625PLY7.js.map → chunk-COGHTKXY.js.map} +0 -0
- /package/dist/{chunk-GV337QP3.js.map → chunk-E5FJWOMP.js.map} +0 -0
- /package/dist/{chunk-TKU6VYG3.js.map → chunk-E6PLM6QG.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
|
|
6
|
-
* intervals: the poll loop (claim → process → repeat)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
58
|
-
*
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
...
|
|
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:
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|