@pattern-stack/codegen 0.22.0 → 0.24.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 +56 -1
- package/consumer-skills/integration/SKILL.md +11 -3
- package/dist/{chunk-XKWOJZZ4.js → chunk-37PILMIT.js} +4 -4
- package/dist/{chunk-NR7QQ6ZI.js → chunk-6M6LZEP6.js} +3 -3
- package/dist/{chunk-VDL5CJ5C.js → chunk-7B7MMDOJ.js} +54 -1
- package/dist/chunk-7B7MMDOJ.js.map +1 -0
- package/dist/{chunk-NXHL5YII.js → chunk-7LKAMLV4.js} +4 -4
- package/dist/{chunk-6DQEIXYU.js → chunk-CKLM57IE.js} +10 -10
- package/dist/chunk-CKLM57IE.js.map +1 -0
- package/dist/{chunk-QXVCRA23.js → chunk-ENAR3F5S.js} +9 -4
- package/dist/chunk-ENAR3F5S.js.map +1 -0
- package/dist/{chunk-FFUDEIFF.js → chunk-HN5HT5WL.js} +2 -2
- package/dist/{chunk-6ECCJVYW.js → chunk-K4BQQ2NN.js} +46 -2
- package/dist/chunk-K4BQQ2NN.js.map +1 -0
- package/dist/{chunk-QFUIE37H.js → chunk-KFXXOFDC.js} +4 -4
- package/dist/{chunk-O2A6XHGD.js → chunk-LLDJS7PJ.js} +2 -2
- package/dist/{chunk-JOBQ6RUU.js → chunk-LQZESSM3.js} +28 -1
- package/dist/chunk-LQZESSM3.js.map +1 -0
- package/dist/{chunk-JRQO2IOF.js → chunk-MU54DZCC.js} +27 -1
- package/dist/chunk-MU54DZCC.js.map +1 -0
- package/dist/{chunk-INO47JXD.js → chunk-PBENHIN2.js} +3 -3
- package/dist/{chunk-CLWBNXKF.js → chunk-PLUJEQLU.js} +2 -2
- package/dist/{chunk-DB5UXJC3.js → chunk-PNCOUFFI.js} +4 -2
- package/dist/chunk-PNCOUFFI.js.map +1 -0
- package/dist/{chunk-S7C6TIIF.js → chunk-S5G3HO7N.js} +3 -1
- package/dist/chunk-S5G3HO7N.js.map +1 -0
- package/dist/{chunk-FNHNSFIJ.js → chunk-WZOPWQN2.js} +2 -2
- package/dist/{chunk-TDEHU73T.js → chunk-YIVQ7KLS.js} +46 -5
- package/dist/chunk-YIVQ7KLS.js.map +1 -0
- package/dist/runtime/subsystems/auth/auth.module.js +2 -2
- package/dist/runtime/subsystems/auth/index.js +4 -4
- package/dist/runtime/subsystems/bridge/bridge.module.js +7 -7
- package/dist/runtime/subsystems/bridge/index.js +7 -7
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +1 -1
- package/dist/runtime/subsystems/events/events.module.js +5 -5
- package/dist/runtime/subsystems/events/generated/bus.js +3 -3
- package/dist/runtime/subsystems/events/generated/index.d.ts +2 -2
- package/dist/runtime/subsystems/events/generated/index.js +9 -3
- package/dist/runtime/subsystems/events/generated/registry.d.ts +36 -0
- package/dist/runtime/subsystems/events/generated/registry.js +1 -1
- package/dist/runtime/subsystems/events/generated/schemas.d.ts +109 -1
- package/dist/runtime/subsystems/events/generated/schemas.js +7 -1
- package/dist/runtime/subsystems/events/generated/types.d.ts +48 -2
- package/dist/runtime/subsystems/events/index.js +5 -5
- package/dist/runtime/subsystems/index.d.ts +3 -2
- package/dist/runtime/subsystems/index.js +29 -25
- package/dist/runtime/subsystems/integration/execute-integration.use-case.d.ts +11 -1
- package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
- package/dist/runtime/subsystems/integration/index.d.ts +2 -1
- package/dist/runtime/subsystems/integration/index.js +10 -8
- package/dist/runtime/subsystems/integration/integration-change-emitter.protocol.d.ts +106 -0
- package/dist/runtime/subsystems/integration/integration-change-emitter.protocol.js +1 -0
- package/dist/runtime/subsystems/integration/integration-change-emitter.protocol.js.map +1 -0
- 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.js +4 -4
- package/dist/runtime/subsystems/integration/integration.tokens.d.ts +11 -1
- package/dist/runtime/subsystems/integration/integration.tokens.js +3 -1
- package/dist/runtime/subsystems/jobs/index.js +12 -12
- package/dist/runtime/subsystems/jobs/job-worker.d.ts +592 -4
- package/dist/runtime/subsystems/jobs/job-worker.js +3 -1
- 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/observability/index.js +3 -3
- package/dist/runtime/subsystems/observability/observability.module.js +3 -3
- package/dist/runtime/subsystems/observability/observability.service.js +2 -2
- package/dist/src/cli/index.js +413 -85
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.d.ts +490 -1
- package/dist/src/index.js +7 -7
- package/package.json +1 -1
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +23 -7
- package/runtime/subsystems/events/generated/registry.ts +27 -0
- package/runtime/subsystems/events/generated/schemas.ts +26 -0
- package/runtime/subsystems/events/generated/types.ts +52 -0
- package/runtime/subsystems/index.ts +23 -0
- package/runtime/subsystems/integration/execute-integration.use-case.ts +69 -1
- package/runtime/subsystems/integration/index.ts +6 -0
- package/runtime/subsystems/integration/integration-change-emitter.protocol.ts +107 -0
- package/runtime/subsystems/integration/integration.tokens.ts +11 -0
- 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-6DQEIXYU.js.map +0 -1
- package/dist/chunk-6ECCJVYW.js.map +0 -1
- package/dist/chunk-DB5UXJC3.js.map +0 -1
- package/dist/chunk-JOBQ6RUU.js.map +0 -1
- package/dist/chunk-JRQO2IOF.js.map +0 -1
- package/dist/chunk-QXVCRA23.js.map +0 -1
- package/dist/chunk-S7C6TIIF.js.map +0 -1
- package/dist/chunk-TDEHU73T.js.map +0 -1
- package/dist/chunk-VDL5CJ5C.js.map +0 -1
- /package/dist/{chunk-XKWOJZZ4.js.map → chunk-37PILMIT.js.map} +0 -0
- /package/dist/{chunk-NR7QQ6ZI.js.map → chunk-6M6LZEP6.js.map} +0 -0
- /package/dist/{chunk-NXHL5YII.js.map → chunk-7LKAMLV4.js.map} +0 -0
- /package/dist/{chunk-FFUDEIFF.js.map → chunk-HN5HT5WL.js.map} +0 -0
- /package/dist/{chunk-QFUIE37H.js.map → chunk-KFXXOFDC.js.map} +0 -0
- /package/dist/{chunk-O2A6XHGD.js.map → chunk-LLDJS7PJ.js.map} +0 -0
- /package/dist/{chunk-INO47JXD.js.map → chunk-PBENHIN2.js.map} +0 -0
- /package/dist/{chunk-CLWBNXKF.js.map → chunk-PLUJEQLU.js.map} +0 -0
- /package/dist/{chunk-FNHNSFIJ.js.map → chunk-WZOPWQN2.js.map} +0 -0
|
@@ -39,6 +39,29 @@ export const dealStageChangedPayloadSchema = z.object({
|
|
|
39
39
|
oldStage: z.string(),
|
|
40
40
|
}).strict();
|
|
41
41
|
|
|
42
|
+
export const messageCreatedPayloadSchema = z.object({
|
|
43
|
+
changedFields: z.record(z.unknown()).nullable(),
|
|
44
|
+
entityId: z.string().uuid(),
|
|
45
|
+
externalId: z.string(),
|
|
46
|
+
provider: z.string(),
|
|
47
|
+
source: z.string(),
|
|
48
|
+
}).strict();
|
|
49
|
+
|
|
50
|
+
export const messageDeletedPayloadSchema = z.object({
|
|
51
|
+
entityId: z.string().uuid(),
|
|
52
|
+
externalId: z.string(),
|
|
53
|
+
provider: z.string(),
|
|
54
|
+
source: z.string(),
|
|
55
|
+
}).strict();
|
|
56
|
+
|
|
57
|
+
export const messageEditedPayloadSchema = z.object({
|
|
58
|
+
changedFields: z.record(z.unknown()).nullable(),
|
|
59
|
+
entityId: z.string().uuid(),
|
|
60
|
+
externalId: z.string(),
|
|
61
|
+
provider: z.string(),
|
|
62
|
+
source: z.string(),
|
|
63
|
+
}).strict();
|
|
64
|
+
|
|
42
65
|
export const stripePaymentReceivedPayloadSchema = z.object({
|
|
43
66
|
amountCents: z.number(),
|
|
44
67
|
currency: z.string(),
|
|
@@ -60,6 +83,9 @@ export const eventPayloadSchemas = {
|
|
|
60
83
|
'crm_sync_started': crmSyncStartedPayloadSchema,
|
|
61
84
|
'deal_created': dealCreatedPayloadSchema,
|
|
62
85
|
'deal_stage_changed': dealStageChangedPayloadSchema,
|
|
86
|
+
'message_created': messageCreatedPayloadSchema,
|
|
87
|
+
'message_deleted': messageDeletedPayloadSchema,
|
|
88
|
+
'message_edited': messageEditedPayloadSchema,
|
|
63
89
|
'stripe_payment_received': stripePaymentReceivedPayloadSchema,
|
|
64
90
|
'webhook_outbound_contact_sync': webhookOutboundContactSyncPayloadSchema,
|
|
65
91
|
} as const satisfies Record<EventTypeName, z.ZodType>;
|
|
@@ -64,6 +64,55 @@ export interface DealStageChangedEvent extends DomainEvent {
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
export interface MessageCreatedEvent extends DomainEvent {
|
|
68
|
+
readonly type: 'message_created';
|
|
69
|
+
readonly aggregateType: 'message';
|
|
70
|
+
readonly payload: {
|
|
71
|
+
/** Differ's per-field before/after map (same value as integration_run_items.changed_fields). */
|
|
72
|
+
changedFields: Record<string, unknown> | null;
|
|
73
|
+
/** Local aggregate id the sink wrote/soft-deleted. */
|
|
74
|
+
entityId: string;
|
|
75
|
+
/** Vendor external id the change keyed on. */
|
|
76
|
+
externalId: string;
|
|
77
|
+
/** Provider label (e.g. 'slack', 'google'). */
|
|
78
|
+
provider: string;
|
|
79
|
+
/** Provenance marker — always 'integration'. A write-back action reads this to avoid echoing the change back to the vendor. */
|
|
80
|
+
source: string;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface MessageDeletedEvent extends DomainEvent {
|
|
85
|
+
readonly type: 'message_deleted';
|
|
86
|
+
readonly aggregateType: 'message';
|
|
87
|
+
readonly payload: {
|
|
88
|
+
/** Local aggregate id the sink wrote/soft-deleted. */
|
|
89
|
+
entityId: string;
|
|
90
|
+
/** Vendor external id the change keyed on. */
|
|
91
|
+
externalId: string;
|
|
92
|
+
/** Provider label (e.g. 'slack', 'google'). */
|
|
93
|
+
provider: string;
|
|
94
|
+
/** Provenance marker — always 'integration'. A write-back action reads this to avoid echoing the change back to the vendor. */
|
|
95
|
+
source: string;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface MessageEditedEvent extends DomainEvent {
|
|
100
|
+
readonly type: 'message_edited';
|
|
101
|
+
readonly aggregateType: 'message';
|
|
102
|
+
readonly payload: {
|
|
103
|
+
/** Differ's per-field before/after map (same value as integration_run_items.changed_fields). */
|
|
104
|
+
changedFields: Record<string, unknown> | null;
|
|
105
|
+
/** Local aggregate id the sink wrote/soft-deleted. */
|
|
106
|
+
entityId: string;
|
|
107
|
+
/** Vendor external id the change keyed on. */
|
|
108
|
+
externalId: string;
|
|
109
|
+
/** Provider label (e.g. 'slack', 'google'). */
|
|
110
|
+
provider: string;
|
|
111
|
+
/** Provenance marker — always 'integration'. A write-back action reads this to avoid echoing the change back to the vendor. */
|
|
112
|
+
source: string;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
67
116
|
/** Stripe charge.succeeded webhook, post-signature-verification. */
|
|
68
117
|
export interface StripePaymentReceivedEvent extends DomainEvent {
|
|
69
118
|
readonly type: 'stripe_payment_received';
|
|
@@ -97,6 +146,9 @@ export type AppDomainEvent =
|
|
|
97
146
|
| CrmSyncStartedEvent
|
|
98
147
|
| DealCreatedEvent
|
|
99
148
|
| DealStageChangedEvent
|
|
149
|
+
| MessageCreatedEvent
|
|
150
|
+
| MessageDeletedEvent
|
|
151
|
+
| MessageEditedEvent
|
|
100
152
|
| StripePaymentReceivedEvent
|
|
101
153
|
| WebhookOutboundContactSyncEvent;
|
|
102
154
|
|
|
@@ -12,6 +12,17 @@ export { EVENT_BUS } from './events';
|
|
|
12
12
|
// mode reaches it via `@shared/subsystems/events`; both must export it.
|
|
13
13
|
export type { DomainEvent, IEventBus, DrizzleTransaction } from './events';
|
|
14
14
|
export { EventsModule, DrizzleEventBus, MemoryEventBus } from './events';
|
|
15
|
+
// The `TYPED_EVENT_BUS` token is re-exported here so package-mode generated code
|
|
16
|
+
// that publishes events from the single `@pattern-stack/codegen/subsystems`
|
|
17
|
+
// barrel resolves it — notably the EMIT-CHANGES integration change-emitter
|
|
18
|
+
// (`<entity>.change-emitter.ts`), which injects `TYPED_EVENT_BUS`. The emitter
|
|
19
|
+
// types the bus with a local structural `publish` shape (NOT the package's
|
|
20
|
+
// `TypedEventBus`, whose `EventTypeName` union is the package's own events — the
|
|
21
|
+
// consumer's `<entity>_*` events live in the CONSUMER registry), so only the
|
|
22
|
+
// token needs forwarding. The CONSUMER binds their generated `TypedEventBus` to
|
|
23
|
+
// the token via `EventsModule.forRoot()`. Vendored mode reaches it via
|
|
24
|
+
// `@shared/subsystems/events`.
|
|
25
|
+
export { TYPED_EVENT_BUS } from './events';
|
|
15
26
|
|
|
16
27
|
// Jobs — orchestration schema only (JOB-1). Protocols / modules land in JOB-2 / JOB-5.
|
|
17
28
|
export { jobs, jobRuns, jobSteps } from './jobs';
|
|
@@ -131,6 +142,18 @@ export {
|
|
|
131
142
|
} from './integration';
|
|
132
143
|
export type { IIntegrationSink } from './integration';
|
|
133
144
|
|
|
145
|
+
// Integration — EMIT-CHANGES seam. The generated per-entity change-emitter
|
|
146
|
+
// (`<entity>.change-emitter.ts`) imports the `INTEGRATION_CHANGE_EMITTER` token
|
|
147
|
+
// + the `IIntegrationChangeEmitter` port + `IntegrationChangeNotification` from
|
|
148
|
+
// `@pattern-stack/codegen/subsystems`. Forwarded here so the emitted opt-in
|
|
149
|
+
// emitter resolves them across the package boundary (package mode).
|
|
150
|
+
export { INTEGRATION_CHANGE_EMITTER } from './integration';
|
|
151
|
+
export type {
|
|
152
|
+
IIntegrationChangeEmitter,
|
|
153
|
+
IntegrationChangeAction,
|
|
154
|
+
IntegrationChangeNotification,
|
|
155
|
+
} from './integration';
|
|
156
|
+
|
|
134
157
|
// Auth
|
|
135
158
|
export {
|
|
136
159
|
ENCRYPTION_KEY,
|
|
@@ -50,8 +50,13 @@ import type { ICursorStore } from './integration-cursor-store.protocol';
|
|
|
50
50
|
import type { IFieldDiffer, FieldDiff } from './integration-field-diff.protocol';
|
|
51
51
|
import type { IIntegrationSink } from './integration-sink.protocol';
|
|
52
52
|
import type { IIntegrationRunRecorder } from './integration-run-recorder.protocol';
|
|
53
|
+
import type {
|
|
54
|
+
IIntegrationChangeEmitter,
|
|
55
|
+
IntegrationChangeAction,
|
|
56
|
+
} from './integration-change-emitter.protocol';
|
|
53
57
|
import { assertTenantId } from './integration-errors';
|
|
54
58
|
import {
|
|
59
|
+
INTEGRATION_CHANGE_EMITTER,
|
|
55
60
|
INTEGRATION_CHANGE_SOURCE,
|
|
56
61
|
INTEGRATION_CURSOR_STORE,
|
|
57
62
|
INTEGRATION_FIELD_DIFFER,
|
|
@@ -118,6 +123,13 @@ export class ExecuteIntegrationUseCase<T extends Record<string, unknown>> {
|
|
|
118
123
|
@Optional()
|
|
119
124
|
@Inject(INTEGRATION_MULTI_TENANT)
|
|
120
125
|
private readonly multiTenant: boolean = false,
|
|
126
|
+
// EMIT-CHANGES seam — optional post-upsert domain-event emitter. Bound only
|
|
127
|
+
// by codegen-emitted assemblies whose entity opts into
|
|
128
|
+
// `integration.sink.emit_changes: true`. Unbound (the default) ⇒ no events
|
|
129
|
+
// published, zero behavior change.
|
|
130
|
+
@Optional()
|
|
131
|
+
@Inject(INTEGRATION_CHANGE_EMITTER)
|
|
132
|
+
private readonly emitter: IIntegrationChangeEmitter | null = null,
|
|
121
133
|
) {}
|
|
122
134
|
|
|
123
135
|
async execute(input: ExecuteIntegrationInput<T>): Promise<ExecuteIntegrationResult> {
|
|
@@ -263,6 +275,12 @@ export class ExecuteIntegrationUseCase<T extends Record<string, unknown>> {
|
|
|
263
275
|
changedFields: {},
|
|
264
276
|
tenantId: input.tenantId,
|
|
265
277
|
});
|
|
278
|
+
// EMIT-CHANGES: a real tombstone (a local row existed and was soft-deleted)
|
|
279
|
+
// is a `<entity>_deleted` event. A delete that hit no local row is a noop —
|
|
280
|
+
// nothing changed, nothing to emit.
|
|
281
|
+
if (result) {
|
|
282
|
+
await this.emitChange(input, change.externalId, result.id, 'deleted');
|
|
283
|
+
}
|
|
266
284
|
return;
|
|
267
285
|
}
|
|
268
286
|
|
|
@@ -321,15 +339,65 @@ export class ExecuteIntegrationUseCase<T extends Record<string, unknown>> {
|
|
|
321
339
|
input.provider,
|
|
322
340
|
);
|
|
323
341
|
|
|
342
|
+
const action: IntegrationChangeAction =
|
|
343
|
+
existing === null ? 'created' : 'updated';
|
|
344
|
+
|
|
324
345
|
await this.recorder.recordItem({
|
|
325
346
|
integrationRunId: runId,
|
|
326
347
|
entityType: input.subscription.domain,
|
|
327
348
|
externalId: change.externalId,
|
|
328
349
|
localId,
|
|
329
|
-
operation:
|
|
350
|
+
operation: action,
|
|
330
351
|
status: 'success',
|
|
331
352
|
changedFields: diff as FieldDiff,
|
|
332
353
|
tenantId: input.tenantId,
|
|
333
354
|
});
|
|
355
|
+
|
|
356
|
+
// EMIT-CHANGES: a real create/update (the diff was NOT noop) publishes a
|
|
357
|
+
// typed `<entity>_created` / `<entity>_edited` event. The noop-reproject path
|
|
358
|
+
// above intentionally does NOT emit — the canonical state is unchanged.
|
|
359
|
+
await this.emitChange(
|
|
360
|
+
input,
|
|
361
|
+
change.externalId,
|
|
362
|
+
localId,
|
|
363
|
+
action,
|
|
364
|
+
diff as FieldDiff,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Publish one typed data-level change event via the optional emitter
|
|
370
|
+
* (EMIT-CHANGES seam). No-op when no emitter is bound (the back-compat
|
|
371
|
+
* default). A failed publish is logged but never aborts the run — the row is
|
|
372
|
+
* already written; emission is best-effort (the outbox tx, when the generated
|
|
373
|
+
* adapter rides on one, gives the at-least-once guarantee, not this try/catch).
|
|
374
|
+
*/
|
|
375
|
+
private async emitChange(
|
|
376
|
+
input: ExecuteIntegrationInput<T>,
|
|
377
|
+
externalId: string,
|
|
378
|
+
entityId: string,
|
|
379
|
+
action: IntegrationChangeAction,
|
|
380
|
+
changedFields?: FieldDiff,
|
|
381
|
+
): Promise<void> {
|
|
382
|
+
if (this.emitter === null) return;
|
|
383
|
+
try {
|
|
384
|
+
await this.emitter.emitChange({
|
|
385
|
+
entityId,
|
|
386
|
+
externalId,
|
|
387
|
+
provider: input.provider,
|
|
388
|
+
action,
|
|
389
|
+
changedFields:
|
|
390
|
+
action === 'deleted'
|
|
391
|
+
? undefined
|
|
392
|
+
: (changedFields as Record<string, unknown> | undefined),
|
|
393
|
+
tenantId: input.tenantId,
|
|
394
|
+
});
|
|
395
|
+
} catch (err) {
|
|
396
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
397
|
+
this.logger.warn(
|
|
398
|
+
`integration change-emit failed: subscription=${input.subscription.id} ` +
|
|
399
|
+
`externalId=${externalId} action=${action}: ${message}`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
334
402
|
}
|
|
335
403
|
}
|
|
@@ -35,6 +35,11 @@ export {
|
|
|
35
35
|
FieldDiffValueSchema,
|
|
36
36
|
} from './integration-field-diff.protocol';
|
|
37
37
|
export type { IIntegrationSink } from './integration-sink.protocol';
|
|
38
|
+
export type {
|
|
39
|
+
IIntegrationChangeEmitter,
|
|
40
|
+
IntegrationChangeAction,
|
|
41
|
+
IntegrationChangeNotification,
|
|
42
|
+
} from './integration-change-emitter.protocol';
|
|
38
43
|
export type {
|
|
39
44
|
CompleteRunInput,
|
|
40
45
|
IIntegrationRunRecorder,
|
|
@@ -125,6 +130,7 @@ export { buildChangeSource } from './build-change-source';
|
|
|
125
130
|
// Tokens
|
|
126
131
|
export {
|
|
127
132
|
ENTITY_CHANGE_SOURCE_REGISTRY,
|
|
133
|
+
INTEGRATION_CHANGE_EMITTER,
|
|
128
134
|
INTEGRATION_CHANGE_SOURCE,
|
|
129
135
|
INTEGRATION_CURSOR_STORE,
|
|
130
136
|
INTEGRATION_FIELD_DIFFER,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration subsystem — change-emitter protocol (port).
|
|
3
|
+
*
|
|
4
|
+
* `IIntegrationChangeEmitter` is the OPT-IN seam through which the generic
|
|
5
|
+
* orchestrator (`ExecuteIntegrationUseCase`) publishes a typed, data-level
|
|
6
|
+
* domain event after every sink write/soft-delete. It is the upstream-generalized
|
|
7
|
+
* form of the per-sink event emission swe-brain hand-built (ADR-0009 Amendment B):
|
|
8
|
+
* the differ already records `changed_fields` on `integration_run_items`, but
|
|
9
|
+
* nothing publishes a "this entity changed" domain event for downstream
|
|
10
|
+
* trigger→action consumers. This port closes that gap.
|
|
11
|
+
*
|
|
12
|
+
* ## Why a port (not a direct TypedEventBus call in the orchestrator)
|
|
13
|
+
*
|
|
14
|
+
* The orchestrator is strictly provider- AND entity-agnostic: `entityType` is a
|
|
15
|
+
* bare `string` and the canonical record is generic `T` (see the use-case header
|
|
16
|
+
* "No CRM bleed"). It therefore cannot know the typed event NAME
|
|
17
|
+
* (`<entity>_created`) or the typed payload shape at compile time. The typed
|
|
18
|
+
* knowledge lives at the per-entity assembly wiring (codegen knows the entity
|
|
19
|
+
* name there). So the orchestrator depends on this thin, untyped port; codegen
|
|
20
|
+
* binds a per-entity adapter that maps `(operation) → <entity>_<verb>` and calls
|
|
21
|
+
* the project's generated `TypedEventBus.publish(...)` with the typed payload.
|
|
22
|
+
*
|
|
23
|
+
* ## Backwards compatibility
|
|
24
|
+
*
|
|
25
|
+
* The port is `@Optional()` on the orchestrator. Entities that do NOT opt in
|
|
26
|
+
* (`integration.sink.emit_changes` absent/false) bind no emitter, so the
|
|
27
|
+
* orchestrator's `this.emitter` is `undefined` and NOTHING is published — zero
|
|
28
|
+
* behavior change. This is the invariant the snapshot fixture (which opts none
|
|
29
|
+
* in) keeps green.
|
|
30
|
+
*
|
|
31
|
+
* ## Provenance — loop-breaking
|
|
32
|
+
*
|
|
33
|
+
* Every emitted event carries `source: 'integration'` in its payload. A future
|
|
34
|
+
* write-back action (the Intervention layer in swe-brain terms) that subscribes
|
|
35
|
+
* to these events can detect `source === 'integration'` and decline to echo the
|
|
36
|
+
* change back to the vendor, breaking the inbound→writeback→inbound loop. This
|
|
37
|
+
* is the data-layer counterpart of the loopback middleware that already guards
|
|
38
|
+
* the read side (`createLoopbackMiddleware`).
|
|
39
|
+
*
|
|
40
|
+
* ## Transactionality
|
|
41
|
+
*
|
|
42
|
+
* `emitChange` receives the same `tx` the sink wrote under (when the sink exposes
|
|
43
|
+
* one — today the sink owns its own transaction internally, so `tx` is reserved
|
|
44
|
+
* for the future where the orchestrator drives the transaction). The generated
|
|
45
|
+
* adapter forwards `tx` into `TypedEventBus.publish(type, id, payload, { tx })`,
|
|
46
|
+
* so the event lands in the outbox iff the row commits (the events subsystem's
|
|
47
|
+
* outbox guarantee). When `tx` is absent the publish is post-commit best-effort,
|
|
48
|
+
* matching today's sink-owns-its-own-transaction reality.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/** The data-level action the orchestrator observed. Maps onto the generated
|
|
52
|
+
* event verb: `created → <entity>_created`, `updated → <entity>_edited`
|
|
53
|
+
* (per swe-brain ADR-0009 B1 — `_edited`, never `_updated`),
|
|
54
|
+
* `deleted → <entity>_deleted` (tombstone soft-delete). `noop` never emits. */
|
|
55
|
+
export type IntegrationChangeAction = 'created' | 'updated' | 'deleted';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The vendor-blind change descriptor the orchestrator hands the emitter. The
|
|
59
|
+
* generated adapter reshapes this into the typed `<entity>_<verb>` payload
|
|
60
|
+
* (`{ entityId, externalId, provider, changedFields?, source: 'integration' }`).
|
|
61
|
+
*/
|
|
62
|
+
export interface IntegrationChangeNotification {
|
|
63
|
+
/** The local row id the sink wrote/soft-deleted (the domain aggregate id —
|
|
64
|
+
* becomes `aggregateId` on the published event AND `entityId` in the payload). */
|
|
65
|
+
readonly entityId: string;
|
|
66
|
+
/** Vendor-prefixed-or-bare external id the change keyed on (e.g. `slack:123`). */
|
|
67
|
+
readonly externalId: string;
|
|
68
|
+
/** Provider label from `ExecuteIntegrationInput.provider` (e.g. `'slack'`). */
|
|
69
|
+
readonly provider: string;
|
|
70
|
+
/** The observed action. `created`/`updated` come from the existing-row check;
|
|
71
|
+
* `deleted` from the soft-delete path. */
|
|
72
|
+
readonly action: IntegrationChangeAction;
|
|
73
|
+
/** The differ's structured per-field before/after map (the same value written
|
|
74
|
+
* to `integration_run_items.changed_fields`). Absent on deletes. */
|
|
75
|
+
readonly changedFields?: Record<string, unknown>;
|
|
76
|
+
/** Multi-tenant deployments thread the tenant id through to the event metadata. */
|
|
77
|
+
readonly tenantId?: string | null;
|
|
78
|
+
/** The transaction the sink wrote under, when the orchestrator drives one.
|
|
79
|
+
* Forwarded to `TypedEventBus.publish(..., { tx })` for the outbox guarantee.
|
|
80
|
+
* Reserved: today the sink owns its own transaction, so this is usually
|
|
81
|
+
* `undefined` and the publish is post-commit. Typed `unknown` here so the
|
|
82
|
+
* port stays free of a Drizzle type dependency (the generated adapter narrows). */
|
|
83
|
+
readonly tx?: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Post-upsert change-event emission port.
|
|
88
|
+
*
|
|
89
|
+
* One implementation per opted-in (entity, provider) assembly — codegen-emitted,
|
|
90
|
+
* bound to `INTEGRATION_CHANGE_EMITTER` in that assembly module. The orchestrator
|
|
91
|
+
* injects it `@Optional()`; an unbound token means no emission (back-compat).
|
|
92
|
+
*/
|
|
93
|
+
export interface IIntegrationChangeEmitter {
|
|
94
|
+
/**
|
|
95
|
+
* Publish the typed `<entity>_<verb>` domain event for one observed change.
|
|
96
|
+
*
|
|
97
|
+
* MUST be called only for real changes — the orchestrator never calls this on
|
|
98
|
+
* a `noop` diff (canonical state unchanged) or a delete that hit no local row
|
|
99
|
+
* (no tombstone created). Implementations should treat a call as "this thing
|
|
100
|
+
* happened" and publish unconditionally.
|
|
101
|
+
*
|
|
102
|
+
* Errors are the orchestrator's concern: it wraps the call so a failed publish
|
|
103
|
+
* does not abort the run (the row is already written; emission is best-effort
|
|
104
|
+
* unless ridden on the outbox tx). See `ExecuteIntegrationUseCase.processChange`.
|
|
105
|
+
*/
|
|
106
|
+
emitChange(notification: IntegrationChangeNotification): Promise<void>;
|
|
107
|
+
}
|
|
@@ -25,6 +25,17 @@ export const INTEGRATION_CURSOR_STORE = 'INTEGRATION_CURSOR_STORE' as const;
|
|
|
25
25
|
export const INTEGRATION_FIELD_DIFFER = 'INTEGRATION_FIELD_DIFFER' as const;
|
|
26
26
|
export const INTEGRATION_SINK = 'INTEGRATION_SINK' as const;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Optional post-upsert change-event emitter token (EMIT-CHANGES seam).
|
|
30
|
+
*
|
|
31
|
+
* Backed by `IIntegrationChangeEmitter`. Bound ONLY by codegen-emitted assembly
|
|
32
|
+
* modules whose entity opts in via `integration.sink.emit_changes: true`. The
|
|
33
|
+
* orchestrator injects it `@Optional()` — an unbound token means no domain
|
|
34
|
+
* events are published (the back-compat default for non-opted-in entities).
|
|
35
|
+
* See `integration-change-emitter.protocol.ts`.
|
|
36
|
+
*/
|
|
37
|
+
export const INTEGRATION_CHANGE_EMITTER = 'INTEGRATION_CHANGE_EMITTER' as const;
|
|
38
|
+
|
|
28
39
|
/**
|
|
29
40
|
* Run-recorder token (SYNC-5). Backed by `IIntegrationRunRecorder`. Drizzle impl
|
|
30
41
|
* lands in SYNC-4; tests provide inline fakes.
|
|
@@ -261,6 +261,11 @@ export class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {
|
|
|
261
261
|
this.options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,
|
|
262
262
|
pollIntervalMs: drizzleExt?.pollIntervalMs,
|
|
263
263
|
listenNotify: drizzleExt?.listenNotify,
|
|
264
|
+
// CLAIM-HB-1 — lease tuning knobs. All optional; the worker defaults
|
|
265
|
+
// claimHeartbeatIntervalMs to staleThresholdMs/3 when omitted.
|
|
266
|
+
staleSweeperIntervalMs: drizzleExt?.staleSweeperIntervalMs,
|
|
267
|
+
staleThresholdMs: drizzleExt?.staleThresholdMs,
|
|
268
|
+
claimHeartbeatIntervalMs: drizzleExt?.claimHeartbeatIntervalMs,
|
|
264
269
|
};
|
|
265
270
|
const worker = this.options.workerFactory
|
|
266
271
|
? this.options.workerFactory(workerOptions)
|
|
@@ -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 {
|