@ultimat3/cli 23.0.0 → 25.0.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 (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
@@ -1,18 +1,8 @@
1
- // `notify.inboxReadRetentionMs` and `notify.inboxUnreadRetentionMs`, read out of the app's own
2
- // `app.config.ts`. The sibling of `app-auth.ts`'s `loadSignInPath` and `runtime-cache.ts`'s
3
- // `loadCacheTiers`, and structural for the same reason: `defineConfig` returns a plain object, so
4
- // a config that resolved through an older core simply has no `notify` section.
5
- //
6
- // WHY A LOADER AND NOT A BOOT ARGUMENT: `startServices` has no `AppConfig` — the app's modules
7
- // import after it, which is the same reason `configureAuthLimiters` takes a factory. A key read
8
- // here is a key read from the file the operator edited, per boot.
1
+ // `notify.inboxReadRetentionMs` and `notify.inboxUnreadRetentionMs`, out of the config
2
+ // `startServices` loads once (`app-config-load.ts`) — before the app's modules import, so the window
3
+ // is read from the file the operator edited, per boot.
9
4
 
10
- // why: Bun ships no path-joining API — `Object.keys(Bun)` has `file`, `write`, `Glob`,
11
- // `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
12
- import { join } from 'node:path';
13
- import { INBOX_RETENTION_KEYS } from '@ultimat3/core';
14
- import { APP_CONFIG_EXPORT } from './app-auth';
15
- import { APP_CONFIG_FILE } from './app-root';
5
+ import type { AppConfig } from '@ultimat3/core';
16
6
 
17
7
  /**
18
8
  * The two windows in milliseconds, each `undefined` where the app named none.
@@ -32,38 +22,12 @@ export const NO_INBOX_RETENTION: InboxRetention = Object.freeze({
32
22
  unreadMs: undefined,
33
23
  });
34
24
 
35
- const isRecord = (value: unknown): value is Record<string, unknown> =>
36
- typeof value === 'object' && value !== null;
37
-
38
25
  /**
39
- * Re-screened here rather than trusted from `defineConfig`. That validator runs when the app
40
- * IMPORTS its config, and this loader imports the module for its export — so a config object
41
- * assembled by hand, or one that resolved through a core too old to validate the section, reaches
42
- * this line unchecked. A window that is not a positive finite number reads as absent: refusing
43
- * would take the sweep over the other four framework tables down with it, and the whole point of
44
- * the key is that not sweeping is a legal state.
26
+ * Core's validator (through the loader) refuses a window that is not a positive finite number, so
27
+ * a `NaN` cutoff — every row older than it, the inbox emptied on the first pass — cannot reach here.
45
28
  */
46
- const windowOf = (section: Record<string, unknown>, key: string): number | undefined => {
47
- const value = section[key];
48
- if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
49
- return value;
50
- };
51
-
52
- export async function loadInboxRetention(root: string): Promise<InboxRetention> {
53
- const configPath = join(root, APP_CONFIG_FILE);
54
- // `Bun.file(p).exists()` rather than `existsSync`: this function is already async, so the
55
- // `node:fs` import buys nothing here — and an import with nothing to say for itself is what
56
- // `bun run node-imports` refuses.
57
- if (!(await Bun.file(configPath).exists())) return NO_INBOX_RETENTION;
58
- const module = (await import(configPath)) as Record<string, unknown>;
59
- const config = module[APP_CONFIG_EXPORT];
60
- if (!isRecord(config)) return NO_INBOX_RETENTION;
61
- const notify = config['notify'];
62
- if (!isRecord(notify)) return NO_INBOX_RETENTION;
63
- // Keyed off core's own list rather than two string literals here, so the two names exist in one
64
- // place. What actually stops a third window being declared-and-never-read is
65
- // `bun run scripts/config-readers.ts` — every leaf key of `AppConfig` needs a reader or a pinned
66
- // reason, and it is the guard whose own header calls that "the framework's most repeated defect".
67
- const [readKey, unreadKey] = INBOX_RETENTION_KEYS;
68
- return { readMs: windowOf(notify, readKey), unreadMs: windowOf(notify, unreadKey) };
29
+ export function inboxRetentionOf(config: AppConfig | undefined): InboxRetention {
30
+ const notify = config?.notify;
31
+ if (notify === undefined) return NO_INBOX_RETENTION;
32
+ return { readMs: notify.inboxReadRetentionMs, unreadMs: notify.inboxUnreadRetentionMs };
69
33
  }
@@ -35,7 +35,7 @@ export interface RuntimeOverrides {
35
35
  readonly mail?: MailDriver;
36
36
  /**
37
37
  * Replaces the `NATS_URL` selection. Already connected when it arrives, and NOT closed by
38
- * `stop()`: whoever built it owns its socket, exactly as `createServer` does not close a
38
+ * `stop()`: whoever built it owns its socket, exactly as `httpServer` does not close a
39
39
  * `rateLimitStore` it was handed.
40
40
  */
41
41
  readonly transport?: Transport;
@@ -53,12 +53,12 @@ export interface RuntimeOverrides {
53
53
  */
54
54
  readonly rateLimitStore?: RateLimitStore;
55
55
  /**
56
- * Where regenerated ISR pages live. Omitted, `createIsrController` keeps a per-process memory
56
+ * Where regenerated ISR pages live. Omitted, `isrController` keeps a per-process memory
57
57
  * store — twelve replicas then hold twelve of them, and a purge tag reaches one twelfth of the
58
58
  * fleet.
59
59
  */
60
60
  readonly isrStore?: IsrStore;
61
- /** Prepended to the pipeline by `createServer`, which `startRoles` never passed one. */
61
+ /** Prepended to the pipeline by `httpServer`, which `startRoles` never passed one. */
62
62
  readonly middleware?: readonly Middleware[];
63
63
  /**
64
64
  * Plain HTTP routes at paths no primitive projects to — `/.well-known/oauth-authorization-server`,
@@ -69,7 +69,7 @@ export interface RuntimeOverrides {
69
69
  * answers a document, and a protocol that fixes its own error shape is neither.
70
70
  */
71
71
  readonly routes?: readonly Route[];
72
- /** The `/media/*` transform. Omitted, `builtinImageDriver` — core's PNG/JPEG pipeline. */
72
+ /** The `/media/*` transform. Omitted, `builtinImageDriver` — core's PNG/JPEG/WebP pipeline. */
73
73
  readonly images?: ImageTransformDriver;
74
74
  /**
75
75
  * Who is dialling the `sync` node. Omitted, the app's own `configureAuthenticator()` is adapted
@@ -1,10 +1,11 @@
1
1
  // The retention sweep this boot owns: the framework tables that grow with traffic, the `purge()`
2
2
  // job that empties them and the `task` that fires it hourly.
3
3
  //
4
- // FIVE TARGETS over six tables, `As of 2026-08-27` — `x_idempotency`, `x_rate_limit`, the two
5
- // `x_auth` tables under one target, `x_notify_deliveries` and `x_notify_inbox`. Counted nowhere in
6
- // prose but here: this header said "three" for two releases after the notify tables joined the
7
- // boot's DDL, which is how `x_notify_inbox` became the one framework table nothing swept.
4
+ // SEVEN TARGETS over seven tables, `As of 2026-10-02` — `x_idempotency`, `x_rate_limit`, the
5
+ // `x_auth` limiter's table, `x_notify_deliveries`, `x_notify_inbox`, `x_notify_digests` and
6
+ // `x_job_events`. Counted nowhere in prose but here: this header said "three" for two releases
7
+ // after the notify tables joined the boot's DDL, which is how `x_notify_inbox` became the one
8
+ // framework table nothing swept.
8
9
  //
9
10
  // WHY here and not in the packages that own the tables: `postgresIdempotencyStore` (tier 3),
10
11
  // `postgresRateLimitStore` (tier 2) and `postgresAuthLimiter` (tier 2) cannot see each other and
@@ -20,9 +21,16 @@
20
21
  import type { PostgresIdempotencyStore } from '@ultimat3/action';
21
22
  import { purgeAuthLimits } from '@ultimat3/auth';
22
23
  import type { PostgresRateLimitStore } from '@ultimat3/http';
23
- import type { JobHandle, PurgeInput, PurgeTarget } from '@ultimat3/jobs';
24
- import { DEFAULT_PURGE_CRON, getJob, getTask, purge, task } from '@ultimat3/jobs';
25
- import { purgeNotifyDeliveries, purgeNotifyInbox } from '@ultimat3/notify';
24
+ import type { EventBus, JobHandle, PurgeInput, PurgeTarget } from '@ultimat3/jobs';
25
+ import {
26
+ DEFAULT_PURGE_CRON,
27
+ eventsPurgeTarget,
28
+ getJob,
29
+ getTask,
30
+ purge,
31
+ task,
32
+ } from '@ultimat3/jobs';
33
+ import { purgeNotifyDeliveries, purgeNotifyDigests, purgeNotifyInbox } from '@ultimat3/notify';
26
34
  import type { InboxRetention } from './runtime-notify-retention';
27
35
  import { NO_INBOX_RETENTION } from './runtime-notify-retention';
28
36
 
@@ -32,7 +40,7 @@ export const PURGE_JOB_NAME = 'x.purge';
32
40
  export const PURGE_TASK_NAME = 'x.purge.hourly';
33
41
 
34
42
  /**
35
- * The two stores this boot BUILT, handed over rather than rebuilt here. A second
43
+ * The stores this boot BUILT, handed over rather than rebuilt here. A second
36
44
  * `postgresIdempotencyStore({ executor })` would sweep on the default window even where the boot
37
45
  * had configured another — two answers to "how long is a record kept", and the shorter one
38
46
  * deletes reservations a retry is still entitled to.
@@ -45,15 +53,22 @@ export interface RetentionStores {
45
53
  readonly idempotency: PostgresIdempotencyStore;
46
54
  readonly rateLimit: PostgresRateLimitStore;
47
55
  /**
48
- * The app's own answer to "how long is an unread message kept", loaded from `app.config.ts` by
49
- * `loadInboxRetention`. Absent windows mean the inbox is never swept, which is the default and a
50
- * decision rather than an oversight — see `NotifyConfig` in `@ultimat3/core`.
56
+ * The bus this boot installed for `step.waitForEvent`. REQUIRED: `x_job_events` takes a row per
57
+ * publish and `find` only filters an expired one, so a boot that left it out is the table
58
+ * growing for the life of the deployment — which it did until this member existed.
59
+ */
60
+ readonly events: EventBus;
61
+ /**
62
+ * The app's own answer to "how long is an unread message kept", read off the config
63
+ * `startServices` loads (`inboxRetentionOf`). Absent windows mean the inbox is never swept,
64
+ * which is the default and a decision rather than an oversight — see `NotifyConfig` in
65
+ * `@ultimat3/core`.
51
66
  */
52
67
  readonly inboxRetention?: InboxRetention | undefined;
53
68
  }
54
69
 
55
70
  /**
56
- * The two notify tables, and neither store is in `RetentionStores` — deliberately, and for the
71
+ * The three notify tables, and no store is in `RetentionStores` — deliberately, and for the
57
72
  * reason `authTarget` is not either. `setNotifyStores` is an APP's boot line and runs when the
58
73
  * app's modules import, which is AFTER this install; `framework-schema.ts` says so where it
59
74
  * applies the DDL "whether or not this boot calls `setNotifyStores`". So the sweep can only ask,
@@ -79,6 +94,12 @@ function notifyTargets(retention: InboxRetention): readonly PurgeTarget[] {
79
94
  retention.unreadMs === undefined ? undefined : new Date(nowMs - retention.unreadMs),
80
95
  }),
81
96
  },
97
+ {
98
+ name: 'x_notify_digests',
99
+ // The job's clock, and the store's own window (`postgresDigestStore({ retentionMs })`, a week
100
+ // by default) — a closed window only its dead-lettered flush would ever have drained.
101
+ purgeExpired: (nowMs: number): Promise<number> => purgeNotifyDigests(nowMs),
102
+ },
82
103
  ];
83
104
  }
84
105
 
@@ -94,14 +115,13 @@ let installed: readonly PurgeTarget[] = [];
94
115
  let sweep: JobHandle<PurgeInput> | undefined;
95
116
 
96
117
  /**
97
- * `x_auth_failures` and `x_auth_lockouts` together, under the prefix they share: one target,
98
- * because `purgeAuthLimits()` clears both in one statement and two targets under one pair of
99
- * tables would be a second delete that removes nothing.
118
+ * `x_auth_lockouts` — every credential bucket, one row per key. The target keeps the `x_auth`
119
+ * name it has always reported under: it is what a dashboard and `PurgeReport.swept` already key on.
100
120
  */
101
121
  const authTarget: PurgeTarget = {
102
122
  name: 'x_auth',
103
123
  // No `nowMs`: a limiter built through the seam holds the clock its host handed it, which is the
104
- // clock every `at_ms` in those tables was written from.
124
+ // clock every instant in that table was written from.
105
125
  purgeExpired: () => purgeAuthLimits(),
106
126
  };
107
127
 
@@ -121,6 +141,8 @@ function retentionTargets(stores: RetentionStores): readonly PurgeTarget[] {
121
141
  },
122
142
  authTarget,
123
143
  ...notifyTargets(stores.inboxRetention ?? NO_INBOX_RETENTION),
144
+ // Its own clock, and so no `nowMs`: every instant in that table is the database's.
145
+ eventsPurgeTarget(stores.events),
124
146
  ];
125
147
  }
126
148
 
@@ -7,32 +7,34 @@
7
7
  import {
8
8
  type PostgresIdempotencyStore,
9
9
  postgresIdempotencyStore,
10
+ requestDeadlineMs,
10
11
  resetIdempotency,
11
12
  setIdempotencyStore,
12
13
  } from '@ultimat3/action';
13
14
  import type { DbClient, PgliteClient, PostgresClient, SqlFragment } from '@ultimat3/db';
14
15
  import {
15
- createPgliteClient,
16
- createPostgresClient,
16
+ baseClient,
17
17
  currentTx,
18
+ pgliteClient,
18
19
  pgliteDataDir,
19
- raw,
20
+ postgresClient,
20
21
  setDbClient,
21
22
  } from '@ultimat3/db';
22
23
  import type { Tx } from '@ultimat3/entity';
23
24
  import type { EventBus, JobDriver, OutboxStore, PgExecutor } from '@ultimat3/jobs';
24
25
  import {
25
- createJobsFacade,
26
- createPgDriver,
27
- createPgEventBus,
28
- createPgOutboxStore,
26
+ outboxJobsFacade,
27
+ postgresEventBus,
28
+ postgresJobDriver,
29
+ postgresOutboxStore,
30
+ resetEventBus,
29
31
  resetJobDriver,
30
32
  resetJobsFacade,
31
33
  setEventBus,
32
34
  setJobDriver,
33
35
  setJobsFacade,
34
36
  } from '@ultimat3/jobs';
35
- import { applyFrameworkSchema } from './framework-schema';
37
+ import { applyLockedSchema, verifySchema } from './framework-schema-apply';
36
38
  import { appExtensions } from './migration-extensions';
37
39
  import type { DevServices } from './runtime-bindings';
38
40
  import type { RuntimeOverrides } from './runtime-overrides';
@@ -97,11 +99,11 @@ export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
97
99
  // Linked at boot, from the app's own migrations: PGlite cannot `create extension` one it
98
100
  // was not handed, and the schema dump's scratch replay already asks this same reader — an
99
101
  // app that replayed in the gate and failed in `x dev` was the two disagreeing.
100
- createPgliteClient({
102
+ pgliteClient({
101
103
  dataDir: pgliteDataDir(binding.url),
102
104
  extensions: () => appExtensions(services.root),
103
105
  })
104
- : createPostgresClient({ url: binding.url });
106
+ : postgresClient({ url: binding.url });
105
107
  const attached = attachReplica(client, replicaUrlFor(binding, env));
106
108
  setDbClient(attached.client);
107
109
  return { client, replica: attached.replica };
@@ -134,10 +136,18 @@ export function pgExecutorFor(client: DbClient): PgExecutor {
134
136
  * `@ultimat3/notify` because a package that holds no database dependency cannot apply its own
135
137
  * schema — the same reason `SQL_JOBS_TABLE` is applied by the boot.
136
138
  */
137
- async function applySchema(client: DevDbClient): Promise<void> {
138
- await applyFrameworkSchema((statement) => client.execute(raw(statement)));
139
- }
139
+ /**
140
+ * `'apply'` — `ROLE=migrate`, `x dev` and every CLI command — runs the DDL under the migration
141
+ * lock with a bounded `lock_timeout` and stamps the build. `'verify'` — a serving role on an
142
+ * external database — runs none: an `alter table` queued behind one open transaction stalled every
143
+ * enqueue in the fleet on each pod start, and racing `create table`s were a first-deploy `23505`.
144
+ */
145
+ export type SchemaMode = 'apply' | 'verify';
140
146
 
147
+ async function applySchema(client: DevDbClient, mode: SchemaMode): Promise<void> {
148
+ if (mode === 'verify') await verifySchema(client);
149
+ else await applyLockedSchema(client);
150
+ }
141
151
  /**
142
152
  * The dev queue is the real Postgres queue on the embedded Postgres — claiming, leases and the
143
153
  * one-live-job-per-key index all behave here exactly as in production. A memory queue in dev
@@ -169,23 +179,31 @@ async function applySchema(client: DevDbClient): Promise<void> {
169
179
  async function startJobs(
170
180
  client: DevDbClient,
171
181
  replica: PostgresClient | undefined,
172
- overrides?: RuntimeOverrides,
182
+ overrides: RuntimeOverrides | undefined,
183
+ schema: SchemaMode,
173
184
  ): Promise<RunningQueue> {
174
- await applySchema(client);
185
+ await applySchema(client, schema);
175
186
  const executor = pgExecutorFor(client);
176
- const driver = overrides?.jobs ?? createPgDriver({ executor });
187
+ const driver = overrides?.jobs ?? postgresJobDriver({ executor });
177
188
  setJobDriver(driver);
178
- const outbox = createPgOutboxStore({
189
+ const outbox = postgresOutboxStore({
179
190
  executor,
180
191
  // The open transaction is a client on its own connection; the `Tx` token is not that object.
181
192
  txExecutor: () => pgExecutorFor(currentTx() ?? client),
182
193
  });
183
194
  setJobsFacade(
184
- createJobsFacade({ store: outbox, driver }, () => currentTx() as unknown as Tx | undefined),
195
+ outboxJobsFacade({ store: outbox, driver }, () => currentTx() as unknown as Tx | undefined),
185
196
  );
186
- const events = createPgEventBus({ executor });
197
+ const events = postgresEventBus({ executor });
187
198
  setEventBus(events);
188
- const idempotency = postgresIdempotencyStore({ executor });
199
+ const idempotency = postgresIdempotencyStore({
200
+ executor,
201
+ // `baseClient`, read per call and never `client`: a transaction's origin is the AMBIENT client
202
+ // `startDb` installed, which is the replicated pair when a standby is configured.
203
+ origin: baseClient,
204
+ // The app's own `requestTimeoutMs`, read per reservation — it is declared after this boot.
205
+ reclaimAfterMs: requestDeadlineMs,
206
+ });
189
207
  setIdempotencyStore(idempotency);
190
208
  return {
191
209
  db: client,
@@ -204,7 +222,8 @@ async function startJobs(
204
222
  * startup entirely, so every query it makes fails on a connection this process already dropped.
205
223
  *
206
224
  * The facade goes with the driver for the same reason: an enqueue routed through a store bound to
207
- * a closed client is a staged row nothing will ever publish.
225
+ * a closed client is a staged row nothing will ever publish — and the event bus with both, or a
226
+ * `publishEvent` after `stop()` runs over the closed pool.
208
227
  */
209
228
  async function releaseQueue(
210
229
  db: DevDbClient,
@@ -215,6 +234,7 @@ async function releaseQueue(
215
234
  // holds this client, and the next command in this process would reserve keys over a closed one.
216
235
  resetIdempotency();
217
236
  resetJobsFacade();
237
+ resetEventBus();
218
238
  resetJobDriver();
219
239
  setDbClient(undefined);
220
240
  await jobs?.close?.();
@@ -239,13 +259,14 @@ export async function startQueue(
239
259
  * this file reads it: see `startDb` for what two readers of one question cost.
240
260
  */
241
261
  env: ReplicaEnv = process.env,
262
+ schema: SchemaMode = 'apply',
242
263
  ): Promise<RunningQueue> {
243
264
  const { client: db, replica } = startDb(services, env);
244
265
  try {
245
266
  // Pay the Postgres boot here, so the first request is not the slow one and a broken database
246
267
  // fails at boot rather than on some later query.
247
268
  await db.ping();
248
- return await startJobs(db, replica, overrides);
269
+ return await startJobs(db, replica, overrides, schema);
249
270
  } catch (error) {
250
271
  // `db.ping()` or `startJobs` is where a broken database is supposed to fail. Without this,
251
272
  // the caller exits holding the PGlite lock and the ambient accessors, and nothing is left to
@@ -1,55 +1,21 @@
1
- // `realtime.enabled`, `realtime.transport` and `realtime.urlEnv`, read out of the app's own
2
- // `app.config.ts` for the boot. The sibling of `runtime-notify-retention.ts` and `runtime-cache.ts`, and
3
- // structural for their reason: `startServices` holds no `AppConfig`, and `defineConfig` returns a
4
- // plain object. Until 22.0.0 no boot read any of the three and `NATS_URL` alone decided the bus.
1
+ // The `realtime` section for the boot, out of the config `startServices` loads once
2
+ // (`app-config-load.ts`). Until 22.0.0 no boot read `enabled`, `transport` or `urlEnv` and
3
+ // `NATS_URL` alone decided the bus; the two per-actor caps are read by the `sync` role.
5
4
 
6
- // why: Bun ships no path-joining API — `Object.keys(Bun)` has `file`, `write`, `Glob`,
7
- // `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
8
- import { join } from 'node:path';
9
- import { ConfigInvalidError, type RealtimeConfig, type RealtimeTransport } from '@ultimat3/core';
10
- import { APP_CONFIG_EXPORT } from './app-auth';
11
- import { APP_CONFIG_FILE } from './app-root';
5
+ import type { AppConfig, RealtimeConfig } from '@ultimat3/core';
12
6
 
13
- /** Core's `defaults()` for the section, restated as the answer for a config that names none. */
7
+ /** Core's `defaults()` for the section — the answer for a root with no `app.config.ts`. */
14
8
  export const REALTIME_DEFAULTS: RealtimeConfig = Object.freeze({
15
9
  enabled: true,
16
10
  transport: 'memory',
17
11
  urlEnv: undefined,
12
+ maxSubscriptionsPerActor: undefined,
13
+ maxSocketsPerActor: undefined,
18
14
  });
19
15
 
20
- const TRANSPORTS: readonly RealtimeTransport[] = ['memory', 'nats'];
21
-
22
- const isRecord = (value: unknown): value is Record<string, unknown> =>
23
- typeof value === 'object' && value !== null;
24
-
25
- /** A transport this boot can build, or a refusal — never a guess at a bus. */
26
- function transportOf(section: Record<string, unknown>): RealtimeTransport {
27
- const value = section['transport'];
28
- if (value === undefined) return REALTIME_DEFAULTS.transport;
29
- const known = TRANSPORTS.find((transport) => transport === value);
30
- if (known !== undefined) return known;
31
- throw new ConfigInvalidError({
32
- cause: `realtime.transport in ${APP_CONFIG_FILE} is not one of ${TRANSPORTS.join(', ')}`,
33
- fix: `set realtime: { transport: 'memory' } in ${APP_CONFIG_FILE}, or { transport: 'nats', urlEnv: 'NATS_URL' } for more than one node`,
34
- meta: { key: 'realtime.transport' },
35
- });
36
- }
37
-
38
16
  /**
39
- * The section as the boot obeys it. A key the file does not name keeps core's default, the same
40
- * per-key merge `defineConfig` performs, so the boot and the app's own config object agree.
17
+ * The section as the boot obeys it — `defineConfig`'s per-key merge, so the boot and the app's own
18
+ * config object agree, and a transport nothing builds is core's `X_CONFIG_INVALID`.
41
19
  */
42
- export async function loadRealtimeConfig(root: string): Promise<RealtimeConfig> {
43
- const path = join(root, APP_CONFIG_FILE);
44
- if (!(await Bun.file(path).exists())) return REALTIME_DEFAULTS;
45
- const module = (await import(path)) as Record<string, unknown>;
46
- const config = module[APP_CONFIG_EXPORT];
47
- const section = isRecord(config) ? config['realtime'] : undefined;
48
- if (!isRecord(section)) return REALTIME_DEFAULTS;
49
- const { enabled, urlEnv } = section;
50
- return {
51
- enabled: typeof enabled === 'boolean' ? enabled : REALTIME_DEFAULTS.enabled,
52
- transport: transportOf(section),
53
- urlEnv: typeof urlEnv === 'string' && urlEnv.trim() !== '' ? urlEnv.trim() : undefined,
54
- };
55
- }
20
+ export const realtimeConfigOf = (config: AppConfig | undefined): RealtimeConfig =>
21
+ config?.realtime ?? REALTIME_DEFAULTS;