@pattern-stack/codegen 0.17.1 → 0.18.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 (111) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +106 -2
  3. package/dist/{chunk-SFQRETXJ.js → chunk-2VGVSL2D.js} +6 -6
  4. package/dist/{chunk-VNBC3VXM.js → chunk-3A34R6CI.js} +7 -7
  5. package/dist/{chunk-FVNAU7VO.js → chunk-7MMS36AN.js} +6 -6
  6. package/dist/{chunk-FWRL7BZ5.js → chunk-C5E7H553.js} +25 -15
  7. package/dist/chunk-C5E7H553.js.map +1 -0
  8. package/dist/{chunk-IOQMMH6C.js → chunk-CFFTPWHM.js} +79 -4
  9. package/dist/chunk-CFFTPWHM.js.map +1 -0
  10. package/dist/{chunk-HOIRY5XP.js → chunk-EWYI5GGJ.js} +10 -10
  11. package/dist/{chunk-BHZP6LOV.js → chunk-IN3EWFB4.js} +4 -4
  12. package/dist/{chunk-CZQUOIDY.js → chunk-J7JMVS2B.js} +4 -4
  13. package/dist/{chunk-KSTZIULO.js → chunk-K2I6XIK5.js} +4 -4
  14. package/dist/{chunk-T6SCOJF4.js → chunk-NXHL5YII.js} +4 -4
  15. package/dist/{chunk-JA7GJDNI.js → chunk-PKDS6QIJ.js} +4 -4
  16. package/dist/{chunk-MYQIQ27N.js → chunk-Q6LRJ4VI.js} +51 -2
  17. package/dist/chunk-Q6LRJ4VI.js.map +1 -0
  18. package/dist/{chunk-EJBK7I4F.js → chunk-R4BPUUB5.js} +3 -3
  19. package/dist/{chunk-4PFF3ED4.js → chunk-RKNW56RU.js} +5 -5
  20. package/dist/{chunk-SGSWVNNB.js → chunk-TBGTMALE.js} +4 -4
  21. package/dist/{chunk-GM3RMJIJ.js → chunk-VHAR2BGH.js} +4 -4
  22. package/dist/{chunk-DUMI2J5M.js → chunk-VQOAATIG.js} +4 -4
  23. package/dist/{chunk-HPS554L4.js → chunk-X6BP6LI5.js} +6 -6
  24. package/dist/{chunk-PSDVGPQR.js → chunk-YZLBU6O2.js} +9 -9
  25. package/dist/runtime/shared/openapi/index.js +3 -3
  26. package/dist/runtime/subsystems/analytics/analytics.module.js +2 -2
  27. package/dist/runtime/subsystems/analytics/index.js +4 -4
  28. package/dist/runtime/subsystems/auth/auth.module.js +3 -3
  29. package/dist/runtime/subsystems/auth/index.js +10 -10
  30. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +2 -2
  31. package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +2 -2
  32. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +6 -6
  33. package/dist/runtime/subsystems/bridge/bridge.module.js +17 -17
  34. package/dist/runtime/subsystems/bridge/index.js +24 -24
  35. package/dist/runtime/subsystems/cache/cache.module.js +1 -1
  36. package/dist/runtime/subsystems/cache/index.js +3 -3
  37. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +3 -3
  38. package/dist/runtime/subsystems/events/events.module.js +6 -6
  39. package/dist/runtime/subsystems/events/generated/bus.js +2 -2
  40. package/dist/runtime/subsystems/events/generated/index.js +2 -2
  41. package/dist/runtime/subsystems/events/index.js +10 -10
  42. package/dist/runtime/subsystems/index.js +64 -64
  43. package/dist/runtime/subsystems/integration/index.js +10 -10
  44. package/dist/runtime/subsystems/integration/integration.module.js +2 -2
  45. package/dist/runtime/subsystems/jobs/index.js +21 -21
  46. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +5 -5
  47. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +3 -3
  48. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +2 -2
  49. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +3 -3
  50. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +2 -2
  51. package/dist/runtime/subsystems/jobs/job-worker.d.ts +8 -0
  52. package/dist/runtime/subsystems/jobs/job-worker.js +3 -3
  53. package/dist/runtime/subsystems/jobs/job-worker.module.js +11 -11
  54. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -9
  55. package/dist/runtime/subsystems/jobs/pg-notify.d.ts +25 -1
  56. package/dist/runtime/subsystems/jobs/pg-notify.js +1 -1
  57. package/dist/src/cli/index.js +1408 -245
  58. package/dist/src/cli/index.js.map +1 -1
  59. package/dist/src/index.d.ts +18 -0
  60. package/dist/src/index.js +5 -5
  61. package/package.json +1 -1
  62. package/runtime/subsystems/jobs/job-worker.ts +29 -11
  63. package/runtime/subsystems/jobs/pg-notify.ts +63 -3
  64. package/src/config/locations.mjs +0 -6
  65. package/src/config/paths.mjs +0 -13
  66. package/templates/entity/new/prompt.js +12 -88
  67. package/dist/chunk-FWRL7BZ5.js.map +0 -1
  68. package/dist/chunk-IOQMMH6C.js.map +0 -1
  69. package/dist/chunk-MYQIQ27N.js.map +0 -1
  70. package/templates/entity/new/frontend/_inject-entities-entry.ejs.t +0 -7
  71. package/templates/entity/new/frontend/_inject-entities-import.ejs.t +0 -7
  72. package/templates/entity/new/frontend/collections/_ensure-anchor-collections.ejs.t +0 -10
  73. package/templates/entity/new/frontend/collections/_inject-index.ejs.t +0 -9
  74. package/templates/entity/new/frontend/collections/_inject-schema-import.ejs.t +0 -9
  75. package/templates/entity/new/frontend/collections/collection.ejs.t +0 -86
  76. package/templates/entity/new/frontend/collections/collections-base.ejs.t +0 -35
  77. package/templates/entity/new/frontend/entity/collection.ejs.t +0 -173
  78. package/templates/entity/new/frontend/entity/combined.ejs.t +0 -505
  79. package/templates/entity/new/frontend/entity/fields.ejs.t +0 -105
  80. package/templates/entity/new/frontend/entity/hooks.ejs.t +0 -74
  81. package/templates/entity/new/frontend/entity/index.ejs.t +0 -22
  82. package/templates/entity/new/frontend/entity/mutation-hooks.ejs.t +0 -85
  83. package/templates/entity/new/frontend/entity/mutations.ejs.t +0 -39
  84. package/templates/entity/new/frontend/entity/types.ejs.t +0 -60
  85. package/templates/entity/new/frontend/generated/_inject-index-export.ejs.t +0 -7
  86. package/templates/entity/new/frontend/generated/_inject-index-import.ejs.t +0 -7
  87. package/templates/entity/new/frontend/generated/_inject-index-registry.ejs.t +0 -7
  88. package/templates/entity/new/frontend/store/_inject-collection-import.ejs.t +0 -9
  89. package/templates/entity/new/frontend/store/_inject-collections.ejs.t +0 -9
  90. package/templates/entity/new/frontend/store/_inject-entity.ejs.t +0 -9
  91. package/templates/entity/new/frontend/store/_inject-import.ejs.t +0 -9
  92. package/templates/entity/new/frontend/store/_inject-lookups.ejs.t +0 -9
  93. package/templates/entity/new/frontend/store/_inject-resolve.ejs.t +0 -10
  94. package/templates/entity/new/frontend/store/hooks.ejs.t +0 -73
  95. package/templates/entity/new/frontend/unified-entity.ejs.t +0 -29
  96. /package/dist/{chunk-SFQRETXJ.js.map → chunk-2VGVSL2D.js.map} +0 -0
  97. /package/dist/{chunk-VNBC3VXM.js.map → chunk-3A34R6CI.js.map} +0 -0
  98. /package/dist/{chunk-FVNAU7VO.js.map → chunk-7MMS36AN.js.map} +0 -0
  99. /package/dist/{chunk-HOIRY5XP.js.map → chunk-EWYI5GGJ.js.map} +0 -0
  100. /package/dist/{chunk-BHZP6LOV.js.map → chunk-IN3EWFB4.js.map} +0 -0
  101. /package/dist/{chunk-CZQUOIDY.js.map → chunk-J7JMVS2B.js.map} +0 -0
  102. /package/dist/{chunk-KSTZIULO.js.map → chunk-K2I6XIK5.js.map} +0 -0
  103. /package/dist/{chunk-T6SCOJF4.js.map → chunk-NXHL5YII.js.map} +0 -0
  104. /package/dist/{chunk-JA7GJDNI.js.map → chunk-PKDS6QIJ.js.map} +0 -0
  105. /package/dist/{chunk-EJBK7I4F.js.map → chunk-R4BPUUB5.js.map} +0 -0
  106. /package/dist/{chunk-4PFF3ED4.js.map → chunk-RKNW56RU.js.map} +0 -0
  107. /package/dist/{chunk-SGSWVNNB.js.map → chunk-TBGTMALE.js.map} +0 -0
  108. /package/dist/{chunk-GM3RMJIJ.js.map → chunk-VHAR2BGH.js.map} +0 -0
  109. /package/dist/{chunk-DUMI2J5M.js.map → chunk-VQOAATIG.js.map} +0 -0
  110. /package/dist/{chunk-HPS554L4.js.map → chunk-X6BP6LI5.js.map} +0 -0
  111. /package/dist/{chunk-PSDVGPQR.js.map → chunk-YZLBU6O2.js.map} +0 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.17.2] — 2026-06-04
8
+
9
+ **Shutdown leak fix** (LISTEN-NOTIFY-2; swe-brain dogfood). With
10
+ `listen_notify: true` (the LISTEN/NOTIFY wake extension shipped in 0.16.0), a
11
+ Nest app that booted and then `app.close()`d — e.g. a boot-check / CI smoke step —
12
+ never exited: at least one `LISTEN codegen_jobs_wake` client survived
13
+ `app.close()`, holding an ESTABLISHED Postgres socket open forever (two swe-brain
14
+ CI runs hung for hours). Backward-compatible; affects only consumers that opted
15
+ into `listen_notify`.
16
+
17
+ ### Fixed
18
+
19
+ - **`PgNotifyListener.stop()` is race-safe against an in-flight `connect()`**
20
+ (LISTEN-NOTIFY-2 RC1 — the defect that actually fired). `connect()` checked
21
+ `this.stopped` only at entry, then `await pool.connect()`, wired handlers,
22
+ issued `LISTEN`, and assigned `this.client` last. A `stop()` arriving during
23
+ the checkout await ran `releaseClient()` against a still-null `this.client`
24
+ (released nothing); the resuming `connect()` then assigned the client and
25
+ issued `LISTEN` — leaking a checked-out connection with no owner left to
26
+ release it. With 5–6 listeners (one per jobs pool + the events drainer) all
27
+ starting at bootstrap and a tight `app.close()`, the race fired on ~1 of 6
28
+ listeners — exactly the observed signature (one survivor, the rest clean).
29
+ Now `connect()` re-checks `stopped` after the checkout AND after `LISTEN`,
30
+ destroying the just-acquired client and bailing before assignment; `stop()`
31
+ tracks and awaits the in-flight connect promise before its own release, so
32
+ `app.close()` can't return while a checkout is still mid-flight. Releases use
33
+ `release(true)` (destroy) so a half-listening socket is never reused.
34
+ - **`JobWorker.onModuleDestroy` stops the wake listener on EVERY destroy path**
35
+ (LISTEN-NOTIFY-2 RC2 — latent). The listener `stop()` lived only on the first
36
+ (non-`shuttingDown`) branch, so a SIGTERM-then-Nest double-destroy hit the
37
+ `if (this.shuttingDown) { …; return; }` early return and skipped it, leaking
38
+ the listener under the normal SIGTERM shutdown path. Teardown is now an
39
+ idempotent `stopNotifyListener()` called unconditionally at the top of every
40
+ destroy. `DrizzleEventBus` already stopped its listener unconditionally; it
41
+ shared `PgNotifyListener` and so benefits from the RC1 fix directly.
42
+
7
43
  ## [0.17.1] — 2026-06-04
8
44
 
9
45
  **Two dogfood fixes that bit the same swe-brain mutation drain** (ADR-0009
package/README.md CHANGED
@@ -138,6 +138,104 @@ modules/{plural}/
138
138
  use-cases/ FindById, List, declarative queries
139
139
  ```
140
140
 
141
+ **Frontend** (`generate.frontend: true`) — see [Frontend generation](#frontend-generation) below.
142
+
143
+ ## Frontend generation
144
+
145
+ Gated entirely by `generate.frontend` (default `false`; the scanner sets it
146
+ `true` when it finds an `apps/frontend/` directory). When on, the `entity new`
147
+ post-step (and therefore `gen-all`) renders the **complete frontend tree from the
148
+ full entity set in one pass** — a TypeScript emitter (`src/emitters/frontend/`),
149
+ not hygen templates. Re-running is idempotent: every file is a complete-file
150
+ write with a `@generated` banner, no inject/anchor machinery, no overwrite
151
+ prompts (ADR-038).
152
+
153
+ Output lands under `locations.frontendGenerated` (default
154
+ `apps/frontend/src/generated/`):
155
+
156
+ ```
157
+ generated/
158
+ index.ts whole-set barrel (+ version-pairing comment)
159
+ config.ts per-entity sync modes + runtime overrides
160
+ query-client.ts shared TanStack QueryClient
161
+ api/<entity>.ts REST client → the generated NestJS controllers
162
+ collections/<entity>.ts createCollection, branched on the entity's sync mode
163
+ entities/<entity>.ts createEntityHooks({ collection, api }) wiring
164
+ store/index.ts createStore over the full set (+ resolvers, lookups)
165
+ fields/<entity>.ts field metadata (FieldMeta, <entity>Fields)
166
+ ```
167
+
168
+ Entity types and Zod schemas are **imported** from `locations.dbEntities`
169
+ (default `@repo/db/entities`), not re-emitted. The emitter imports the plain
170
+ class name — `import type { <Class> } from '<dbEntities>/<name>'` — so
171
+ `dbEntities` is the contract: it MUST export the entity type under its plain
172
+ `<Class>` name (e.g. `Contact`, not `ContactEntity`). If your db package only
173
+ exports `<Class>Entity`, that is the one knob to change in the emitter.
174
+
175
+ The hook/mutation/store/provider logic is consumed from
176
+ `@pattern-stack/frontend-patterns` (`createEntityHooks`, `createStore`) — the
177
+ generated files are thin wiring. **The consumer's frontend installs that package
178
+ plus the paired TanStack libraries.** `project init` adds the version-pairing
179
+ deps to `apps/frontend/package.json` when `generate.frontend: true` (idempotent
180
+ merge — only missing keys added, existing version ranges preserved); when no
181
+ frontend package.json exists it prints the list to install. `@pattern-stack/codegen`
182
+ itself gains no runtime dependency.
183
+
184
+ | Package | Range |
185
+ |---|---|
186
+ | `@pattern-stack/frontend-patterns` | `^0.2.0-alpha.18` |
187
+ | `@tanstack/react-db` | `^0.1.55` |
188
+ | `@tanstack/electric-db-collection` | `^0.2.11` |
189
+ | `@tanstack/query-db-collection` | `^1.0.6` |
190
+ | `@tanstack/react-query` | `^5.0.0` |
191
+
192
+ ### `frontend:` config block
193
+
194
+ All knobs are inert unless `generate.frontend: true`. Defaults shown; the block
195
+ is optional (fully defaulted when absent):
196
+
197
+ ```yaml
198
+ frontend:
199
+ auth:
200
+ function: getAuthorizationHeader # auth-header fn; null DISABLES the header
201
+ parsers: # Electric column-type → parser fn source
202
+ timestamptz: '(date: string) => new Date(date)'
203
+ sync:
204
+ mode: electric # global default sync mode (api | electric)
205
+ shapeUrl: /v1/shape # Electric shape base path
206
+ useTableParam: true # emit `params: { table }` shape-URL form
207
+ columnMapper: snakeCamelMapper # Electric column mapper fn; null to omit
208
+ columnMapperNeedsCall: true # call the mapper (fn()) vs reference (fn)
209
+ apiBaseUrlImport: null # when set, import API_BASE_URL from it as baseURL
210
+ apiUrl: /api # REST base path when no apiBaseUrlImport
211
+ ```
212
+
213
+ `null`-disables convention: an **absent** `auth.function` defaults to
214
+ `getAuthorizationHeader`; an **explicit `null`** disables it entirely (no header
215
+ lines emitted). Likewise `sync.columnMapper: null` omits the Electric mapper.
216
+
217
+ ### Per-entity sync mode (`entity.sync`)
218
+
219
+ Each entity may override the global `frontend.sync.mode` inside its `entity:`
220
+ block (sibling to `surface:`/`context:`):
221
+
222
+ ```yaml
223
+ entity:
224
+ name: contact
225
+ plural: contacts
226
+ table: contacts
227
+ sync: api # api | electric — overrides frontend.sync.mode for this entity
228
+ ```
229
+
230
+ `api` wires `queryCollectionOptions` (REST via TanStack Query); `electric` wires
231
+ `electricCollectionOptions` (real-time shape sync). Absent → the global default.
232
+ `offline` (Electric + Dexie) is deferred — the schema rejects it.
233
+
234
+ Cross-entity FK names (file, plural, class, collection var) are resolved against
235
+ the **target entity's own YAML** via the registry — never re-pluralized from a
236
+ string at emit time (so an explicit `plural:` like `person`→`persons` is honored
237
+ by every consumer).
238
+
141
239
  ## Integration Codegen (provider/adapter + assembly + read primitive)
142
240
 
143
241
  When an entity carries a `surface:` tag and `definitions/providers/*.yaml` exist,
@@ -261,9 +359,14 @@ naming:
261
359
  terminology:
262
360
  command: use-case
263
361
  query: use-case
362
+
363
+ # frontend: # inert unless generate.frontend: true
364
+ # ... # see "Frontend generation" above for the full block
264
365
  ```
265
366
 
266
- Auto-detect your project's conventions with `codegen project scan`.
367
+ The `frontend:` block (auth, parsers, sync) is documented under
368
+ [Frontend generation](#frontend-generation). Auto-detect your project's
369
+ conventions with `codegen project scan`.
267
370
 
268
371
  ## Using in Your Project
269
372
 
@@ -276,6 +379,7 @@ See [docs/GETTING-STARTED.md](docs/GETTING-STARTED.md) for a walkthrough of enti
276
379
  ```
277
380
  src/ Generator source code
278
381
  cli/ Clipanion CLI (noun-verb architecture)
382
+ emitters/ TypeScript emitters (integration, frontend — ADR-038)
279
383
  analyzer/ Graph building, consistency checking
280
384
  parser/ YAML loading, cross-reference resolution
281
385
  scanner/ Project pattern detection
@@ -287,7 +391,7 @@ src/ Generator source code
287
391
  runtime/ Code shipped into consumer projects
288
392
  base-classes/ BaseRepository, BaseService, pattern bases
289
393
  subsystems/ Events, Jobs, Cache, Storage
290
- templates/ Hygen EJS templates
394
+ templates/ Hygen EJS templates (backend pipelines)
291
395
  test/ Baseline snapshots, scaffold integration, smoke test
292
396
  docs/ ADRs, consumer setup, getting started
293
397
  ```
@@ -1,19 +1,19 @@
1
1
  import {
2
2
  BRIDGE_DELIVERY_JOB_TYPE
3
- } from "./chunk-SGSWVNNB.js";
3
+ } from "./chunk-TBGTMALE.js";
4
4
  import {
5
5
  bridgeDelivery
6
6
  } from "./chunk-2TVVBC53.js";
7
- import {
8
- JOBS_LISTEN_NOTIFY
9
- } from "./chunk-ZPL74UQN.js";
10
7
  import {
11
8
  jobRuns
12
9
  } from "./chunk-OKXZ63IA.js";
10
+ import {
11
+ JOBS_LISTEN_NOTIFY
12
+ } from "./chunk-ZPL74UQN.js";
13
13
  import {
14
14
  JOBS_WAKE_CHANNEL,
15
15
  pgNotify
16
- } from "./chunk-MYQIQ27N.js";
16
+ } from "./chunk-Q6LRJ4VI.js";
17
17
  import {
18
18
  BRIDGE_REGISTRY
19
19
  } from "./chunk-4LH67P4U.js";
@@ -151,4 +151,4 @@ BridgeOutboxDrainHook = __decorateClass([
151
151
  export {
152
152
  BridgeOutboxDrainHook
153
153
  };
154
- //# sourceMappingURL=chunk-SFQRETXJ.js.map
154
+ //# sourceMappingURL=chunk-2VGVSL2D.js.map
@@ -1,6 +1,3 @@
1
- import {
2
- MissingTenantIdError
3
- } from "./chunk-T4BIIU5E.js";
4
1
  import {
5
2
  clampLimit,
6
3
  decodeKeysetCursor,
@@ -8,12 +5,15 @@ import {
8
5
  toJobRunSummary
9
6
  } from "./chunk-L3LZWWSX.js";
10
7
  import {
11
- JOBS_MULTI_TENANT,
12
- JOB_ORCHESTRATOR
13
- } from "./chunk-ZPL74UQN.js";
8
+ MissingTenantIdError
9
+ } from "./chunk-T4BIIU5E.js";
14
10
  import {
15
11
  jobRuns
16
12
  } from "./chunk-OKXZ63IA.js";
13
+ import {
14
+ JOBS_MULTI_TENANT,
15
+ JOB_ORCHESTRATOR
16
+ } from "./chunk-ZPL74UQN.js";
17
17
  import {
18
18
  DRIZZLE
19
19
  } from "./chunk-U64T4YZE.js";
@@ -198,4 +198,4 @@ DrizzleJobRunService = __decorateClass([
198
198
  export {
199
199
  DrizzleJobRunService
200
200
  };
201
- //# sourceMappingURL=chunk-VNBC3VXM.js.map
201
+ //# sourceMappingURL=chunk-3A34R6CI.js.map
@@ -5,10 +5,6 @@ import {
5
5
  JobTypeNotFoundError,
6
6
  MissingTenantIdError
7
7
  } from "./chunk-T4BIIU5E.js";
8
- import {
9
- JOBS_LISTEN_NOTIFY,
10
- JOBS_MULTI_TENANT
11
- } from "./chunk-ZPL74UQN.js";
12
8
  import {
13
9
  keySelectorToTemplate,
14
10
  resolveJobKey
@@ -18,10 +14,14 @@ import {
18
14
  jobSteps,
19
15
  jobs
20
16
  } from "./chunk-OKXZ63IA.js";
17
+ import {
18
+ JOBS_LISTEN_NOTIFY,
19
+ JOBS_MULTI_TENANT
20
+ } from "./chunk-ZPL74UQN.js";
21
21
  import {
22
22
  JOBS_WAKE_CHANNEL,
23
23
  pgNotify
24
- } from "./chunk-MYQIQ27N.js";
24
+ } from "./chunk-Q6LRJ4VI.js";
25
25
  import {
26
26
  DRIZZLE
27
27
  } from "./chunk-U64T4YZE.js";
@@ -393,4 +393,4 @@ export {
393
393
  evaluateKeyTemplate,
394
394
  DrizzleJobOrchestrator
395
395
  };
396
- //# sourceMappingURL=chunk-FVNAU7VO.js.map
396
+ //# sourceMappingURL=chunk-7MMS36AN.js.map
@@ -1,18 +1,18 @@
1
- import {
2
- JOB_ORCHESTRATOR,
3
- JOB_RUN_SERVICE,
4
- JOB_STEP_SERVICE
5
- } from "./chunk-ZPL74UQN.js";
6
1
  import {
7
2
  JOB_HANDLER_REGISTRY
8
3
  } from "./chunk-7P5ODGLA.js";
9
4
  import {
10
5
  jobRuns
11
6
  } from "./chunk-OKXZ63IA.js";
7
+ import {
8
+ JOB_ORCHESTRATOR,
9
+ JOB_RUN_SERVICE,
10
+ JOB_STEP_SERVICE
11
+ } from "./chunk-ZPL74UQN.js";
12
12
  import {
13
13
  JOBS_WAKE_CHANNEL,
14
14
  PgNotifyListener
15
- } from "./chunk-MYQIQ27N.js";
15
+ } from "./chunk-Q6LRJ4VI.js";
16
16
  import {
17
17
  tokenKey
18
18
  } from "./chunk-GYGNEQSC.js";
@@ -199,6 +199,7 @@ var JobWorker = class {
199
199
  }
200
200
  }
201
201
  async onModuleDestroy() {
202
+ await this.stopNotifyListener();
202
203
  if (this.shuttingDown) {
203
204
  await this.drainInFlight();
204
205
  return;
@@ -213,14 +214,6 @@ var JobWorker = class {
213
214
  this.sweeperTimer = null;
214
215
  }
215
216
  process.removeListener("SIGTERM", this.sigtermHandler);
216
- if (this.notifyListener) {
217
- try {
218
- await this.notifyListener.stop();
219
- } catch (err) {
220
- this.logger.error(`notify listener stop failed: ${err.message}`);
221
- }
222
- this.notifyListener = null;
223
- }
224
217
  await this.drainInFlight();
225
218
  try {
226
219
  await this.db.update(jobRuns).set({ status: "pending", claimedAt: null, startedAt: null }).where(
@@ -240,6 +233,23 @@ var JobWorker = class {
240
233
  timeout
241
234
  ]);
242
235
  }
236
+ /**
237
+ * LISTEN-NOTIFY-2 — stop + drop the wake listener. Idempotent: a second call
238
+ * (SIGTERM + Nest destroy) finds `notifyListener` already null and no-ops.
239
+ * `PgNotifyListener.stop()` is itself race-safe against an in-flight
240
+ * `connect()`, so even a destroy that arrives microseconds after `start()`
241
+ * releases the listener socket rather than leaking it.
242
+ */
243
+ async stopNotifyListener() {
244
+ const listener = this.notifyListener;
245
+ if (!listener) return;
246
+ this.notifyListener = null;
247
+ try {
248
+ await listener.stop();
249
+ } catch (err) {
250
+ this.logger.error(`notify listener stop failed: ${err.message}`);
251
+ }
252
+ }
243
253
  // ============================================================================
244
254
  // Poll loop
245
255
  // ============================================================================
@@ -518,4 +528,4 @@ export {
518
528
  buildStaleSweepQuery,
519
529
  JobWorker
520
530
  };
521
- //# sourceMappingURL=chunk-FWRL7BZ5.js.map
531
+ //# sourceMappingURL=chunk-C5E7H553.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../runtime/subsystems/jobs/job-worker.ts"],"sourcesContent":["/**\n * JobWorker — backend-agnostic tick loop for the job orchestration domain\n * (ADR-022, JOB-3).\n *\n * One worker instance per active pool. On `onModuleInit` it starts two\n * intervals: the poll loop (claim → process → repeat) and the stale-claim\n * sweeper. On `onModuleDestroy` / SIGTERM it drains in-flight work and\n * releases still-`running` rows back to `pending` so a replacement worker\n * can resume with step memoization intact.\n *\n * The claim query is the beating heart: `SELECT … FOR UPDATE SKIP LOCKED`\n * inside a single transaction. Multiple worker processes share the table\n * without serialising on row locks.\n */\n// TODO(logging-subsystem): swap to ILogger once ADR-028 lands\nimport { Inject, Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';\nimport { ModuleRef } from '@nestjs/core';\nimport { and, asc, desc, eq, inArray, lt, lte, sql } from 'drizzle-orm';\nimport type { DrizzleClient } from '../../types/drizzle';\nimport { tokenKey } from '../token-key';\nimport { DRIZZLE } from '../../constants/tokens';\nimport { jobRuns, type JobRunRow } from './job-orchestration.schema';\nimport type { IJobOrchestrator, JobRun } from './job-orchestrator.protocol';\nimport type { IJobRunService } from './job-run-service.protocol';\nimport type { IJobStepService } from './job-step-service.protocol';\nimport {\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n} from './jobs-domain.tokens';\nimport {\n JOB_HANDLER_REGISTRY,\n JobHandlerBase,\n type JobContext,\n type JobHandlerMeta,\n type RetryPolicy,\n type SpawnChildOptions,\n type StepOptions,\n} from './job-handler.base';\nimport { JOBS_WAKE_CHANNEL, PgNotifyListener } from './pg-notify';\n\n/**\n * Options accepted by `JobWorker`. JOB-5 threads these through module\n * `.forRoot()` config; supplied here as a plain DI-constructor argument\n * so the worker compiles standalone.\n */\nexport interface JobWorkerOptions {\n /** Pool name this worker claims from. Matches `job.pool`. */\n pool: string;\n /** Max concurrent in-flight `processRun` calls. */\n concurrency: number;\n /** Poll interval in ms. Default 1000. */\n pollIntervalMs?: number;\n /** Stale sweep interval in ms. Default 60_000. */\n staleSweeperIntervalMs?: number;\n /**\n * Threshold beyond which a `running` row is presumed stranded by a\n * crashed worker. Default 5 min. Must be >= 2× max handler duration.\n */\n staleThresholdMs?: number;\n /** Max ms to wait for in-flight drain on SIGTERM. Default 30_000. */\n shutdownTimeoutMs?: number;\n /**\n * LISTEN-NOTIFY-1 — when true, hold a dedicated listener connection and\n * LISTEN on `codegen_jobs_wake`. A notification naming this worker's `pool`\n * triggers an immediate (debounced) claim cycle, so an enqueue is claimed in\n * milliseconds instead of waiting for the next `pollIntervalMs` tick. Polling\n * continues unchanged as the fallback heartbeat. Default false.\n */\n listenNotify?: boolean;\n}\n\n// ADR-037: namespaced `Symbol.for(...)` (via `tokenKey()`) — matches by value\n// across runtime copies.\nexport const JOB_WORKER_OPTIONS = Symbol.for(tokenKey('jobs', 'worker-options'));\n\nconst DEFAULT_POLL_INTERVAL_MS = 1_000;\nconst DEFAULT_STALE_SWEEPER_INTERVAL_MS = 60_000;\nconst DEFAULT_STALE_THRESHOLD_MS = 5 * 60_000;\nconst DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;\n\nconst TERMINAL_STATUSES: JobRunRow['status'][] = [\n 'completed',\n 'failed',\n 'timed_out',\n 'canceled',\n];\n\n// ─── Pure helpers (exported for unit tests) ────────────────────────────────\n\n/**\n * Backoff delay in ms for the Nth attempt (1-indexed). Supports both\n * policy modes. Exponential is capped at `Number.MAX_SAFE_INTEGER` so\n * pathological attempt counts don't overflow.\n */\nexport function computeBackoff(policy: RetryPolicy, attempts: number): number {\n const base = Math.max(policy.baseMs, 0);\n if (policy.backoff === 'fixed') {\n return base;\n }\n // exponential: baseMs * 2^(attempts-1)\n const exponent = Math.max(attempts - 1, 0);\n if (exponent >= 53) return Number.MAX_SAFE_INTEGER; // 2^53 overflow guard\n const raw = base * Math.pow(2, exponent);\n if (!Number.isFinite(raw) || raw >= Number.MAX_SAFE_INTEGER) {\n return Number.MAX_SAFE_INTEGER;\n }\n return raw;\n}\n\n/**\n * Decide whether an error should be retried under the given policy.\n * Matches `nonRetryableErrors` by `.name` OR `.code`. Returns\n * - `'retry'` if attempts remain and the error isn't blacklisted,\n * - `'fail'` otherwise (terminal failure).\n */\nexport function classifyError(\n err: unknown,\n policy: RetryPolicy | undefined,\n currentAttempts: number,\n): 'retry' | 'fail' {\n if (!policy) return 'fail';\n const errObj = err as { name?: string; code?: string } | undefined;\n const name = errObj?.name;\n const code = errObj?.code;\n const nonRetryable = policy.nonRetryableErrors ?? [];\n if (nonRetryable.some((n) => n === name || n === code)) return 'fail';\n if (currentAttempts + 1 >= policy.attempts) return 'fail';\n return 'retry';\n}\n\n/**\n * Build the raw claim-candidate select. Exported so tests can inspect\n * `.toSQL()` without spinning up the full worker. Matches JOB-3 §4 and\n * ADR-022 \"Claim query (Drizzle backend)\".\n */\nexport function buildClaimQuery(db: DrizzleClient, pool: string) {\n return db\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.status, 'pending'),\n eq(jobRuns.pool, pool),\n lte(jobRuns.runAt, new Date()),\n ),\n )\n .orderBy(desc(jobRuns.priority), asc(jobRuns.runAt))\n .limit(1)\n .for('update', { skipLocked: true });\n}\n\n/**\n * Build the stale-claim sweep candidate select. `FOR UPDATE SKIP LOCKED`\n * per OQ-2 resolution (2026-04-19): per-worker sweeper, safe without\n * leader election because the update is self-gating.\n */\nexport function buildStaleSweepQuery(\n db: DrizzleClient,\n staleThresholdMs: number,\n) {\n const threshold = new Date(Date.now() - staleThresholdMs);\n return db\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.status, 'running'),\n lt(jobRuns.claimedAt, threshold),\n ),\n )\n .for('update', { skipLocked: true });\n}\n\n// ─── Error serialisation ───────────────────────────────────────────────────\n\nfunction serialiseError(err: unknown, attempt: number, retryable: boolean) {\n const e = err as { message?: string; stack?: string; code?: string } | undefined;\n return {\n message: (e?.message ?? String(err)) as string,\n stack: e?.stack,\n retryable,\n attempt,\n };\n}\n\n// ─── JobWorker ─────────────────────────────────────────────────────────────\n\n@Injectable()\nexport class JobWorker implements OnModuleInit, OnModuleDestroy {\n private readonly logger = new Logger(JobWorker.name);\n private shuttingDown = false;\n private readonly inFlight = new Set<Promise<void>>();\n private pollTimer: ReturnType<typeof setInterval> | null = null;\n private sweeperTimer: ReturnType<typeof setInterval> | null = null;\n private sigtermHandled = false;\n private readonly sigtermHandler: () => void;\n\n private readonly pollIntervalMs: number;\n private readonly staleSweeperIntervalMs: number;\n private readonly staleThresholdMs: number;\n private readonly shutdownTimeoutMs: number;\n\n // LISTEN-NOTIFY-1 — dedicated listener + debounce state. `null` when\n // `listenNotify` is off (the common case); polling is the only driver then.\n private readonly listenNotifyEnabled: boolean;\n private notifyListener: PgNotifyListener | null = null;\n /** True while a wake-driven claim cycle is in flight (debounce gate). */\n private wakeDraining = false;\n /** A notify arrived mid-cycle → re-check once when the cycle ends. */\n private wakeRecheckPending = false;\n\n constructor(\n @Inject(DRIZZLE) private readonly db: DrizzleClient,\n @Inject(JOB_ORCHESTRATOR) private readonly orchestrator: IJobOrchestrator,\n @Inject(JOB_RUN_SERVICE) private readonly runService: IJobRunService,\n @Inject(JOB_STEP_SERVICE) private readonly stepService: IJobStepService,\n @Inject(JOB_WORKER_OPTIONS) private readonly options: JobWorkerOptions,\n private readonly moduleRef: ModuleRef,\n ) {\n this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n this.staleSweeperIntervalMs =\n options.staleSweeperIntervalMs ?? DEFAULT_STALE_SWEEPER_INTERVAL_MS;\n this.staleThresholdMs = options.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;\n this.shutdownTimeoutMs =\n options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;\n this.listenNotifyEnabled = options.listenNotify ?? false;\n\n this.sigtermHandler = () => {\n if (this.sigtermHandled) return;\n this.sigtermHandled = true;\n void this.onModuleDestroy();\n };\n void this.runService; // reserved for future scope-aware cancellation paths\n }\n\n // ============================================================================\n // Lifecycle\n // ============================================================================\n\n onModuleInit(): void {\n this.pollTimer = setInterval(() => {\n void this.pollAndProcess();\n }, this.pollIntervalMs);\n this.sweeperTimer = setInterval(() => {\n void this.sweepStaleClaims();\n }, this.staleSweeperIntervalMs);\n process.on('SIGTERM', this.sigtermHandler);\n\n // LISTEN-NOTIFY-1 — start the wake listener ALONGSIDE the poll timer (never\n // instead). A notify for this worker's pool drives an immediate claim cycle;\n // the interval timer above stays the durability heartbeat. Listener startup\n // is fire-and-forget: a connect failure self-heals via the listener's own\n // backoff, and until it's up the poll loop is the sole driver.\n if (this.listenNotifyEnabled) {\n // The DRIZZLE provider wraps a `pg.Pool`, exposed by drizzle as `$client`.\n const pool = (this.db as unknown as { $client?: unknown }).$client;\n if (!pool || typeof (pool as { connect?: unknown }).connect !== 'function') {\n this.logger.warn(\n `listen_notify enabled but the Drizzle client exposes no pg Pool ` +\n `($client.connect missing) — falling back to interval polling only.`,\n );\n } else {\n this.notifyListener = new PgNotifyListener({\n channel: JOBS_WAKE_CHANNEL,\n pool: pool as { connect(): Promise<never> },\n label: `jobs:${this.options.pool}`,\n onNotify: (payload) => this.onWake(payload),\n });\n void this.notifyListener.start();\n }\n }\n }\n\n /**\n * Wake handler — a `codegen_jobs_wake` notification arrived. Only payloads\n * naming THIS worker's pool are relevant (other pools have their own workers).\n * Debounced: if a claim cycle is already running we just flag a re-check so a\n * burst of N enqueues collapses to at most one extra cycle (D3).\n */\n private onWake(payload: string): void {\n if (this.shuttingDown) return;\n if (payload !== this.options.pool) return;\n if (this.wakeDraining) {\n this.wakeRecheckPending = true;\n return;\n }\n void this.drainOnWake();\n }\n\n /**\n * Claim-until-empty on a wake. Unlike the interval `pollAndProcess` (one\n * claim per tick), a wake drains greedily up to the concurrency ceiling so a\n * burst that arrived together is dispatched without waiting for N ticks. The\n * `wakeRecheckPending` flag coalesces notifies that land mid-drain.\n */\n private async drainOnWake(): Promise<void> {\n this.wakeDraining = true;\n try {\n do {\n this.wakeRecheckPending = false;\n // Claim while there's capacity; pollAndProcess no-ops at the ceiling.\n let progressed = true;\n while (\n progressed &&\n !this.shuttingDown &&\n this.inFlight.size < this.options.concurrency\n ) {\n const before = this.inFlight.size;\n await this.pollAndProcess();\n progressed = this.inFlight.size > before;\n }\n } while (this.wakeRecheckPending && !this.shuttingDown);\n } finally {\n this.wakeDraining = false;\n }\n }\n\n async onModuleDestroy(): Promise<void> {\n // LISTEN-NOTIFY-2 — release the listener connection on EVERY destroy path,\n // including the `shuttingDown` early-return below (reached when SIGTERM and\n // Nest's onModuleDestroy both fire) and a destroy with no prior SIGTERM.\n // Previously this lived only on the first (non-shuttingDown) branch, so a\n // double-fire — or a stop() that raced the listener's own in-flight\n // connect() — left a `LISTEN codegen_jobs_wake` socket open past\n // `app.close()`, hanging the process. `PgNotifyListener.stop()` is itself\n // idempotent + connect-race-safe (LISTEN-NOTIFY-2). Best-effort; a failure\n // here doesn't block the drain.\n await this.stopNotifyListener();\n\n if (this.shuttingDown) {\n // Still drain, but don't tear intervals down twice.\n await this.drainInFlight();\n return;\n }\n this.shuttingDown = true;\n if (this.pollTimer) {\n clearInterval(this.pollTimer);\n this.pollTimer = null;\n }\n if (this.sweeperTimer) {\n clearInterval(this.sweeperTimer);\n this.sweeperTimer = null;\n }\n process.removeListener('SIGTERM', this.sigtermHandler);\n\n await this.drainInFlight();\n\n // Any rows still `running` past timeout → release back to pending.\n try {\n await this.db\n .update(jobRuns)\n .set({ status: 'pending', claimedAt: null, startedAt: null })\n .where(\n and(eq(jobRuns.status, 'running'), eq(jobRuns.pool, this.options.pool)),\n );\n } catch (err) {\n this.logger.error(`shutdown reset failed: ${(err as Error).message}`);\n }\n }\n\n private async drainInFlight(): Promise<void> {\n if (this.inFlight.size === 0) return;\n const timeout = new Promise<void>((resolve) =>\n setTimeout(resolve, this.shutdownTimeoutMs),\n );\n await Promise.race([\n Promise.allSettled([...this.inFlight]).then(() => undefined),\n timeout,\n ]);\n }\n\n /**\n * LISTEN-NOTIFY-2 — stop + drop the wake listener. Idempotent: a second call\n * (SIGTERM + Nest destroy) finds `notifyListener` already null and no-ops.\n * `PgNotifyListener.stop()` is itself race-safe against an in-flight\n * `connect()`, so even a destroy that arrives microseconds after `start()`\n * releases the listener socket rather than leaking it.\n */\n private async stopNotifyListener(): Promise<void> {\n const listener = this.notifyListener;\n if (!listener) return;\n this.notifyListener = null;\n try {\n await listener.stop();\n } catch (err) {\n this.logger.error(`notify listener stop failed: ${(err as Error).message}`);\n }\n }\n\n // ============================================================================\n // Poll loop\n // ============================================================================\n\n async pollAndProcess(): Promise<void> {\n if (this.shuttingDown) return;\n if (this.inFlight.size >= this.options.concurrency) return;\n\n let claimed: JobRunRow | null;\n try {\n claimed = await this.claimNext(this.options.pool);\n } catch (err) {\n this.logger.error(`claimNext failed: ${(err as Error).message}`);\n return;\n }\n if (!claimed) return;\n\n const run = claimed;\n const promise = this.processRun(run).catch((err) => {\n this.logger.error(\n `processRun(${run.id}) unhandled: ${(err as Error).message}`,\n );\n });\n this.inFlight.add(promise);\n promise.finally(() => {\n this.inFlight.delete(promise);\n });\n }\n\n /**\n * Claim the next runnable row from the pool. Transaction ensures the\n * select-candidate + update-to-running pair is atomic; FOR UPDATE SKIP\n * LOCKED lets multiple workers share the table without serialising.\n */\n async claimNext(pool: string): Promise<JobRunRow | null> {\n return this.db.transaction(async (tx) => {\n const candidates = await tx\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.status, 'pending'),\n eq(jobRuns.pool, pool),\n lte(jobRuns.runAt, new Date()),\n ),\n )\n .orderBy(desc(jobRuns.priority), asc(jobRuns.runAt))\n .limit(1)\n .for('update', { skipLocked: true });\n const candidate = candidates[0];\n if (!candidate) return null;\n\n const [claimed] = await tx\n .update(jobRuns)\n .set({\n status: 'running',\n claimedAt: new Date(),\n startedAt: new Date(),\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, candidate.id))\n .returning();\n return (claimed ?? null) as JobRunRow | null;\n });\n }\n\n // ============================================================================\n // Stale claim sweeper\n // ============================================================================\n\n /**\n * Release rows whose `claimed_at` is older than the threshold. Safe to\n * run concurrently across workers — the two-phase tx (select-for-update\n * then update) guarantees each stranded row is only reset once.\n */\n async sweepStaleClaims(): Promise<void> {\n if (this.shuttingDown) return;\n try {\n await this.db.transaction(async (tx) => {\n const threshold = new Date(Date.now() - this.staleThresholdMs);\n const stale = await tx\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(eq(jobRuns.status, 'running'), lt(jobRuns.claimedAt, threshold)),\n )\n .for('update', { skipLocked: true });\n if (stale.length === 0) return;\n const ids = stale.map((r) => r.id);\n await tx\n .update(jobRuns)\n .set({ status: 'pending', claimedAt: null, startedAt: null })\n .where(inArray(jobRuns.id, ids));\n for (const id of ids) {\n this.logger.warn(`Recovered stale claim on run ${id}`);\n }\n });\n } catch (err) {\n this.logger.error(`sweepStaleClaims failed: ${(err as Error).message}`);\n }\n }\n\n // ============================================================================\n // processRun\n // ============================================================================\n\n private async processRun(claimed: JobRunRow): Promise<void> {\n const registryEntry = JOB_HANDLER_REGISTRY.get(claimed.jobType);\n\n // (a) Missing handler — defensive; JOB-5 boot validator should have caught.\n if (!registryEntry) {\n this.logger.error(\n `No handler registered for jobType='${claimed.jobType}' (run ${claimed.id})`,\n );\n await this.markFailed(\n claimed,\n new Error(`No handler registered for jobType='${claimed.jobType}'`),\n /*finalAttempts*/ (claimed.attempts ?? 0) + 1,\n );\n return;\n }\n\n // (b) Concurrency-queue release gate — defer if another run with the\n // same key is already `running`.\n if (claimed.concurrencyKey) {\n const inflight = await this.db\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.concurrencyKey, claimed.concurrencyKey),\n eq(jobRuns.status, 'running'),\n ),\n );\n const other = inflight.find((r) => r.id !== claimed.id);\n if (other) {\n await this.db\n .update(jobRuns)\n .set({\n status: 'pending',\n claimedAt: null,\n startedAt: null,\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, claimed.id));\n return;\n }\n }\n\n const meta = registryEntry.meta as JobHandlerMeta<unknown>;\n const HandlerClass = registryEntry.handlerClass;\n\n // (c) Build JobContext. Resolve the handler instance from Nest's DI\n // graph so its `@Inject` constructor params (which may come from\n // any module in the app graph) are satisfied. `moduleRef.create()`\n // would otherwise instantiate a fresh class within JobWorkerModule's\n // scope only — which blows up with \"not a provider of the current\n // module\" for any handler that consumes a service from a peer\n // module (e.g. CrmSyncJob injecting CrmSyncFactory from CrmModule).\n // Consequence: handlers MUST be registered as providers in their\n // owning module (@Injectable + `providers: [HandlerClass]`). The\n // @JobHandler decorator handles registry registration only, not DI.\n // See the jobs skill's handler-authoring.md for the registration\n // rule.\n const handler = this.moduleRef.get(\n HandlerClass as unknown as new (...args: unknown[]) => unknown,\n { strict: false },\n ) as JobHandlerBase<unknown>;\n const ctx: JobContext<unknown> = {\n input: claimed.input,\n run: claimed as JobRun,\n step: this.makeStepFn(claimed),\n spawnChild: this.makeSpawnFn(claimed),\n logger: new Logger(`JobRun:${claimed.id}`),\n };\n\n const attemptsBefore = claimed.attempts ?? 0;\n try {\n // (d) Run the handler.\n const output = (await handler.run(ctx)) as Record<string, unknown> | undefined;\n // (e) Success.\n await this.db\n .update(jobRuns)\n .set({\n status: 'completed',\n output: (output ?? {}) as Record<string, unknown>,\n finishedAt: new Date(),\n updatedAt: new Date(),\n attempts: attemptsBefore + 1,\n })\n .where(eq(jobRuns.id, claimed.id));\n } catch (err) {\n // (f) Error classification + retry/fail.\n const policy = meta.retry;\n const decision = classifyError(err, policy, attemptsBefore);\n const nextAttempts = attemptsBefore + 1;\n if (decision === 'retry' && policy) {\n const delay = computeBackoff(policy, nextAttempts);\n await this.db\n .update(jobRuns)\n .set({\n status: 'pending',\n attempts: nextAttempts,\n runAt: new Date(Date.now() + delay),\n startedAt: null,\n claimedAt: null,\n error: serialiseError(err, nextAttempts, true),\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, claimed.id));\n } else {\n await this.markFailed(claimed, err, nextAttempts);\n }\n }\n }\n\n private async markFailed(\n claimed: JobRunRow,\n err: unknown,\n finalAttempts: number,\n ): Promise<void> {\n await this.db\n .update(jobRuns)\n .set({\n status: 'failed',\n attempts: finalAttempts,\n finishedAt: new Date(),\n error: serialiseError(err, finalAttempts, false),\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, claimed.id));\n\n // Parent-close-policy cascade: if this run has children under the same\n // root_run_id and this run's own parentClosePolicy is 'terminate', cascade.\n if (claimed.parentClosePolicy === 'terminate') {\n try {\n // JOB-8 — thread the run's own tenantId so the orchestrator's\n // multi-tenant gate passes. Without this, every terminate-policy\n // cascade throws MissingTenantIdError under multiTenant=true and\n // the outer catch silently swallows it — children never cancel.\n await this.orchestrator.cancel(claimed.id, {\n cascade: true,\n reason: 'parent-failed',\n tenantId: claimed.tenantId,\n });\n } catch (cascadeErr) {\n // cancel is idempotent; failure here is unusual but not fatal.\n this.logger.warn(\n `cascade on failed run ${claimed.id}: ${(cascadeErr as Error).message}`,\n );\n }\n }\n }\n\n // ============================================================================\n // ctx.step / ctx.spawnChild builders\n // ============================================================================\n\n private makeStepFn(run: JobRunRow) {\n return async <TOutput>(\n stepId: string,\n fn: () => Promise<TOutput>,\n _opts?: StepOptions,\n ): Promise<TOutput> => {\n void _opts;\n const existing = await this.stepService.findStep(run.id, stepId);\n if (existing?.status === 'completed') {\n return existing.output as TOutput;\n }\n\n const seq = await this.nextStepSeq(run.id);\n const startedAt = new Date();\n const nextAttempts = (existing?.attempts ?? 0) + 1;\n await this.stepService.recordStep({\n jobRunId: run.id,\n stepId,\n kind: 'task',\n seq,\n status: 'running',\n startedAt,\n attempts: nextAttempts,\n });\n try {\n const output = await fn();\n await this.stepService.recordStep({\n jobRunId: run.id,\n stepId,\n kind: 'task',\n seq,\n status: 'completed',\n output: output as Record<string, unknown> | undefined,\n finishedAt: new Date(),\n attempts: nextAttempts,\n });\n return output;\n } catch (err) {\n await this.stepService.recordStep({\n jobRunId: run.id,\n stepId,\n kind: 'task',\n seq,\n status: 'failed',\n error: serialiseError(err, nextAttempts, false),\n finishedAt: new Date(),\n attempts: nextAttempts,\n });\n throw err;\n }\n };\n }\n\n private makeSpawnFn(run: JobRunRow) {\n return async (\n type: string,\n input: unknown,\n opts?: SpawnChildOptions,\n ): Promise<JobRun> => {\n return this.orchestrator.start(type, input, {\n parentRunId: run.id,\n parentClosePolicy: opts?.closePolicy,\n runAt: opts?.runAt,\n priority: opts?.priority,\n tags: opts?.tags,\n triggerSource: 'parent',\n triggerRef: run.id,\n });\n };\n }\n\n /**\n * Allocate the next `seq` for a given run. SELECT-max approach — runs\n * typically have <100 steps so the scan is cheap, and correctness across\n * retries is more important than the microseconds saved by an in-memory\n * counter (which would drift if the worker crashes mid-run and another\n * worker resumes via stale-claim sweep).\n */\n private async nextStepSeq(runId: string): Promise<number> {\n const result = await this.db.execute(\n sql`SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM job_step WHERE job_run_id = ${runId}`,\n );\n // Driver shape varies and is NOT uniformly array-iterable, so we must\n // never array-destructure the raw result (that throws `{} is not iterable`\n // on the node-postgres `Result` object, which exposes `.rows` instead of\n // being an array — first hit by package-mode bridge deliveries on\n // `drizzle-orm/node-postgres`). Normalise to a row array first, then read.\n // - node-postgres `db.execute(sql)` → `{ rows: [{ next }], ... }`\n // - some drivers / future shapes → a plain `[{ next }]` array\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const raw = result as any;\n const rows: Array<{ next?: unknown }> = Array.isArray(raw)\n ? raw\n : Array.isArray(raw?.rows)\n ? raw.rows\n : [];\n const next = rows[0]?.next;\n return typeof next === 'undefined' ? 1 : Number(next);\n }\n\n // ============================================================================\n // (suppress unused-import noise)\n // ============================================================================\n}\n\n// Terminal statuses re-exported for JOB-4 parity imports.\nexport { TERMINAL_STATUSES };\n"],"mappings":"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","names":[]}
@@ -349,7 +349,14 @@ var EntityConfigSchema = z.object({
349
349
  context: z.string().regex(
350
350
  /^[a-z][a-z0-9_]*$/,
351
351
  "context must be lowercase snake_case (e.g. 'integration')"
352
- ).optional()
352
+ ).optional(),
353
+ // ADR-038: per-entity frontend sync mode. Overrides global frontend.sync.mode.
354
+ // 'api' → queryCollectionOptions (REST via TanStack Query)
355
+ // 'electric' → electricCollectionOptions (real-time shape sync)
356
+ // 'offline' (Electric + Dexie) is deferred — see
357
+ // docs/specs/2026-06-04-frontend-pipeline-rebuild.md OQ-6.
358
+ // Sibling to `surface:`/`context:`; lives inside the `entity:` block.
359
+ sync: z.enum(["api", "electric"]).optional()
353
360
  }).strict().refine((d) => !(d.pattern && d.patterns), {
354
361
  message: "'pattern' and 'patterns' are mutually exclusive"
355
362
  });
@@ -1320,6 +1327,7 @@ function transformToEntity(result) {
1320
1327
  patterns: definition.entity.patterns,
1321
1328
  patternConfig: definition.entity.config,
1322
1329
  scopeable: definition.entity.scopeable ?? false,
1330
+ expose: definition.entity.expose ?? ["repository", "rest", "trpc"],
1323
1331
  folderStructure: definition.entity.folder_structure ?? "nested",
1324
1332
  fields: /* @__PURE__ */ new Map(),
1325
1333
  relationships: /* @__PURE__ */ new Map(),
@@ -1664,9 +1672,75 @@ function resolveRelationshipReferences(relationshipDefs, entities) {
1664
1672
  return issues;
1665
1673
  }
1666
1674
 
1675
+ // src/parser/entity-registry.ts
1676
+ import { resolve as resolve3 } from "path";
1677
+ var camelCase = (s) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
1678
+ var pascalCase = (s) => {
1679
+ const camel = camelCase(s);
1680
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
1681
+ };
1682
+ function loadErrorToIssue2(error) {
1683
+ const issues = [
1684
+ {
1685
+ severity: "error",
1686
+ type: "parse_error",
1687
+ message: error.error,
1688
+ path: error.filePath
1689
+ }
1690
+ ];
1691
+ if (error.details) {
1692
+ for (const detail of error.details) {
1693
+ issues.push({
1694
+ severity: "error",
1695
+ type: "schema_error",
1696
+ message: detail,
1697
+ path: error.filePath
1698
+ });
1699
+ }
1700
+ }
1701
+ return issues;
1702
+ }
1703
+ function loadEntityRegistry(entitiesDir) {
1704
+ const registry = /* @__PURE__ */ new Map();
1705
+ const issues = [];
1706
+ const resolvedDir = resolve3(entitiesDir);
1707
+ let files;
1708
+ try {
1709
+ files = findYamlFiles(resolvedDir);
1710
+ } catch {
1711
+ issues.push({
1712
+ severity: "error",
1713
+ type: "parse_error",
1714
+ message: `Failed to read directory: ${resolvedDir}`,
1715
+ path: resolvedDir
1716
+ });
1717
+ return { registry, issues };
1718
+ }
1719
+ for (const filePath of files) {
1720
+ const result = loadEntityFromYaml(filePath);
1721
+ if (!result.success) {
1722
+ issues.push(...loadErrorToIssue2(result));
1723
+ continue;
1724
+ }
1725
+ const { entity } = result.definition;
1726
+ registry.set(entity.name, {
1727
+ name: entity.name,
1728
+ plural: entity.plural,
1729
+ // authoritative — never derived
1730
+ table: entity.table,
1731
+ className: pascalCase(entity.name),
1732
+ classNamePlural: pascalCase(entity.plural),
1733
+ camelName: camelCase(entity.name),
1734
+ pluralCamelName: camelCase(entity.plural),
1735
+ sync: entity.sync ?? null
1736
+ });
1737
+ }
1738
+ return { registry, issues };
1739
+ }
1740
+
1667
1741
  // src/parser/validate-providers.ts
1668
1742
  import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
1669
- import { isAbsolute, join as join2, resolve as resolve3 } from "path";
1743
+ import { isAbsolute, join as join2, resolve as resolve4 } from "path";
1670
1744
  import ts from "typescript";
1671
1745
  function collectEntitySurfaces(entities) {
1672
1746
  const surfaces = /* @__PURE__ */ new Set();
@@ -1698,7 +1772,7 @@ function resolveModuleFile(importPath, opts) {
1698
1772
  }
1699
1773
  }
1700
1774
  if (base === null) {
1701
- base = isAbsolute(importPath) ? importPath : resolve3(opts.sourceRoot, importPath);
1775
+ base = isAbsolute(importPath) ? importPath : resolve4(opts.sourceRoot, importPath);
1702
1776
  }
1703
1777
  const candidates = [
1704
1778
  base,
@@ -3999,6 +4073,7 @@ export {
3999
4073
  detectYamlType,
4000
4074
  loadEntities,
4001
4075
  loadRelationships,
4076
+ loadEntityRegistry,
4002
4077
  collectEntitySurfaces,
4003
4078
  validateProviders,
4004
4079
  buildDomainGraph,
@@ -4049,4 +4124,4 @@ export {
4049
4124
  analyzeDomain,
4050
4125
  validateEntities
4051
4126
  };
4052
- //# sourceMappingURL=chunk-IOQMMH6C.js.map
4127
+ //# sourceMappingURL=chunk-CFFTPWHM.js.map