@teamscala/service-boot 0.23.523 → 0.23.525

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 (127) hide show
  1. package/package.json +1 -9
  2. package/src/agreement-gate/agreement-lookup.ts +0 -7
  3. package/src/agreement-gate/authorized-emails.ts +0 -14
  4. package/src/agreement-gate/placeholder-strip.ts +0 -10
  5. package/src/agreement-gate/resolution-caches.ts +0 -5
  6. package/src/agreement-gate/settings.ts +0 -22
  7. package/src/agreement-gate/signing-session.ts +0 -8
  8. package/src/agreement-gate/signing-shell.ts +0 -9
  9. package/src/agreement-gate.ts +0 -52
  10. package/src/apply-logger-config.ts +1 -9
  11. package/src/asset-cache-key.ts +1 -5
  12. package/src/asset-optimize.ts +1 -7
  13. package/src/asset-proxy.ts +1 -10
  14. package/src/asset-social-card.ts +1 -9
  15. package/src/attach-agreement-magic.ts +0 -14
  16. package/src/attach-portal-members.ts +0 -17
  17. package/src/attach-proposal-accept.ts +1 -21
  18. package/src/attach-proposal-magic.ts +1 -13
  19. package/src/attach-transfers-upload.ts +0 -21
  20. package/src/auth/sign-out.ts +1 -44
  21. package/src/auth-gate/public-paths.ts +1 -96
  22. package/src/auth-gate.ts +1 -41
  23. package/src/bind-page-handler.ts +1 -83
  24. package/src/build-client-assets/build-lock.ts +1 -11
  25. package/src/build-client-assets/hashed-output.ts +1 -11
  26. package/src/build-client-assets/hydrate-bundle.ts +1 -137
  27. package/src/build-client-assets/manifest.ts +1 -30
  28. package/src/build-client-assets/source-candidates.ts +0 -51
  29. package/src/build-client-assets/sourcemaps-upload.ts +1 -52
  30. package/src/build-client-assets/styles-build.ts +1 -71
  31. package/src/build-client-assets.ts +1 -30
  32. package/src/capability-slug.ts +1 -45
  33. package/src/compose-page-title.ts +1 -34
  34. package/src/content-gate/access.ts +0 -34
  35. package/src/content-gate/catalog.ts +0 -52
  36. package/src/content-gate/config-types.ts +3 -90
  37. package/src/content-gate/gate-entry-clears.ts +0 -18
  38. package/src/content-gate/gate.ts +0 -27
  39. package/src/content-gate/parse-cookie.ts +0 -4
  40. package/src/content-page-floor.ts +0 -28
  41. package/src/csrf-protection.ts +1 -28
  42. package/src/error-page-logo.ts +2 -35
  43. package/src/expand-roster.ts +1 -25
  44. package/src/gate-ttls.ts +0 -12
  45. package/src/gpt-gate.ts +1 -19
  46. package/src/hono-error-handler.ts +0 -19
  47. package/src/install-harness.ts +1 -12
  48. package/src/install-logging.ts +1 -47
  49. package/src/load-page-manifest.ts +1 -29
  50. package/src/load-service-install-row.ts +42 -10
  51. package/src/module-not-found.ts +0 -17
  52. package/src/mount-orpc-router.ts +1 -94
  53. package/src/mount-server-packages.ts +1 -37
  54. package/src/mount.ts +1 -4
  55. package/src/mutable-app-ref.ts +0 -21
  56. package/src/orpc-contract-rows.json +289 -0
  57. package/src/orpc-openapi-document.ts +1 -19
  58. package/src/orpc-openapi-gate.ts +1 -23
  59. package/src/page-route-shell-types.ts +1 -8
  60. package/src/page-server-loader-graph.ts +0 -10
  61. package/src/page-server-orpc/server-client.ts +1 -23
  62. package/src/pg-boss-handler-registry.ts +1 -24
  63. package/src/pg-boss-mount.ts +1 -109
  64. package/src/proposal-authorized-emails.ts +0 -32
  65. package/src/proposal-gate.ts +1 -52
  66. package/src/quiz-gate.ts +1 -31
  67. package/src/read-html-template.ts +1 -49
  68. package/src/readiness-gate.ts +0 -8
  69. package/src/register-api-routes.ts +1 -11
  70. package/src/register-crash-handlers.ts +1 -6
  71. package/src/register-page-routes.ts +1 -12
  72. package/src/register-page-server-client.ts +1 -66
  73. package/src/register-registry-page-route/ai-chat-prefetch.ts +1 -24
  74. package/src/register-registry-page-route/apply-page-branding.ts +1 -100
  75. package/src/register-registry-page-route/captured-loader-config.ts +1 -12
  76. package/src/register-registry-page-route/graph-cache.ts +1 -38
  77. package/src/register-registry-page-route/live-loader-config.ts +1 -22
  78. package/src/register-registry-page-route/meta-title.ts +1 -36
  79. package/src/register-registry-page-route/page-data-shared.ts +1 -18
  80. package/src/register-registry-page-route/prefetch-org-id.ts +1 -24
  81. package/src/register-registry-page-route/proposal-approval-email-token.ts +1 -22
  82. package/src/register-registry-page-route/proposal-brand-stamping.ts +1 -31
  83. package/src/register-registry-page-route/public-shell-page-data.ts +1 -34
  84. package/src/register-registry-page-route/region-slots-page-data.ts +1 -8
  85. package/src/register-registry-page-route/resolve-page-data.ts +1 -10
  86. package/src/register-registry-page-route/resolve-panel-page.ts +1 -10
  87. package/src/register-registry-page-route/resolve-request-graph.ts +1 -68
  88. package/src/register-registry-page-route/run-content-gates.ts +3 -64
  89. package/src/register-registry-page-route/single-page-data.ts +1 -9
  90. package/src/register-registry-page-route/stamp-session-context.ts +1 -10
  91. package/src/register-registry-page-route/warn-uncovered-blocks.ts +1 -18
  92. package/src/register-registry-page-route.ts +1 -93
  93. package/src/register-static-assets.ts +1 -53
  94. package/src/register-ws-routes.ts +1 -21
  95. package/src/resolve-brand-favicon.ts +0 -22
  96. package/src/resolve-service-port.ts +1 -61
  97. package/src/resolve-theme-mode.ts +1 -30
  98. package/src/resolvers/app-header.ts +1 -20
  99. package/src/resolvers/fetch-org-brand.ts +1 -11
  100. package/src/resolvers/portal-route-paths.ts +1 -29
  101. package/src/resolvers/website-by-host-map.ts +1 -7
  102. package/src/resource-not-found.ts +0 -48
  103. package/src/run-boot-hook-list.ts +1 -13
  104. package/src/run-boot-hooks.ts +1 -19
  105. package/src/scala-tools-currency-watchdog.ts +1 -7
  106. package/src/security-headers.ts +1 -7
  107. package/src/select-service-routes.ts +1 -31
  108. package/src/server-error-page.ts +0 -18
  109. package/src/theme-fonts-link.ts +1 -21
  110. package/src/transfers/presigned-upload.ts +1 -27
  111. package/src/two-phase-full-app.ts +0 -82
  112. package/src/two-phase-minimal-app.ts +1 -111
  113. package/src/two-phase-readiness.ts +0 -6
  114. package/src/types.ts +0 -17
  115. package/src/ui-service.ts +2 -48
  116. package/src/ws-boot-self-test.ts +1 -14
  117. package/src/content-gate/gate-entry-marker.test.ts +0 -111
  118. package/src/content-gate/token.segmentation.test.ts +0 -76
  119. package/src/content-gate/token.test.ts +0 -65
  120. package/src/content-gate/token.ts +0 -219
  121. package/src/is-react-element-type.test.ts +0 -45
  122. package/src/is-react-element-type.ts +0 -20
  123. package/src/page-server-orpc/hub-mcp-client.ts +0 -20
  124. package/src/pg-boss-mount.test.ts +0 -101
  125. package/src/resource-chat-inject.page-assistant.test.ts +0 -145
  126. package/src/resource-chat-inject.test.ts +0 -240
  127. package/src/resource-chat-inject.ts +0 -374
@@ -120,6 +120,295 @@
120
120
  "package": "@teamscala/service-boot",
121
121
  "suite_type": "function_call",
122
122
  "slug": "boot:openapi-contract-refuses-when-no-key-is-configured"
123
+ },
124
+ {
125
+ "args": [
126
+ {},
127
+ "quiz_token=good; quiz_token_entered=1",
128
+ 1730000000000,
129
+ {
130
+ "cookieName": "quiz_token",
131
+ "verifyToken": {
132
+ "$fn": {
133
+ "returns": {
134
+ "name": "V",
135
+ "email": "v@example.com"
136
+ }
137
+ }
138
+ },
139
+ "onAuthorized": {
140
+ "$fn": {
141
+ "returns": null
142
+ }
143
+ },
144
+ "getResourceId": {
145
+ "$fn": {
146
+ "returns": "res-1"
147
+ }
148
+ },
149
+ "onUnauthorized": {
150
+ "$fn": {
151
+ "returns": null
152
+ }
153
+ },
154
+ "entryCookieName": "quiz_token_entered"
155
+ }
156
+ ],
157
+ "note": "Converts gate-entry-marker.test.ts case 3, including the measured first-shipped-version failure (the clears read after the run() scope saw nothing and left the marker in the browser, measured live 2026-09-14): recording is asserted from the recorded call itself, which a post-scope read cannot fake.",
158
+ "label": "an opting gate with token AND marker authorizes and RECORDS the marker for clearing — one view, then the gate returns",
159
+ "expect": {
160
+ "email": "v@example.com",
161
+ "applies": true,
162
+ "authorized": true
163
+ },
164
+ "export": "runContentGate",
165
+ "module": "src/content-gate/gate.ts",
166
+ "package": "@teamscala/service-boot",
167
+ "fixtures": {
168
+ "stubs": [
169
+ {
170
+ "export": "recordGateEntryClear",
171
+ "module": "./gate-entry-clears.ts",
172
+ "record": "clears",
173
+ "returns": null
174
+ }
175
+ ]
176
+ },
177
+ "outcomes": {
178
+ "clears": [
179
+ {
180
+ "count": 1
181
+ },
182
+ {
183
+ "equals": [
184
+ {
185
+ "args": [
186
+ "quiz_token_entered"
187
+ ]
188
+ }
189
+ ]
190
+ }
191
+ ]
192
+ },
193
+ "suite_type": "function_call",
194
+ "slug": "gate-entry-marker:marker-plus-token-authorizes-and-records-clear"
195
+ },
196
+ {
197
+ "args": [
198
+ {},
199
+ "quiz_token=bad; quiz_token_entered=1",
200
+ 1730000000000,
201
+ {
202
+ "cookieName": "quiz_token",
203
+ "verifyToken": {
204
+ "$fn": {
205
+ "returns": null
206
+ }
207
+ },
208
+ "onAuthorized": {
209
+ "$fn": {
210
+ "returns": null
211
+ }
212
+ },
213
+ "getResourceId": {
214
+ "$fn": {
215
+ "returns": "res-1"
216
+ }
217
+ },
218
+ "onUnauthorized": {
219
+ "$fn": {
220
+ "returns": null
221
+ }
222
+ },
223
+ "entryCookieName": "quiz_token_entered"
224
+ }
225
+ ],
226
+ "note": "Converts gate-entry-marker.test.ts case 4.",
227
+ "label": "marker present but token invalid stays unauthorized and records no clear",
228
+ "expect": {
229
+ "applies": true,
230
+ "authorized": false
231
+ },
232
+ "export": "runContentGate",
233
+ "module": "src/content-gate/gate.ts",
234
+ "package": "@teamscala/service-boot",
235
+ "fixtures": {
236
+ "stubs": [
237
+ {
238
+ "export": "recordGateEntryClear",
239
+ "module": "./gate-entry-clears.ts",
240
+ "record": "clears",
241
+ "returns": null
242
+ }
243
+ ]
244
+ },
245
+ "outcomes": {
246
+ "clears": [
247
+ {
248
+ "count": 0
249
+ }
250
+ ]
251
+ },
252
+ "suite_type": "function_call",
253
+ "slug": "gate-entry-marker:marker-without-valid-token-records-no-clear"
254
+ },
255
+ {
256
+ "args": [
257
+ {},
258
+ "quiz_token=good",
259
+ 1730000000000,
260
+ {
261
+ "cookieName": "quiz_token",
262
+ "verifyToken": {
263
+ "$fn": {
264
+ "returns": {
265
+ "name": "V",
266
+ "email": "v@example.com"
267
+ }
268
+ }
269
+ },
270
+ "onAuthorized": {
271
+ "$fn": {
272
+ "returns": null
273
+ }
274
+ },
275
+ "getResourceId": {
276
+ "$fn": {
277
+ "returns": "res-1"
278
+ }
279
+ },
280
+ "onUnauthorized": {
281
+ "$fn": {
282
+ "returns": null
283
+ }
284
+ }
285
+ }
286
+ ],
287
+ "note": "Converts the hand-written gate-entry-marker.test.ts case 1: the entry marker is opt-in, so every non-opting service behaves exactly as before. Converted 2026-10-07 by the tests-out conversion.",
288
+ "label": "a gate that does NOT declare an entry marker authorizes on any load and records nothing for clearing",
289
+ "expect": {
290
+ "email": "v@example.com",
291
+ "applies": true,
292
+ "authorized": true
293
+ },
294
+ "export": "runContentGate",
295
+ "module": "src/content-gate/gate.ts",
296
+ "package": "@teamscala/service-boot",
297
+ "fixtures": {
298
+ "stubs": [
299
+ {
300
+ "export": "recordGateEntryClear",
301
+ "module": "./gate-entry-clears.ts",
302
+ "record": "clears",
303
+ "returns": null
304
+ }
305
+ ]
306
+ },
307
+ "outcomes": {
308
+ "clears": [
309
+ {
310
+ "count": 0
311
+ }
312
+ ]
313
+ },
314
+ "suite_type": "function_call",
315
+ "slug": "gate-entry-marker:non-opting-authorizes-any-load-records-nothing"
316
+ },
317
+ {
318
+ "args": [
319
+ {},
320
+ "quiz_token=good",
321
+ 1730000000000,
322
+ {
323
+ "cookieName": "quiz_token",
324
+ "verifyToken": {
325
+ "$fn": {
326
+ "returns": {
327
+ "name": "V",
328
+ "email": "v@example.com"
329
+ }
330
+ }
331
+ },
332
+ "onAuthorized": {
333
+ "$fn": {
334
+ "returns": null
335
+ }
336
+ },
337
+ "getResourceId": {
338
+ "$fn": {
339
+ "returns": "res-1"
340
+ }
341
+ },
342
+ "onUnauthorized": {
343
+ "$fn": {
344
+ "returns": null
345
+ }
346
+ },
347
+ "entryCookieName": "quiz_token_entered"
348
+ }
349
+ ],
350
+ "note": "Converts gate-entry-marker.test.ts case 2: the marker, not the token alone, is what skips the gate — the mechanism measured live 2026-09-14 when the quiz_token cookie alone let a refresh past the identity gate.",
351
+ "label": "an opting gate authorizes only the request carrying the marker — a token WITHOUT it is unauthorized, so a refresh shows the gate",
352
+ "expect": {
353
+ "applies": true,
354
+ "authorized": false
355
+ },
356
+ "export": "runContentGate",
357
+ "module": "src/content-gate/gate.ts",
358
+ "package": "@teamscala/service-boot",
359
+ "fixtures": {
360
+ "stubs": [
361
+ {
362
+ "export": "recordGateEntryClear",
363
+ "module": "./gate-entry-clears.ts",
364
+ "record": "clears",
365
+ "returns": null
366
+ }
367
+ ]
368
+ },
369
+ "outcomes": {
370
+ "clears": [
371
+ {
372
+ "count": 0
373
+ }
374
+ ]
375
+ },
376
+ "suite_type": "function_call",
377
+ "slug": "gate-entry-marker:opting-gate-without-marker-is-unauthorized"
378
+ },
379
+ {
380
+ "args": [],
381
+ "note": "Converts the pairing half of gate-entry-marker.test.ts: gate and access endpoint must declare the SAME marker name, or the endpoint's redirect carries a cookie the gate never reads and the one-view never happens. Converted 2026-10-07.",
382
+ "label": "the quiz access endpoint opts into the SAME entry marker as the gate",
383
+ "expect": {
384
+ "export_shape": {
385
+ "member_equals": {
386
+ "entryCookieName": "quiz_token_entered"
387
+ }
388
+ }
389
+ },
390
+ "export": "quizAccessConfig",
391
+ "module": "src/quiz-gate.ts",
392
+ "package": "@teamscala/service-boot",
393
+ "suite_type": "function_call",
394
+ "slug": "quiz-gate:access-endpoint-shares-the-entry-marker"
395
+ },
396
+ {
397
+ "args": [],
398
+ "note": "Converts the pairing half of gate-entry-marker.test.ts: the quiz SSR gate carries the marker name, so a refresh returns to the identity gate (operator ruling 2026-09-14). Converted 2026-10-07.",
399
+ "label": "the quiz gate opts into the one-view entry marker",
400
+ "expect": {
401
+ "export_shape": {
402
+ "member_equals": {
403
+ "entryCookieName": "quiz_token_entered"
404
+ }
405
+ }
406
+ },
407
+ "export": "quizGateConfig",
408
+ "module": "src/quiz-gate.ts",
409
+ "package": "@teamscala/service-boot",
410
+ "suite_type": "function_call",
411
+ "slug": "quiz-gate:gate-declares-the-one-view-entry-marker"
123
412
  }
124
413
  ]
125
414
  }
@@ -1,26 +1,8 @@
1
1
  /**
2
2
  * @system service-boot
3
- * @status handwritten — none derivable: the title and the server URL are the MOUNT's facts (the slug it was handed, the host the request arrived on) and no row carries either — searched the portal's package-manifest rows (scala-portal, scala-portal-ts-dev, portal-ui, portal-shell) for a public base URL and the service_env rows for a configured origin; none carries one, so the reachable address can only come from the request
4
- * @edit edit directly
5
- *
6
- * THE DOCUMENT'S OWN FACTS, DERIVED WHERE THEY ARE KNOWN: the service
7
- * names itself, and the request knows where it arrived. Deriving rather
8
- * than configuring is what makes ONE document correct for the internal
9
- * address the MCP gateway uses and for a public host at the same time
10
- * (`information-exists-in-one-location` — the request is the only place
11
- * the reachable address exists).
12
- *
13
- * The `/api/rpc` prefix is part of the answer, not an accident of the
14
- * mount: it is the prefix the POST handler serves under, so a client that
15
- * reads the document can call what the document describes.
3
+ * @status handwritten
16
4
  */
17
5
 
18
- /**
19
- * Both fallbacks are read through FALSYNESS, not `??`: a header that is
20
- * present but empty named nothing, and `"" ?? "http"` would splice an
21
- * empty scheme into the URL as `://host` — a document that serves and
22
- * cannot be called from (caught by the row passing empty positions).
23
- */
24
6
  import type { OpenApiDocumentOptions } from "@teamscala/orpc/openapi";
25
7
 
26
8
  const FALLBACK_HOST = "localhost";
@@ -1,37 +1,15 @@
1
1
  /**
2
2
  * @system service-boot
3
- * @status handwritten — none derivable: the gate is a REJECTION RULE over four request facts, and no row states a rule — searched the service-boot auth surfaces (auth-gate.ts, auth/, content-gate/, gpt-gate.ts, bind-page-handler.ts) for an existing static-key check to call, and public-paths.ts names exempted PATHS, never a verifier
4
- * @edit edit directly
5
- *
6
- * THE OpenAPI CONTRACT GATE: who may read the document.
7
- *
8
- * It refuses a PROXIED request even when the key is right. The guard
9
- * `auth-helpers-fail-closed` holds a static key internal-only and never a
10
- * public sole-gate, and names the remedy: also reject on proxy-header
11
- * presence. Here the two are one fact from both sides — public ingress
12
- * crosses the tunnel and injects those headers, an internal fetch carries
13
- * neither, and the MCP gateway is the internal fetch.
14
- *
15
- * EVERY REFUSAL IS A REFUSAL: no key, no header, wrong key, proxied.
3
+ * @status handwritten
16
4
  */
17
5
 
18
- /**
19
- * The four request facts the gate reads, all optional: a fact the caller
20
- * did not send is absent, never a value to trust. Truthiness reads absence
21
- * and null identically, so a header is an assertion, not a hint.
22
- */
23
6
  export interface OpenApiContractRequest {
24
- /** The raw `authorization` header, absent when the caller sent none. */
25
7
  authorization?: string | null;
26
- /** The configured `SCALA_DEV_KEY`, absent when the service has none. */
27
8
  key?: string | null;
28
- /** `x-forwarded-proto`. Its presence means the request was proxied. */
29
9
  forwardedProto?: string | null;
30
- /** `x-forwarded-for`. Same meaning, checked so neither alone is load-bearing. */
31
10
  forwardedFor?: string | null;
32
11
  }
33
12
 
34
- /** True only for an unproxied request presenting exactly the configured key. */
35
13
  export function openApiContractAuthorised(request: OpenApiContractRequest): boolean {
36
14
  if (!request.key) return false;
37
15
  if (request.forwardedProto || request.forwardedFor) return false;
@@ -1,13 +1,6 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Render mode literal shared across page route registration. The old
7
- * shell-variant types (LegacyShell, PageSlugShell, RegionsShell,
8
- * PublicShell, ConfigPageRoute) were removed when the Phase 2 deploy-time
9
- * `service_install.page_routes[]` path was eliminated — all routing is
10
- * now registry-driven `page_route` rows (Phase 1).
11
4
  */
12
5
 
13
6
  export type RenderModeLiteral =
@@ -1,16 +1,6 @@
1
1
  /**
2
2
  * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * page-server-loader-graph.ts — binds the ONE loader-graph resolver
7
- * (@teamscala/resolver-engine's createLoaderConfigResolver) to the page-server
8
- * SSR client. The resolver used to be duplicated here with its own algorithm;
9
- * the copies drifted (a non-UUID URL ref resolved absent on this surface and
10
- * still threw into the render breaker on the fn-router surface), so the
11
- * algorithm became one injected function and each surface binds its own
12
- * client source. This module IS that binding for the SSR surface — the single
13
- * place the page-server client and the loader-graph resolver meet.
14
4
  */
15
5
 
16
6
  import { createLoaderConfigResolver } from "@teamscala/resolver-engine/loader-config/resolve-from-loader-config";
@@ -1,28 +1,6 @@
1
1
  /**
2
- * @system page-server
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Server-side ORPC client for in-process direct invocation, per the canonical
7
- * Vite/Next/Nuxt SSR pattern documented at https://orpc.dev/docs/best-practices/optimize-ssr
8
- *
9
- * The host service (scala-install) must call `setServerClient(client)` once
10
- * at boot, after createRouterClient(appRouter, { context: { prisma } }).
11
- * Then page-server's createPageHandler reads `getServerClient()` per request
12
- * — no HTTP loopback, just a typed function call.
13
- *
14
- * NO EMPTY-ROUTER SUBSTITUTE. This used to answer an unregistered read by
15
- * fabricating `createRouterClient({}, …)` — a client with no models on it. That
16
- * turned "boot never registered a client" into "every model lookup returns
17
- * undefined", which surfaces far away as MissingLoaderModelCapabilityError on
18
- * page render rather than as a boot failure. Measured 2026-08-20: quiz and forms
19
- * served 500s on every data-backed page for hours while /health returned 200 and
20
- * boot logged "ORPC serverClient registered", because the empty router is
21
- * indistinguishable from a real one until something asks it for a model
22
- * (`a-component-may-not-report-a-state-it-has-not-verified`).
23
- *
24
- * A missing client is now a thrown error naming the boot step that should have
25
- * set it, so the failure is reported by the component that has the fault.
26
4
  */
27
5
 
28
6
  import { createRouterClient } from "@orpc/server";
@@ -1,38 +1,15 @@
1
1
  /**
2
- * @system server-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The pg-boss scheduled-job handler registry — `handler_slug` → handler
7
- * function. Built on `@teamscala/generic-registry` `createRegistry` (the mandated
8
- * primitive per `generic-registry-is-the-only-registry-pattern`); a hand-rolled
9
- * Map or a hardcoded switch in service-boot is the prohibited alternative.
10
- *
11
- * Handlers live in packages and self-register at boot (a boot hook / package
12
- * subscriber shape) by calling `registerScheduledJobHandler`. `resolveHandler`
13
- * in `pg-boss-mount.ts` reads this registry to dispatch a firing `scheduled_job`
14
- * row to its registered function.
15
4
  */
16
5
  import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
17
6
  import { createRegistry } from "@teamscala/generic-registry/create-registry";
18
7
 
19
8
  export type PgBossJobHandler = (ctx: {
20
9
  config: ScheduledJobConfig;
21
- /** Per-job data payload from a one-shot `boss.send()` — absent for scheduled jobs. */
22
10
  jobData?: unknown;
23
11
  }) => Promise<unknown> | unknown;
24
12
 
25
13
  export const pgBossHandlerRegistry = createRegistry<PgBossJobHandler>(
26
14
  "pg-boss-handlers",
27
15
  );
28
-
29
- /**
30
- * Register a handler for a `scheduled_job.handler_slug`. Idempotent on re-import
31
- * (createRegistry.register overwrites silently — last registration wins).
32
- */
33
- export function registerScheduledJobHandler(
34
- slug: string,
35
- handler: PgBossJobHandler,
36
- ): void {
37
- pgBossHandlerRegistry.register(slug, handler);
38
- }
@@ -1,13 +1,6 @@
1
1
  /**
2
- * @system server-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Mounts pg-boss for a backend service. Reads all scheduled_job registry
7
- * entries scoped to the current service from the registry cache, registers
8
- * pg-boss workers per queue_name, and schedules cron/interval jobs.
9
- * Per Constitution Rule 6: pg-boss is the single mechanism for all
10
- * application background work.
11
4
  */
12
5
 
13
6
  import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
@@ -21,25 +14,8 @@ import type { ScheduleOptions } from "pg-boss";
21
14
  import { PgBoss } from "pg-boss";
22
15
  import { pgBossHandlerRegistry } from "./pg-boss-handler-registry.ts";
23
16
 
24
- // Handlers register via their package's own boot hook / subscriber import
25
- // (package-subscriber shape) — no bundled copies. The mount resolves each
26
- // row's handler_slug against this registry at dispatch time.
27
17
 
28
18
 
29
- /**
30
- * Module-level reference to the mounted PgBoss instance — set by mountPgBoss on
31
- * successful start. Exposed via getPgBoss() so ORPC procedures (and other
32
- * in-process callers) can submit one-shot jobs via boss.send() without a second
33
- * PgBoss connection. Null before mount or when mountPgBoss found no scheduled
34
- * jobs for this service (boss never created).
35
- */
36
- let pgBossInstance: PgBoss | null = null;
37
-
38
- /** Returns the mounted PgBoss instance, or null if pg-boss is not running on this service. */
39
- export function getPgBoss(): PgBoss | null {
40
- return pgBossInstance;
41
- }
42
-
43
19
  export interface PgBossMountContext {
44
20
  databaseUrl: string;
45
21
  serviceSlug: string;
@@ -66,9 +42,6 @@ function belongsToService(
66
42
  const config = entry.config as Record<string, unknown>;
67
43
  const handlerSlug = config.handler_slug as string | undefined;
68
44
  if (!handlerSlug) return false;
69
- // Explicit row-driven targeting takes precedence — a universal boot-package
70
- // handler (e.g. one in @teamscala/service-boot, which no service declares in
71
- // server_packages) cannot be scoped by the server_package prefix match.
72
45
  const targetService = config.service_slug as string | undefined;
73
46
  if (targetService) return targetService === serviceSlug;
74
47
  return packages.some(
@@ -120,50 +93,13 @@ export async function mountPgBoss(
120
93
  const log = getAppLogger();
121
94
  const jobs: MountedJob[] = [];
122
95
 
123
- // pg-boss connects through pg, whose sslmode dialect differs from the
124
- // shared URL's libpq spelling — route through the canonical rewrite.
125
- // Config object (not a bare connectionString) so the internal pool is
126
- // bounded + tagged for the ~220ms cross-internet dev→Railway RTT.
127
- // POLL-ONLY, EXPLICITLY. pg-boss v12 dispatches by polling (SELECT .. FOR
128
- // UPDATE SKIP LOCKED inside a transaction; its locks are
129
- // pg_advisory_xact_lock — transaction-scoped, so a transaction pooler
130
- // cannot leak them); LISTEN/NOTIFY is a supplier-documented latency hint
131
- // whose registration dies on a transaction-pooler backend reassignment
132
- // (measured 2026-09-14: the opted-in sync worker boss logged
133
- // `LISTEN/NOTIFY channel registration was lost` every log window).
134
- // attorney.js defaults useListenNotify to false in 12.29.0 (pinned),
135
- // 12.30.0 (deployed) and 12.31.1 (latest) — all three read directly —
136
- // and spelling it here keeps that contract explicit so a pg-boss bump
137
- // cannot silently re-introduce a LISTEN dependency
138
- // (close-the-session-mode-door, 2026-09-14). The connection dials the
139
- // injected DATABASE_URL — the transaction pooler — reconciling the pg
140
- // sslmode dialect; the mount adds no endpoint derivation of its own.
141
96
  const boss = new PgBoss({
142
97
  useListenNotify: false,
143
98
  connectionString: pgSslCompatUrl(ctx.databaseUrl),
144
99
  application_name: `pgboss:${ctx.serviceSlug}`,
145
- // ONE connection, not three: pg-boss is a sibling pool that sits OUTSIDE
146
- // the Prisma budget, and with ~46 declared holders the fleet saturated
147
- // at 304/300 (2026-08-27) partly on 3-connection pg-boss pools that had
148
- // no idle timeout. At max:1 jobs serialize into a queue — the sanctioned
149
- // background degradation (`a-per-consumer-pool-max-is-not-a-bound-on-the-server`:
150
- // background work degrades to a queue, interactive work degrades to an
151
- // outage). The sibling total is declared in
152
- // config/db-pool-sizing.perServiceOverhead (pg-boss 1; the LISTEN allowance is gone with poll-only).
153
100
  max: 1,
154
- // Generous connection-acquisition budget for the cross-internet link so
155
- // a slow TLS handshake under sustained load doesn't terminate the conn.
156
101
  connectionTimeoutMillis: 30_000,
157
102
  });
158
- // crash-isolation: pg-boss's manager emits 'error' on a worker-connection
159
- // failure (a proxy-killed idle conn under sustained cross-internet writes).
160
- // With NO listener, EventEmitter THROWS on emit('error'), the worker's
161
- // promise rejects unhandled, and Bun exits the process — the scala-extract
162
- // restart-storm of 2026-07-19. The listener makes it a logged, recovered
163
- // event. This is the pg-boss-owned connection, the sibling of @teamscala/db's
164
- // application-pool pool.on('error') handler (the app pool alone did not
165
- // cover pg-boss's separate pool). Registered before start() so a boot-time
166
- // error is caught too.
167
103
  boss.on("error", (err) => {
168
104
  log.warn(`[pg-boss:${ctx.serviceSlug}] recovered error event (process kept alive)`, {
169
105
  error: err instanceof Error ? err.message : String(err),
@@ -172,21 +108,10 @@ export async function mountPgBoss(
172
108
  boss.on("warning", (w) => {
173
109
  log.warn(`[pg-boss:${ctx.serviceSlug}] warning`, { warning: String(w) });
174
110
  });
175
- // start() MUST precede work()/schedule() — it opens the DB connection;
176
- // calling schedule() before start() crashes the timekeeper with
177
- // "Database not opened. Call open() before executing SQL." (pg-boss v12)
178
111
  await boss.start();
179
- pgBossInstance = boss;
180
112
 
181
113
  for (const entry of scheduledEntries) {
182
- // crash-isolation: a malformed scheduled_job row degrades to "this job
183
- // unavailable" (logged + event-log row), NEVER a boot-crash. A bare
184
- // pg-boss assert reaching boot is the violation.
185
114
  let config: ScheduledJobConfig;
186
- // validateRow returns a RESULT WRAPPER ({ok:true,value}|{ok:false,issues}),
187
- // never the config itself — measured 2026-09-16: assigning the wrapper as
188
- // the config left queue_name/handler_slug undefined and silently skipped
189
- // EVERY row on services holding db-validation 0.1.289+.
190
115
  const validated = validateRow("scheduled_job", entry.config);
191
116
  if (!validated.ok) {
192
117
  const reason = validated.issues
@@ -217,19 +142,12 @@ export async function mountPgBoss(
217
142
  const queueName = config.queue_name;
218
143
 
219
144
  try {
220
- // pg-boss v12 requires the queue to exist before work()/schedule()
221
- // dispatch against it ("Queue X does not exist") — createQueue's
222
- // underlying SQL is ON CONFLICT DO NOTHING, so calling it on every
223
- // boot is a safe idempotent assert, never a duplicate-creation error.
224
145
  await boss.createQueue(queueName);
225
146
  await boss.work(queueName, async (jobs) => {
226
147
  let result: unknown;
227
148
  for (const job of jobs) {
228
149
  result = await resolveHandler(config.handler_slug, config, job.data);
229
150
  }
230
- // pg-boss stores the work handler return as job output when
231
- // exactly one job was processed (manager.js complete()). For
232
- // batch/scheduled jobs the return is not stored.
233
151
  return jobs.length === 1 ? result : undefined;
234
152
  });
235
153
 
@@ -267,15 +185,6 @@ export async function mountPgBoss(
267
185
  });
268
186
  }
269
187
 
270
- // A SUCCESSFUL mount emitted nothing until now, while every failure
271
- // path above recorded an event — so the one state you most need to
272
- // confirm ("is this job actually scheduled on this service?") was the
273
- // only one with no evidence. Combined with resolveHandler THROWING
274
- // into boss.work() rather than warning, that made "never scheduled"
275
- // and "fired and threw" externally identical, and cost a
276
- // scheduled_job relocation that had to be reverted undiagnosed on
277
- // 2026-07-30. Emitting on success makes the mount set queryable
278
- // (event-log-is-the-only-durable-event-log).
279
188
  const mounted = jobs[jobs.length - 1];
280
189
  recordEvent({
281
190
  kind: "pgboss.job.mounted",
@@ -289,10 +198,6 @@ export async function mountPgBoss(
289
198
  },
290
199
  });
291
200
  } catch (err) {
292
- // crash-isolation for the mount path: a work()/schedule() failure
293
- // (e.g. DB not open, queue conflict) degrades to "this job unavailable"
294
- // (logged + event-log row), NEVER a boot-crash. Same treatment as the
295
- // row-validation guard above.
296
201
  const reason = err instanceof Error ? err.message : String(err);
297
202
  log.error(
298
203
  `[pg-boss-mount:${ctx.serviceSlug}] scheduled_job "${entry.slug}" failed to mount (work/schedule) — skipping`,
@@ -306,16 +211,6 @@ export async function mountPgBoss(
306
211
  }
307
212
  }
308
213
 
309
- // Reconcile orphaned cron schedules: unschedule any pg-boss schedule that no longer corresponds to an ACTIVE registry row. Deactivating/deleting a
310
- // scheduled_job (or sync_job_handler) previously left its cron lingering in pgboss.schedule, firing indefinitely — 2 stale web-extractor schedules
311
- // found 2026-07-27, still scraping every 3/15min months after the job was meant to be one-off. Deactivation must actually stop the cron. A schedule is
312
- // "backed" iff ANY active registry row's slug OR queue_name equals its name — this covers scheduled_job AND sync_job_handler (and any future
313
- // schedule-owning type) without hardcoding the type list, so live sync jobs (zoho_assist_sync, trackabi, …) are never touched. Runs on every pg-boss
314
- // mount and reconciles GLOBALLY: an orphan is removed regardless of which
315
- // service created it, since a cron with no active backing row is drift by
316
- // definition. A service with zero active scheduled jobs early-returns above
317
- // and never reaches here — scala-loga (7 cron jobs) is the reliable
318
- // reconciler, but any service-with-jobs that boots cleans the full set.
319
214
  try {
320
215
  const backedScheduleNames = new Set(
321
216
  allEntries.flatMap((e) => {
@@ -342,9 +237,6 @@ export async function mountPgBoss(
342
237
  });
343
238
  }
344
239
  } catch (err) {
345
- // crash-isolation: a reconciliation failure degrades to "orphans not
346
- // cleaned this boot" (logged), never a boot-crash — reconciled on the
347
- // next successful mount.
348
240
  log.warn(`[pg-boss:${ctx.serviceSlug}] schedule reconciliation failed`, {
349
241
  error: err instanceof Error ? err.message : String(err),
350
242
  });