@ultimat3/cli 16.0.0 → 18.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.
@@ -0,0 +1,81 @@
1
+ // Single responsibility: which tables `x db gen` must grant `REPLICA IDENTITY FULL`, read off the
2
+ // live queries' DECLARED `subscribes:` — and the refusal for a declared name no entity's table
3
+ // matches. This tier is the only one holding both registries, so it is the only one that can ask.
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+
7
+ /**
8
+ * The two fields this reads, and nothing else. Structurally satisfied by `QueryDescriptor` (whose
9
+ * `subscribes` is `null` when the read declared none) and by `@ultimat3/manifest`'s `QueryFact`
10
+ * (where it is absent instead) — one function over both spellings of one fact, rather than a
11
+ * projection per caller that could disagree about which reads are subscribed.
12
+ */
13
+ export interface SubscribingQuery {
14
+ readonly name: string;
15
+ readonly subscribes?: readonly string[] | null | undefined;
16
+ }
17
+
18
+ /** Enough of the app's own tables to act on, without printing a hundred of them into one line. */
19
+ const NAMED_IN_FIX = 10;
20
+
21
+ function offer(tables: ReadonlySet<string>): string {
22
+ const known = [...tables].sort();
23
+ if (known.length === 0) return 'this app declares no entity at all — x g entity Note body:text';
24
+ const shown = known.slice(0, NAMED_IN_FIX).join(', ');
25
+ return known.length > NAMED_IN_FIX ? `${shown} and ${known.length - NAMED_IN_FIX} more` : shown;
26
+ }
27
+
28
+ /**
29
+ * A live read declares a relation this app has no entity for.
30
+ *
31
+ * Refused rather than dropped, and that is the whole point of the check: `@ultimat3/db` keeps only
32
+ * the declared names an entity's table matches (`replica-identity.ts`), so an EXTRA name is
33
+ * discarded in silence — and `@ultimat3/query` has no table catalog, so its own `subscribes:`
34
+ * assertions cannot see one either. A typo therefore granted `REPLICA IDENTITY FULL` to nothing
35
+ * while its author read the declaration as granted, which is the failure #357 exists to end.
36
+ *
37
+ * The name goes in the `cause` and never into a command: it is a string from the app's own source,
38
+ * and the remedy is an edit to a field rather than anything to paste at a shell.
39
+ */
40
+ export class QuerySubscribesUnknownError extends UltimateError {
41
+ constructor(input: { query: string; table: string; tables: ReadonlySet<string> }) {
42
+ super({
43
+ code: 'X_QUERY_SUBSCRIBES_UNKNOWN',
44
+ cause:
45
+ `the query "${input.query}" declares subscribes: ["${input.table}"] and no entity ` +
46
+ 'declares a table with that name',
47
+ fix:
48
+ `edit subscribes: on the query "${input.query}" to name a table this app declares ` +
49
+ `(${offer(input.tables)}), or drop the name — x db gen grants REPLICA IDENTITY FULL to ` +
50
+ 'exactly the tables it lists, and would have granted it to nothing',
51
+ meta: { query: input.query, table: input.table },
52
+ });
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The tables to hand `GenerateOptions.replicaIdentityFull`, deduped and sorted.
58
+ *
59
+ * Sorted so the same app generates the same bytes whatever order its modules registered in — the
60
+ * rule `@ultimat3/db`'s own `pending()` states one layer down, kept here too because a caller that
61
+ * ordered by registration would put a diff in a file for nothing.
62
+ *
63
+ * Every name is checked against `tables` BEFORE any of them is returned: a run that emitted the
64
+ * good half and refused afterwards would leave an author with a migration that is right for one
65
+ * table and silently absent for the other.
66
+ */
67
+ export function replicaIdentityTables(
68
+ queries: readonly SubscribingQuery[],
69
+ tables: ReadonlySet<string>,
70
+ ): readonly string[] {
71
+ const wanted = new Set<string>();
72
+ for (const query of queries) {
73
+ for (const table of query.subscribes ?? []) {
74
+ if (!tables.has(table)) {
75
+ throw new QuerySubscribesUnknownError({ query: query.name, table, tables });
76
+ }
77
+ wanted.add(table);
78
+ }
79
+ }
80
+ return [...wanted].sort();
81
+ }
@@ -58,8 +58,20 @@ export function declaredUngeneratable(up: string): number {
58
58
  * the statement stays, and the next author to squash these migrations is told by the file itself
59
59
  * that regenerating it loses something. Re-declaring is available only for the statements an
60
60
  * entity can express — an enum is a text column plus a check invariant, which `x db gen` writes —
61
- * and never for `REPLICA IDENTITY FULL`, which nothing in the framework emits, so a `fix:` naming
62
- * only the second branch would be an instruction half its readers cannot carry out.
61
+ * so a `fix:` naming only the second branch would be an instruction some of its readers cannot
62
+ * carry out.
63
+ *
64
+ * `REPLICA IDENTITY FULL` used to be the example of a statement with no second branch at all, and
65
+ * it stopped being one on 2026-08-26 (#357): a live query DECLARES the relations it is patched
66
+ * from (`subscribes:`), `db-subscribes.ts` reads them off the registry the manifest is built from,
67
+ * and `x db gen` emits the ALTER and records it on the snapshot. So the re-declare branch now
68
+ * covers it too: declare `subscribes:` on the live query and regenerate.
69
+ *
70
+ * A committed `replica identity full`/`default` ALTER is NOT counted, `As of 2026-08-26`:
71
+ * `GENERATABLE_FORMS` carries the verb phrase, so a squash no longer loses it and no marker is
72
+ * owed for one. Measured on `examples/dummy/packages/db/migrations/0001_init.sql`: 7 found and 7
73
+ * declared before that entry, 5 and 5 after. `using index` and `nothing` are NOT on the list —
74
+ * the generator emits neither, so those stay hand-written and stay counted.
63
75
  */
64
76
  export class MigrationUngeneratableError extends UltimateError {
65
77
  constructor(input: { file: string; declared: number; statements: readonly string[] }) {
package/src/dev-assets.ts CHANGED
@@ -5,14 +5,9 @@
5
5
  // thing it does NOT decide is who may read a stored object: `/media` borrows that whole answer
6
6
  // from `dev-storage.ts`, because the same bytes are reachable through both.
7
7
 
8
- // `join` is `node:`-only by necessity: Bun exposes no path-join primitive, and `ICON_SOURCE` is
9
- // app-root-relative, so resolving it against the root is string work no `Bun.file` overload does.
10
- import { join } from 'node:path';
11
8
  import { probeImage } from '@ultimat3/core';
12
9
  import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
13
10
  import { applyCacheHeaders } from '@ultimat3/http';
14
- import type { IconPlan } from '@ultimat3/pwa';
15
- import { BuiltinImagePipeline, PwaIconMissingError, planIcons } from '@ultimat3/pwa';
16
11
  import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
17
12
  import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery } from '@ultimat3/seo';
18
13
  import type { ImageTransform, Storage, VariantFormat } from '@ultimat3/storage';
@@ -24,16 +19,13 @@ import {
24
19
  STORAGE_READ_PERMISSION,
25
20
  } from './dev-storage';
26
21
  import { faviconRoute } from './favicon';
27
-
28
- /**
29
- * The one source image every generated icon derives from. `x new` scaffolds it, `x doctor` checks
30
- * it and this file reads it — one constant, because a second spelling is an app that passes the
31
- * diagnostic and still serves no icons. PNG, not SVG: core's pipeline decodes PNG and JPEG only.
32
- */
33
- export const ICON_SOURCE = 'apps/web/site/icon.png';
34
-
35
- /** Where `planIcons` writes, and therefore the paths the generated web manifest names. */
36
- export const ICON_BASE_PATH = '/icons';
22
+ // The icon matrix's source, its base path and its renderer live in their own module so that
23
+ // `pwa-artifacts.ts` — which this file imports for the manifest route — can reach them without
24
+ // importing this one back. A cycle between the two would be the manifest and the icons it names
25
+ // resolving through each other.
26
+ import { iconPlan, iconRenderer } from './icon-assets';
27
+ import type { PwaArtifacts } from './pwa-artifacts';
28
+ import { pwaManifestRoute } from './pwa-artifacts';
37
29
 
38
30
  /**
39
31
  * Storage-backed images. `responsiveImage({ src: '/media/<key>' })` mints its variants under it.
@@ -171,50 +163,18 @@ async function mediaResponse(
171
163
  return imageResponse(read.bytes, read.object.contentType, mediaCache(key));
172
164
  }
173
165
 
174
- /**
175
- * Rendered once per process, not per request: the fourteen matrix entries are pure functions of
176
- * one source file, and re-encoding a 512px PNG on every hit would be work no caller can observe.
177
- */
178
- function iconRenderer(root: string): (plan: IconPlan, path: string) => Promise<Uint8Array> {
179
- const pipeline = new BuiltinImagePipeline();
180
- const rendered = new Map<string, Promise<Uint8Array>>();
181
- const sourceBytes = async (): Promise<Uint8Array> => {
182
- const file = Bun.file(join(root, ICON_SOURCE));
183
- if (!(await file.exists())) {
184
- throw new PwaIconMissingError(
185
- `${ICON_SOURCE} does not exist, so every icon the web manifest declares is unbacked and ` +
186
- 'the app is not installable',
187
- // The same edit `x doctor` reports for the same condition, in `@ultimat3/pwa`'s own words.
188
- // `x new` was here and takes an app name, so it could never run inside the broken app.
189
- `add a 1024x1024 square PNG at ${ICON_SOURCE}`,
190
- );
191
- }
192
- return file.bytes();
193
- };
194
- return async (plan, path) => {
195
- const entry = plan.entries.find((candidate) => candidate.outputPath === path);
196
- if (entry === undefined) {
197
- throw new PwaIconMissingError(
198
- `${path} is not in the icon matrix, so no transform describes it`,
199
- `request one of ${plan.entries.map((one) => one.outputPath).join(', ')}`,
200
- );
201
- }
202
- const existing = rendered.get(path);
203
- if (existing !== undefined) return existing;
204
- const bytes = sourceBytes().then((source) => pipeline.resize(source, entry.transform));
205
- rendered.set(path, bytes);
206
- // A failed render must not be remembered — the next request comes after the source was added.
207
- bytes.catch(() => rendered.delete(path));
208
- return bytes;
209
- };
210
- }
211
-
212
166
  export interface AssetRoutesOptions {
213
167
  /** App root. The source icon is resolved against it; storage keys never are. */
214
168
  readonly root: string;
215
169
  readonly storage: Storage;
216
170
  /** Replaces `builtinImageDriver` for `/media/*`. Omitted, core's PNG/JPEG pipeline. */
217
171
  readonly images?: ImageTransformDriver;
172
+ /**
173
+ * `manifest.webmanifest`, resolved at boot by `pwa-artifacts.ts`. Absent when the app declares
174
+ * `pwa.enabled: false` — and then no route is mounted at all, rather than one answering an empty
175
+ * document: a manifest a browser can fetch is a promise the app is installable.
176
+ */
177
+ readonly pwa?: PwaArtifacts;
218
178
  }
219
179
 
220
180
  /**
@@ -225,7 +185,9 @@ export interface AssetRoutesOptions {
225
185
  * this package's own rule forbids. `x dev` owns the runtime half, the diagnostic owns the other.
226
186
  */
227
187
  export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
228
- const plan = planIcons({ sourceIcon: ICON_SOURCE, outDir: ICON_BASE_PATH });
188
+ // `iconPlan()`, never a second `planIcons` call: the icons this mounts and the icons
189
+ // `manifest.webmanifest` names are one list, or the manifest promises a size nothing mints.
190
+ const plan = iconPlan();
229
191
  const render = iconRenderer(options.root);
230
192
 
231
193
  const routes: Route[] = plan.entries.map((entry) => ({
@@ -260,6 +222,9 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
260
222
  // container is the dev/prod difference this package's own rule forbids. `favicon.ts` owns what
261
223
  // the answer IS — this file only says the app's asset surface is where it hangs.
262
224
  routes.push(faviconRoute(options.root));
225
+ // Same rule one asset further along: the icons above are the ones this manifest NAMES, so the
226
+ // two belong to one surface and cannot be mounted from two places without drifting apart.
227
+ if (options.pwa !== undefined) routes.push(pwaManifestRoute(options.pwa));
263
228
 
264
229
  return routes;
265
230
  }
@@ -0,0 +1,69 @@
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 `dev-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.
9
+
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';
16
+
17
+ /**
18
+ * The two windows in milliseconds, each `undefined` where the app named none.
19
+ *
20
+ * ABSENT IS A DECISION, not a missing default, and it is the only safe one: an inbox row is a
21
+ * message a person has not read yet, so when it disappears is the app's call (axiom 8). The
22
+ * framework picking a number silently is the failure this whole key exists to avoid.
23
+ */
24
+ export interface InboxRetention {
25
+ readonly readMs: number | undefined;
26
+ readonly unreadMs: number | undefined;
27
+ }
28
+
29
+ /** Nothing swept — what a boot with no config file, no `notify` section or no keys resolves to. */
30
+ export const NO_INBOX_RETENTION: InboxRetention = Object.freeze({
31
+ readMs: undefined,
32
+ unreadMs: undefined,
33
+ });
34
+
35
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
36
+ typeof value === 'object' && value !== null;
37
+
38
+ /**
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.
45
+ */
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) };
69
+ }
package/src/dev-purge.ts CHANGED
@@ -1,5 +1,10 @@
1
- // The retention sweep this boot owns: the three framework tables that grow with traffic, the
2
- // `purge()` job that empties them and the `task` that fires it hourly.
1
+ // The retention sweep this boot owns: the framework tables that grow with traffic, the `purge()`
2
+ // job that empties them and the `task` that fires it hourly.
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.
3
8
  //
4
9
  // WHY here and not in the packages that own the tables: `postgresIdempotencyStore` (tier 3),
5
10
  // `postgresRateLimitStore` (tier 2) and `postgresAuthLimiter` (tier 2) cannot see each other and
@@ -17,6 +22,9 @@ import { purgeAuthLimits } from '@ultimat3/auth';
17
22
  import type { PostgresRateLimitStore } from '@ultimat3/http';
18
23
  import type { JobHandle, PurgeInput, PurgeTarget } from '@ultimat3/jobs';
19
24
  import { DEFAULT_PURGE_CRON, getJob, getTask, purge, task } from '@ultimat3/jobs';
25
+ import { purgeNotifyDeliveries, purgeNotifyInbox } from '@ultimat3/notify';
26
+ import type { InboxRetention } from './dev-notify-retention';
27
+ import { NO_INBOX_RETENTION } from './dev-notify-retention';
20
28
 
21
29
  /** The durable queue key. Pinned, like every framework-owned job name — rows carry it. */
22
30
  export const PURGE_JOB_NAME = 'x.purge';
@@ -36,6 +44,42 @@ export const PURGE_TASK_NAME = 'x.purge.hourly';
36
44
  export interface RetentionStores {
37
45
  readonly idempotency: PostgresIdempotencyStore;
38
46
  readonly rateLimit: PostgresRateLimitStore;
47
+ /**
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`.
51
+ */
52
+ readonly inboxRetention?: InboxRetention | undefined;
53
+ }
54
+
55
+ /**
56
+ * The two notify tables, and neither store is in `RetentionStores` — deliberately, and for the
57
+ * reason `authTarget` is not either. `setNotifyStores` is an APP's boot line and runs when the
58
+ * app's modules import, which is AFTER this install; `framework-schema.ts` says so where it
59
+ * applies the DDL "whether or not this boot calls `setNotifyStores`". So the sweep can only ask,
60
+ * per attempt, what is installed now — `purgeNotifyInbox`/`purgeNotifyDeliveries` answer 0 for a
61
+ * memory store or none at all, which is a boot that made a decision, not a failure.
62
+ */
63
+ function notifyTargets(retention: InboxRetention): readonly PurgeTarget[] {
64
+ return [
65
+ {
66
+ name: 'x_notify_deliveries',
67
+ // The job's clock, exactly as `x_rate_limit` below: `at` is written by whichever process took
68
+ // the delivery, so a cutoff computed inside Postgres measures the offset between two clocks
69
+ // rather than the age of the row. The WINDOW is the ledger's own, never named here — one
70
+ // number, beside the statement that reads it.
71
+ purgeExpired: (nowMs: number): Promise<number> => purgeNotifyDeliveries(nowMs),
72
+ },
73
+ {
74
+ name: 'x_notify_inbox',
75
+ purgeExpired: (nowMs: number): Promise<number> =>
76
+ purgeNotifyInbox({
77
+ read: retention.readMs === undefined ? undefined : new Date(nowMs - retention.readMs),
78
+ unread:
79
+ retention.unreadMs === undefined ? undefined : new Date(nowMs - retention.unreadMs),
80
+ }),
81
+ },
82
+ ];
39
83
  }
40
84
 
41
85
  /**
@@ -76,6 +120,7 @@ function retentionTargets(stores: RetentionStores): readonly PurgeTarget[] {
76
120
  purgeExpired: (nowMs: number): Promise<number> => stores.rateLimit.purgeExpired(nowMs),
77
121
  },
78
122
  authTarget,
123
+ ...notifyTargets(stores.inboxRetention ?? NO_INBOX_RETENTION),
79
124
  ];
80
125
  }
81
126
 
package/src/dev-render.ts CHANGED
@@ -45,6 +45,17 @@ export type IslandResolver = (routeFile: string) => (src: string) => string;
45
45
 
46
46
  export interface DocumentOptions {
47
47
  readonly resolveIsland?: IslandResolver;
48
+ /**
49
+ * `<link rel="manifest">`, both `theme-color` metas and the apple-touch links — `PwaArtifacts.head`
50
+ * from `pwa-artifacts.ts`, or absent when the app is not installable.
51
+ *
52
+ * A document-level string rather than something a route's `meta()` returns: it is the same three
53
+ * elements on every page of the app, an installable app is one whose EVERY page carries them
54
+ * (a browser offers the install on whichever page the visitor landed on), and `headFromMeta`
55
+ * projects per-route SEO. Passed through `DocumentOptions` for `resolveIsland`'s reason — the
56
+ * boot knows it, the renderer cannot ask.
57
+ */
58
+ readonly pwaHead?: string;
48
59
  }
49
60
 
50
61
  export interface DevRenderOptions extends DocumentOptions {
@@ -68,13 +79,18 @@ export interface DevRouteData extends Record<string, unknown> {
68
79
  */
69
80
  const lang = (): string => currentLocale();
70
81
 
71
- const headFor = async (entry: RouteEntry, ctx: DevRouteData, data: RouteData): Promise<string> =>
82
+ const headFor = async (
83
+ entry: RouteEntry,
84
+ ctx: DevRouteData,
85
+ data: RouteData,
86
+ options: DocumentOptions,
87
+ ): Promise<string> =>
72
88
  renderHead(
73
89
  headFromMeta(
74
90
  await entry.config.meta(metaContextFor(ctx, data)),
75
91
  seoRenderers({ path: new URL(ctx.url).pathname }),
76
92
  ),
77
- );
93
+ ) + (options.pwaHead ?? '');
78
94
 
79
95
  /** `<style>` for the surface's own stylesheets, or nothing at all when the surface imports none. */
80
96
  const styleTag = (entry: RouteEntry): string => {
@@ -155,7 +171,7 @@ async function documentFrom(
155
171
  ): Promise<string> {
156
172
  const islands = collectorFor(entry, options);
157
173
  const [head, body] = await Promise.all([
158
- headFor(entry, ctx, data),
174
+ headFor(entry, ctx, data, options),
159
175
  routeBody(entry, ctx, data, islands),
160
176
  ]);
161
177
  return (
@@ -203,7 +219,7 @@ async function resultFor(
203
219
  // correct output, no streaming benefit.
204
220
  const islands = collectorFor(entry, options);
205
221
  const [head, shell] = await Promise.all([
206
- headFor(entry, request, data),
222
+ headFor(entry, request, data, options),
207
223
  routeBody(entry, request, data, islands),
208
224
  ]);
209
225
  return streamResult(
@@ -41,6 +41,24 @@ const embeddedRefusal = (): BadFlagError =>
41
41
  fix: 'DATABASE_URL=postgres://user:password@localhost:5432/app x dev --role replicator',
42
42
  });
43
43
 
44
+ /**
45
+ * The relations the feed decodes: every registered entity's PHYSICAL TABLE, never its name.
46
+ *
47
+ * Both readers of this list are catalog readers. `PgReplicationStream` keeps a change only when
48
+ * `#entities.has(relation.name)`, and a pgoutput Relation message names the table; `warnPartialIdentity`
49
+ * matches the same list against `pg_class.relname`. An entity NAME is the framework's own registry
50
+ * key — what a cache tag, a policy and `x entities describe` are keyed by — and `entity('user',
51
+ * { table: 'users' })` makes the two different strings. Passing the name meant a renamed table
52
+ * matched nothing on either side: every change SKIPPED, and a replica-identity warning that could
53
+ * never fire. Latent wherever the two agree, which is every entity in `examples/dummy`.
54
+ *
55
+ * Deduplicated and sorted: two entities may share one table, and the same registry must hand the
56
+ * feed the same list whatever order it was registered in.
57
+ */
58
+ export function replicatedRelations(): readonly string[] {
59
+ return [...new Set(describeEntities().map((entity) => entity.table))].sort();
60
+ }
61
+
44
62
  /**
45
63
  * An entity list is the feed's filter, so an empty one is a replicator that decodes every change
46
64
  * and forwards none. Refused here rather than inside the feed: this is the layer that knows the
@@ -61,7 +79,7 @@ const noEntitiesRefusal = (): BadFlagError =>
61
79
  */
62
80
  export async function startReplicator(options: StartReplicatorOptions): Promise<RunningReplicator> {
63
81
  if (options.services.db.mode === 'embedded') throw embeddedRefusal();
64
- const entities = describeEntities().map((entity) => entity.name);
82
+ const entities = replicatedRelations();
65
83
  if (entities.length === 0) throw noEntitiesRefusal();
66
84
 
67
85
  const selection = selectChangeFeed(options.env, { entities });
package/src/dev-roles.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  configuredHttp,
15
15
  createServer,
16
16
  defineHttpConfig,
17
+ MAX_PROXY_HOPS,
17
18
  mergeHttpConfig,
18
19
  } from '@ultimat3/http';
19
20
  import type { OutboxRelay, Scheduler, Worker } from '@ultimat3/jobs';
@@ -188,6 +189,23 @@ function warnIfUnauthenticatable(routes: readonly Route[]): void {
188
189
  );
189
190
  }
190
191
 
192
+ /**
193
+ * How many proxies is too many, and the number is **`@ultimat3/http`'s**, not this file's.
194
+ *
195
+ * `defineHttpConfig` screens the same setting — `assertFiniteCount('trustedProxyHops', …,
196
+ * MAX_PROXY_HOPS)` — and this screen used to say 16 where that one says 64, so one setting had two
197
+ * ceilings and a deployment behind 20 hops was accepted by the library and refused by the boot.
198
+ * Widened rather than narrowed: tightening the library would break a shipped public API, while
199
+ * this end only ever refused topologies http already supports.
200
+ *
201
+ * It was a duplicated literal until 2026-08-26, because `@ultimat3/http` did not export the
202
+ * constant. It does now, so this file IMPORTS it — a downward edge, tier 5 to tier 2 — and there is
203
+ * no second number left to drift. `runtime-overrides.test.ts` still probes both screens for the
204
+ * highest count each accepts, and it is kept rather than deleted: it compares BEHAVIOUR, so it also
205
+ * catches the two disagreeing for a reason a shared constant cannot fix, such as one side gaining a
206
+ * range check the other does not have.
207
+ */
208
+
191
209
  /**
192
210
  * How many proxies append to `x-forwarded-for` between the client and this process, or `null`
193
211
  * when nothing in front of it is trusted.
@@ -207,7 +225,7 @@ export function trustedHopsFromEnv(env: Env): number | null {
207
225
  const hops = Number(raw);
208
226
  // A malformed count is refused rather than defaulted: reading the header at the wrong index is
209
227
  // trusting a value the client typed, which is the failure trusting a proxy exists to avoid.
210
- if (!Number.isInteger(hops) || hops < 1 || hops > 16) {
228
+ if (!Number.isInteger(hops) || hops < 1 || hops > MAX_PROXY_HOPS) {
211
229
  throw new PortInvalidError({ value: raw, name: 'TRUSTED_PROXY_HOPS' });
212
230
  }
213
231
  return hops;
@@ -31,6 +31,7 @@ import { selectTransport } from '@ultimat3/realtime/server';
31
31
  import type { Storage } from '@ultimat3/storage';
32
32
  import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
33
33
  import { loadCacheTiers, startCacheTiers } from './dev-cache';
34
+ import { loadInboxRetention } from './dev-notify-retention';
34
35
  import { installRetentionSweep } from './dev-purge';
35
36
  import type { DevDbClient } from './dev-queue';
36
37
  import { pgExecutorFor, startQueue } from './dev-queue';
@@ -327,11 +328,19 @@ export async function startServices(
327
328
  postgresAuthLimiter({ executor, clock: systemClock, policy }),
328
329
  );
329
330
  started.push(() => resetAuthLimiters());
330
- // The hourly sweep over the three framework tables this boot is responsible for. Every one of
331
- // them ships a `purgeExpired()` that nothing called, so every row written was a row kept —
331
+ // The hourly sweep over the framework tables this boot is responsible for. Every one of them
332
+ // ships a `purgeExpired()` that nothing called, so every row written was a row kept —
332
333
  // `x_rate_limit` takes one upsert per request the web role serves, assets included.
334
+ //
335
+ // `inboxRetention` is READ HERE and not defaulted in `dev-purge.ts`: the two windows are the
336
+ // app's, `startServices` holds no `AppConfig`, and a loader that silently answered "never
337
+ // sweep" from inside the sweep would be indistinguishable from an app that chose it.
333
338
  started.push(
334
- installRetentionSweep({ idempotency: queue.idempotency, rateLimit: rateLimitStore }),
339
+ installRetentionSweep({
340
+ idempotency: queue.idempotency,
341
+ rateLimit: rateLimitStore,
342
+ inboxRetention: await loadInboxRetention(services.root),
343
+ }),
335
344
  );
336
345
  // One readiness check per resource this boot OWNS, released with it. Nothing in the tree
337
346
  // registered one, so `/readyz` was `markReady()` alone — "this process bound a socket" — and
@@ -21,6 +21,13 @@ export interface DevServices {
21
21
  readonly events: ServiceBinding;
22
22
  readonly storage: ServiceBinding;
23
23
  readonly stateDir: string;
24
+ /**
25
+ * The app directory itself, carried rather than re-derived from `stateDir`. A boot that needs to
26
+ * read the app's own `app.config.ts` — `loadInboxRetention` does — otherwise has to undo the
27
+ * `join(root, '.x')` above, and a `dirname` that silently disagrees with this file's join is a
28
+ * path bug nothing would catch.
29
+ */
30
+ readonly root: string;
24
31
  }
25
32
 
26
33
  export type Env = Readonly<Record<string, string | undefined>>;
@@ -44,6 +51,7 @@ export function resolveServices(root: string, env: Env): DevServices {
44
51
  mkdirSync(stateDir, { recursive: true });
45
52
  }
46
53
  return {
54
+ root,
47
55
  stateDir,
48
56
  db:
49
57
  databaseUrl === undefined
package/src/dev-traces.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { RequestTrace, SpanKind, TimelineSpan } from '@ultimat3/admin/dev';
7
7
  import type { ReadableSpan, SpanExporter } from '@ultimat3/core';
8
+ import { finiteCount } from '@ultimat3/core';
8
9
  // The attribute name is `@ultimat3/db`'s to declare — this reads it rather than restating it, so
9
10
  // renaming it there is a compile error here instead of a panel that silently groups nothing.
10
11
  import { STATEMENT_ATTRIBUTE } from '@ultimat3/db';
@@ -130,7 +131,10 @@ function toTrace(root: ReadableSpan, spans: readonly ReadableSpan[]): RequestTra
130
131
  * root is what keeps a half-finished request, and a job's spans, out of a panel about requests.
131
132
  */
132
133
  export function createTraceRecorder(options: { limit?: number } = {}): TraceRecorder {
133
- const limit = options.limit ?? DEFAULT_LIMIT;
134
+ // `byTrace.size > NaN` is false on every pass, so an unchecked limit does not widen the buffer —
135
+ // it deletes the eviction loop, and a dev session then holds every span of every request it has
136
+ // ever seen. At least 1: a recorder that retains nothing is what `/_x/timeline` reads.
137
+ const limit = finiteCount('createTraceRecorder', 'limit', options.limit ?? DEFAULT_LIMIT, 1);
134
138
  // Insertion-ordered: the oldest trace id is the first key, which is the one eviction drops.
135
139
  const byTrace = new Map<string, ReadableSpan[]>();
136
140
 
package/src/e2e-page.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // e2e test drives and `@ultimat3/scraping` owns the only driver that can drive one, and neither
3
3
  // may import the other — so the join is here, in the one package allowed to know about both.
4
4
 
5
+ import { finiteCount } from '@ultimat3/core';
5
6
  import type { LocatorLike, PageLike } from '@ultimat3/testing';
6
7
  import { E2eServiceWorkerAbsentError } from './e2e-errors';
7
8
  import { evaluateClosure } from './e2e-evaluate';
@@ -30,6 +31,9 @@ export interface E2ePageOptions {
30
31
  readonly serviceWorkerTimeoutMs?: number | undefined;
31
32
  }
32
33
 
34
+ /** Named once, so both refusals below name the call an app's test preload actually makes. */
35
+ const SUBJECT = 'installE2eDriver';
36
+
33
37
  export const DEFAULT_E2E_TIMEOUT_MS = 30_000;
34
38
  export const DEFAULT_SERVICE_WORKER_TIMEOUT_MS = 10_000;
35
39
 
@@ -82,8 +86,18 @@ const serviceWorkerExpression = (timeoutMs: number): string =>
82
86
  */
83
87
  export function e2ePage(options: E2ePageOptions): PageLike {
84
88
  const page = options.page;
85
- const timeout = options.timeoutMs ?? DEFAULT_E2E_TIMEOUT_MS;
86
- const swTimeout = options.serviceWorkerTimeoutMs ?? DEFAULT_SERVICE_WORKER_TIMEOUT_MS;
89
+ // Both are screened HERE, at construction, and not where they land: `timeout` is handed to a
90
+ // driver that reads it as a deadline, and `swTimeout` is INTERPOLATED into the in-page source,
91
+ // where `setTimeout(fn, NaN)` is `setTimeout(fn, 0)` — so a NaN budget makes every
92
+ // `waitForServiceWorker()` refuse a worker that really did take control. A misdiagnosis reported
93
+ // as a test failure is worse than the failure. Floor 0, because a driver reads `timeout: 0` as
94
+ // "no deadline" and that is a value an app is entitled to declare.
95
+ const timeout = finiteCount(SUBJECT, 'timeoutMs', options.timeoutMs ?? DEFAULT_E2E_TIMEOUT_MS);
96
+ const swTimeout = finiteCount(
97
+ SUBJECT,
98
+ 'serviceWorkerTimeoutMs',
99
+ options.serviceWorkerTimeoutMs ?? DEFAULT_SERVICE_WORKER_TIMEOUT_MS,
100
+ );
87
101
  const absolute = (url: string): string => new URL(url, options.baseUrl).toString();
88
102
  const locate = (selection: E2eSelection): LocatorLike => e2eLocator(page, selection);
89
103
 
@@ -108,6 +108,14 @@ export const CLI_OWNED_ERROR_CODES = [
108
108
  // code, because the only remedy available for all of them is a line in the migration file, and
109
109
  // where that file lives is this package's fact.
110
110
  'X_MIGRATION_UNGENERATABLE',
111
+ // The third `subscribes:` condition, and the only one neither @ultimat3/query nor
112
+ // @ultimat3/db can ask. Query owns X_QUERY_SUBSCRIBES_INVALID (a declaration that cannot
113
+ // be acted on) and X_QUERY_SUBSCRIBES_DRIFT (it disagrees with the resolved shape); db
114
+ // keeps only the declared names an entity's table matches and DROPS the rest, because it
115
+ // has no way to tell a typo from a table another migration owns. Neither package holds
116
+ // both registries, so a name matching nothing was granted REPLICA IDENTITY FULL on
117
+ // nothing while its author read the declaration as granted (#357).
118
+ 'X_QUERY_SUBSCRIBES_UNKNOWN',
111
119
  'X_DB_MIGRATE_FAILED',
112
120
  'X_DB_BRANCH_FAILED',
113
121
  'X_DB_STUDIO_FAILED',
@@ -258,6 +266,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
258
266
  X_DB_SCHEMA_UNMIGRATED: 'an entity declaration no migration recorded',
259
267
  X_DB_SCHEMA_UNDECLARED: 'a migration records schema no entity declares',
260
268
  X_MIGRATION_UNGENERATABLE: 'this migration holds SQL no declaration carries and does not say so',
269
+ X_QUERY_SUBSCRIBES_UNKNOWN: 'a live query subscribes to a table no entity declares',
261
270
  X_DB_MIGRATE_FAILED: 'x db migrate failed',
262
271
  X_DB_BRANCH_FAILED: 'an x db branch step failed',
263
272
  X_DB_STUDIO_FAILED: 'x db studio failed',