@ultimat3/cli 17.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 });
@@ -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
@@ -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',
@@ -0,0 +1,74 @@
1
+ // Single responsibility: the one source image every generated icon derives from, where the matrix
2
+ // is served, and the renderer that turns one into the other. Its own module so the two things that
3
+ // need it — `dev-assets.ts`, which SERVES the matrix, and `pwa-artifacts.ts`, which NAMES it in the
4
+ // web manifest — can share it without importing each other.
5
+
6
+ // why: Bun exposes no path-join primitive, and `ICON_SOURCE` is app-root-relative, so resolving it
7
+ // against the root is string work no `Bun.file` overload does.
8
+ import { join } from 'node:path';
9
+ import type { IconPlan } from '@ultimat3/pwa';
10
+ import { BuiltinImagePipeline, PwaIconMissingError, planIcons } from '@ultimat3/pwa';
11
+
12
+ /**
13
+ * The one source image every generated icon derives from. `x new` scaffolds it, `x doctor` checks
14
+ * it and this file reads it — one constant, because a second spelling is an app that passes the
15
+ * diagnostic and still serves no icons. PNG, not SVG: core's pipeline decodes PNG and JPEG only.
16
+ */
17
+ export const ICON_SOURCE = 'apps/web/site/icon.png';
18
+
19
+ /** Where `planIcons` writes, and therefore the paths the generated web manifest names. */
20
+ export const ICON_BASE_PATH = '/icons';
21
+
22
+ /**
23
+ * The matrix's whole plan, off one constant pair. One call, so the icons `/icons/*` serves and the
24
+ * icons `manifest.webmanifest` names can never be two different lists.
25
+ */
26
+ export const iconPlan = (): IconPlan =>
27
+ planIcons({ sourceIcon: ICON_SOURCE, outDir: ICON_BASE_PATH });
28
+
29
+ /**
30
+ * Whether the app committed the one file the whole matrix derives from. Read where the answer
31
+ * changes what is EMITTED — a manifest naming twelve icons an app has no source for is twelve 404s
32
+ * in an install prompt, which is the promise-nothing-keeps shape this module's callers exist to
33
+ * close. `x doctor` owns the diagnostic and reports the same condition with `X_PWA_ICON_MISSING`.
34
+ */
35
+ export const hasSourceIcon = (root: string): Promise<boolean> =>
36
+ Bun.file(join(root, ICON_SOURCE)).exists();
37
+
38
+ /**
39
+ * Rendered once per process, not per request: the fourteen matrix entries are pure functions of
40
+ * one source file, and re-encoding a 512px PNG on every hit would be work no caller can observe.
41
+ */
42
+ export function iconRenderer(root: string): (plan: IconPlan, path: string) => Promise<Uint8Array> {
43
+ const pipeline = new BuiltinImagePipeline();
44
+ const rendered = new Map<string, Promise<Uint8Array>>();
45
+ const sourceBytes = async (): Promise<Uint8Array> => {
46
+ const file = Bun.file(join(root, ICON_SOURCE));
47
+ if (!(await file.exists())) {
48
+ throw new PwaIconMissingError(
49
+ `${ICON_SOURCE} does not exist, so every icon the web manifest declares is unbacked and ` +
50
+ 'the app is not installable',
51
+ // The same edit `x doctor` reports for the same condition, in `@ultimat3/pwa`'s own words.
52
+ // `x new` was here and takes an app name, so it could never run inside the broken app.
53
+ `add a 1024x1024 square PNG at ${ICON_SOURCE}`,
54
+ );
55
+ }
56
+ return file.bytes();
57
+ };
58
+ return async (plan, path) => {
59
+ const entry = plan.entries.find((candidate) => candidate.outputPath === path);
60
+ if (entry === undefined) {
61
+ throw new PwaIconMissingError(
62
+ `${path} is not in the icon matrix, so no transform describes it`,
63
+ `request one of ${plan.entries.map((one) => one.outputPath).join(', ')}`,
64
+ );
65
+ }
66
+ const existing = rendered.get(path);
67
+ if (existing !== undefined) return existing;
68
+ const bytes = sourceBytes().then((source) => pipeline.resize(source, entry.transform));
69
+ rendered.set(path, bytes);
70
+ // A failed render must not be remembered — the next request comes after the source was added.
71
+ bytes.catch(() => rendered.delete(path));
72
+ return bytes;
73
+ };
74
+ }