@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/consumer-skills/integration/SKILL.md +11 -3
  3. package/dist/{chunk-XKWOJZZ4.js → chunk-37PILMIT.js} +4 -4
  4. package/dist/{chunk-NR7QQ6ZI.js → chunk-6M6LZEP6.js} +3 -3
  5. package/dist/{chunk-VDL5CJ5C.js → chunk-7B7MMDOJ.js} +54 -1
  6. package/dist/chunk-7B7MMDOJ.js.map +1 -0
  7. package/dist/{chunk-NXHL5YII.js → chunk-7LKAMLV4.js} +4 -4
  8. package/dist/{chunk-6DQEIXYU.js → chunk-CKLM57IE.js} +10 -10
  9. package/dist/chunk-CKLM57IE.js.map +1 -0
  10. package/dist/{chunk-QXVCRA23.js → chunk-ENAR3F5S.js} +9 -4
  11. package/dist/chunk-ENAR3F5S.js.map +1 -0
  12. package/dist/{chunk-FFUDEIFF.js → chunk-HN5HT5WL.js} +2 -2
  13. package/dist/{chunk-6ECCJVYW.js → chunk-K4BQQ2NN.js} +46 -2
  14. package/dist/chunk-K4BQQ2NN.js.map +1 -0
  15. package/dist/{chunk-QFUIE37H.js → chunk-KFXXOFDC.js} +4 -4
  16. package/dist/{chunk-O2A6XHGD.js → chunk-LLDJS7PJ.js} +2 -2
  17. package/dist/{chunk-JOBQ6RUU.js → chunk-LQZESSM3.js} +28 -1
  18. package/dist/chunk-LQZESSM3.js.map +1 -0
  19. package/dist/{chunk-JRQO2IOF.js → chunk-MU54DZCC.js} +27 -1
  20. package/dist/chunk-MU54DZCC.js.map +1 -0
  21. package/dist/{chunk-INO47JXD.js → chunk-PBENHIN2.js} +3 -3
  22. package/dist/{chunk-CLWBNXKF.js → chunk-PLUJEQLU.js} +2 -2
  23. package/dist/{chunk-DB5UXJC3.js → chunk-PNCOUFFI.js} +4 -2
  24. package/dist/chunk-PNCOUFFI.js.map +1 -0
  25. package/dist/{chunk-S7C6TIIF.js → chunk-S5G3HO7N.js} +3 -1
  26. package/dist/chunk-S5G3HO7N.js.map +1 -0
  27. package/dist/{chunk-FNHNSFIJ.js → chunk-WZOPWQN2.js} +2 -2
  28. package/dist/{chunk-TDEHU73T.js → chunk-YIVQ7KLS.js} +46 -5
  29. package/dist/chunk-YIVQ7KLS.js.map +1 -0
  30. package/dist/runtime/subsystems/auth/auth.module.js +2 -2
  31. package/dist/runtime/subsystems/auth/index.js +4 -4
  32. package/dist/runtime/subsystems/bridge/bridge.module.js +7 -7
  33. package/dist/runtime/subsystems/bridge/index.js +7 -7
  34. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +1 -1
  35. package/dist/runtime/subsystems/events/events.module.js +5 -5
  36. package/dist/runtime/subsystems/events/generated/bus.js +3 -3
  37. package/dist/runtime/subsystems/events/generated/index.d.ts +2 -2
  38. package/dist/runtime/subsystems/events/generated/index.js +9 -3
  39. package/dist/runtime/subsystems/events/generated/registry.d.ts +36 -0
  40. package/dist/runtime/subsystems/events/generated/registry.js +1 -1
  41. package/dist/runtime/subsystems/events/generated/schemas.d.ts +109 -1
  42. package/dist/runtime/subsystems/events/generated/schemas.js +7 -1
  43. package/dist/runtime/subsystems/events/generated/types.d.ts +48 -2
  44. package/dist/runtime/subsystems/events/index.js +5 -5
  45. package/dist/runtime/subsystems/index.d.ts +3 -2
  46. package/dist/runtime/subsystems/index.js +29 -25
  47. package/dist/runtime/subsystems/integration/execute-integration.use-case.d.ts +11 -1
  48. package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
  49. package/dist/runtime/subsystems/integration/index.d.ts +2 -1
  50. package/dist/runtime/subsystems/integration/index.js +10 -8
  51. package/dist/runtime/subsystems/integration/integration-change-emitter.protocol.d.ts +106 -0
  52. package/dist/runtime/subsystems/integration/integration-change-emitter.protocol.js +1 -0
  53. package/dist/runtime/subsystems/integration/integration-change-emitter.protocol.js.map +1 -0
  54. package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js +2 -2
  55. package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js +2 -2
  56. package/dist/runtime/subsystems/integration/integration.module.js +4 -4
  57. package/dist/runtime/subsystems/integration/integration.tokens.d.ts +11 -1
  58. package/dist/runtime/subsystems/integration/integration.tokens.js +3 -1
  59. package/dist/runtime/subsystems/jobs/index.js +12 -12
  60. package/dist/runtime/subsystems/jobs/job-worker.d.ts +592 -4
  61. package/dist/runtime/subsystems/jobs/job-worker.js +3 -1
  62. package/dist/runtime/subsystems/jobs/job-worker.module.js +6 -6
  63. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +19 -0
  64. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +4 -4
  65. package/dist/runtime/subsystems/observability/index.js +3 -3
  66. package/dist/runtime/subsystems/observability/observability.module.js +3 -3
  67. package/dist/runtime/subsystems/observability/observability.service.js +2 -2
  68. package/dist/src/cli/index.js +413 -85
  69. package/dist/src/cli/index.js.map +1 -1
  70. package/dist/src/index.d.ts +490 -1
  71. package/dist/src/index.js +7 -7
  72. package/package.json +1 -1
  73. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +23 -7
  74. package/runtime/subsystems/events/generated/registry.ts +27 -0
  75. package/runtime/subsystems/events/generated/schemas.ts +26 -0
  76. package/runtime/subsystems/events/generated/types.ts +52 -0
  77. package/runtime/subsystems/index.ts +23 -0
  78. package/runtime/subsystems/integration/execute-integration.use-case.ts +69 -1
  79. package/runtime/subsystems/integration/index.ts +6 -0
  80. package/runtime/subsystems/integration/integration-change-emitter.protocol.ts +107 -0
  81. package/runtime/subsystems/integration/integration.tokens.ts +11 -0
  82. package/runtime/subsystems/jobs/job-worker.module.ts +5 -0
  83. package/runtime/subsystems/jobs/job-worker.ts +126 -12
  84. package/runtime/subsystems/jobs/jobs-domain.module.ts +19 -0
  85. package/templates/entity/new/clean-lite-ps/prompt-extension.js +59 -10
  86. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +11 -0
  87. package/dist/chunk-6DQEIXYU.js.map +0 -1
  88. package/dist/chunk-6ECCJVYW.js.map +0 -1
  89. package/dist/chunk-DB5UXJC3.js.map +0 -1
  90. package/dist/chunk-JOBQ6RUU.js.map +0 -1
  91. package/dist/chunk-JRQO2IOF.js.map +0 -1
  92. package/dist/chunk-QXVCRA23.js.map +0 -1
  93. package/dist/chunk-S7C6TIIF.js.map +0 -1
  94. package/dist/chunk-TDEHU73T.js.map +0 -1
  95. package/dist/chunk-VDL5CJ5C.js.map +0 -1
  96. /package/dist/{chunk-XKWOJZZ4.js.map → chunk-37PILMIT.js.map} +0 -0
  97. /package/dist/{chunk-NR7QQ6ZI.js.map → chunk-6M6LZEP6.js.map} +0 -0
  98. /package/dist/{chunk-NXHL5YII.js.map → chunk-7LKAMLV4.js.map} +0 -0
  99. /package/dist/{chunk-FFUDEIFF.js.map → chunk-HN5HT5WL.js.map} +0 -0
  100. /package/dist/{chunk-QFUIE37H.js.map → chunk-KFXXOFDC.js.map} +0 -0
  101. /package/dist/{chunk-O2A6XHGD.js.map → chunk-LLDJS7PJ.js.map} +0 -0
  102. /package/dist/{chunk-INO47JXD.js.map → chunk-PBENHIN2.js.map} +0 -0
  103. /package/dist/{chunk-CLWBNXKF.js.map → chunk-PLUJEQLU.js.map} +0 -0
  104. /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: existing === null ? 'created' : 'updated',
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 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 {