@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.
- package/CLAUDE.md +90 -7
- package/package.json +30 -30
- package/src/app-root.ts +13 -1
- package/src/cmd-dev.ts +16 -2
- package/src/cmd-doctor.ts +1 -1
- package/src/cmd-test.ts +20 -9
- package/src/db-accept-created.ts +207 -0
- package/src/db-generate.ts +18 -1
- package/src/db-subscribes.ts +81 -0
- package/src/db-ungeneratable.ts +14 -2
- package/src/dev-assets.ts +19 -54
- package/src/dev-notify-retention.ts +69 -0
- package/src/dev-purge.ts +47 -2
- package/src/dev-render.ts +20 -4
- package/src/dev-replicator.ts +19 -1
- package/src/dev-roles.ts +19 -1
- package/src/dev-runtime.ts +12 -3
- package/src/dev-services.ts +8 -0
- package/src/dev-traces.ts +5 -1
- package/src/e2e-page.ts +16 -2
- package/src/error-codes.ts +9 -0
- package/src/icon-assets.ts +74 -0
- package/src/index.ts +19 -4
- package/src/island-harness-script.ts +8 -1
- package/src/island-shot.ts +45 -6
- package/src/island-verdict.ts +16 -4
- package/src/mcp-errors.ts +6 -0
- package/src/messages.ts +1 -1
- package/src/metrics-endpoint.ts +6 -1
- package/src/prerender.ts +16 -0
- package/src/pwa-artifacts.ts +188 -0
- package/src/serve.ts +13 -1
- package/src/sync-authenticator.ts +8 -2
- package/src/templates/naming.ts +11 -0
- package/src/templates/scaffold-repo.ts +14 -1
- package/src/test-shards.ts +150 -116
- package/src/ts-scan.ts +6 -1
- package/src/verify-test-run.ts +31 -46
|
@@ -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
|
+
}
|
package/src/db-ungeneratable.ts
CHANGED
|
@@ -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
|
-
*
|
|
62
|
-
*
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
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 (
|
|
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(
|
package/src/dev-replicator.ts
CHANGED
|
@@ -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 =
|
|
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 >
|
|
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;
|
package/src/dev-runtime.ts
CHANGED
|
@@ -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
|
|
331
|
-
//
|
|
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({
|
|
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
|
package/src/dev-services.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
package/src/error-codes.ts
CHANGED
|
@@ -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',
|