@ultimat3/cli 30.1.0 → 30.3.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/package.json +28 -28
- package/src/cmd-exercise.ts +1 -1
- package/src/exercise-database.ts +15 -1
- package/src/exercise-findings.ts +3 -1
- package/src/exercise-subject.ts +3 -0
- package/src/seo-routes.ts +4 -1
- package/src/site-config.ts +1 -1
- package/src/site-seo-write.ts +9 -2
- package/src/templates/scaffold-entries.ts +1 -1
- package/wiki/Entities-And-Migrations.md +16 -0
- package/wiki/SEO.md +1 -1
- package/wiki/Testing.md +6 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "30.
|
|
3
|
+
"version": "30.3.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -53,33 +53,33 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@babel/core": "^8.0.7",
|
|
56
|
-
"@ultimat3/action": "30.
|
|
57
|
-
"@ultimat3/admin": "30.
|
|
58
|
-
"@ultimat3/ai": "30.
|
|
59
|
-
"@ultimat3/auth": "30.
|
|
60
|
-
"@ultimat3/cache": "30.
|
|
61
|
-
"@ultimat3/core": "30.
|
|
62
|
-
"@ultimat3/db": "30.
|
|
63
|
-
"@ultimat3/entity": "30.
|
|
64
|
-
"@ultimat3/flags": "30.
|
|
65
|
-
"@ultimat3/http": "30.
|
|
66
|
-
"@ultimat3/i18n": "30.
|
|
67
|
-
"@ultimat3/jobs": "30.
|
|
68
|
-
"@ultimat3/mail": "30.
|
|
69
|
-
"@ultimat3/manifest": "30.
|
|
70
|
-
"@ultimat3/mcp": "30.
|
|
71
|
-
"@ultimat3/money": "30.
|
|
72
|
-
"@ultimat3/notify": "30.
|
|
73
|
-
"@ultimat3/policy": "30.
|
|
74
|
-
"@ultimat3/pwa": "30.
|
|
75
|
-
"@ultimat3/query": "30.
|
|
76
|
-
"@ultimat3/realtime": "30.
|
|
77
|
-
"@ultimat3/render": "30.
|
|
78
|
-
"@ultimat3/schema": "30.
|
|
79
|
-
"@ultimat3/seo": "30.
|
|
80
|
-
"@ultimat3/storage": "30.
|
|
81
|
-
"@ultimat3/testing": "30.
|
|
82
|
-
"@ultimat3/time": "30.
|
|
56
|
+
"@ultimat3/action": "30.3.0",
|
|
57
|
+
"@ultimat3/admin": "30.3.0",
|
|
58
|
+
"@ultimat3/ai": "30.3.0",
|
|
59
|
+
"@ultimat3/auth": "30.3.0",
|
|
60
|
+
"@ultimat3/cache": "30.3.0",
|
|
61
|
+
"@ultimat3/core": "30.3.0",
|
|
62
|
+
"@ultimat3/db": "30.3.0",
|
|
63
|
+
"@ultimat3/entity": "30.3.0",
|
|
64
|
+
"@ultimat3/flags": "30.3.0",
|
|
65
|
+
"@ultimat3/http": "30.3.0",
|
|
66
|
+
"@ultimat3/i18n": "30.3.0",
|
|
67
|
+
"@ultimat3/jobs": "30.3.0",
|
|
68
|
+
"@ultimat3/mail": "30.3.0",
|
|
69
|
+
"@ultimat3/manifest": "30.3.0",
|
|
70
|
+
"@ultimat3/mcp": "30.3.0",
|
|
71
|
+
"@ultimat3/money": "30.3.0",
|
|
72
|
+
"@ultimat3/notify": "30.3.0",
|
|
73
|
+
"@ultimat3/policy": "30.3.0",
|
|
74
|
+
"@ultimat3/pwa": "30.3.0",
|
|
75
|
+
"@ultimat3/query": "30.3.0",
|
|
76
|
+
"@ultimat3/realtime": "30.3.0",
|
|
77
|
+
"@ultimat3/render": "30.3.0",
|
|
78
|
+
"@ultimat3/schema": "30.3.0",
|
|
79
|
+
"@ultimat3/seo": "30.3.0",
|
|
80
|
+
"@ultimat3/storage": "30.3.0",
|
|
81
|
+
"@ultimat3/testing": "30.3.0",
|
|
82
|
+
"@ultimat3/time": "30.3.0",
|
|
83
83
|
"babel-preset-solid": "^1.9.16"
|
|
84
84
|
}
|
|
85
85
|
}
|
package/src/cmd-exercise.ts
CHANGED
|
@@ -95,7 +95,7 @@ async function exercise(
|
|
|
95
95
|
proved.length === 0
|
|
96
96
|
? []
|
|
97
97
|
: await withExerciseDatabase(root, ctx.env, (client) =>
|
|
98
|
-
subjectProofFindings(proved, cores, client),
|
|
98
|
+
subjectProofFindings(proved, cores, client, overrides?.factories),
|
|
99
99
|
);
|
|
100
100
|
const report = exerciseReport(records, seed);
|
|
101
101
|
// The whole pass, not a one-primitive run's slice of it, is what the file is for — but a
|
package/src/exercise-database.ts
CHANGED
|
@@ -7,7 +7,12 @@ import type { Environment } from '@ultimat3/core';
|
|
|
7
7
|
import { ENVIRONMENT_KEY, resolveEnvironment, UltimateError } from '@ultimat3/core';
|
|
8
8
|
import type { DbClient } from '@ultimat3/db';
|
|
9
9
|
import { migrate, raw, setDbClient } from '@ultimat3/db';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
captureDeterminism,
|
|
12
|
+
installDeterminism,
|
|
13
|
+
restoreCapturedDeterminism,
|
|
14
|
+
testSealKeyEnv,
|
|
15
|
+
} from '@ultimat3/testing';
|
|
11
16
|
import { chooseReplayEngine, withScratchDatabase } from './db-replay-engine';
|
|
12
17
|
import { applyFrameworkSchema } from './framework-schema';
|
|
13
18
|
import { readMigrations } from './migrations';
|
|
@@ -50,6 +55,12 @@ const PINNED = [ENVIRONMENT_KEY, 'NODE_ENV'] as const;
|
|
|
50
55
|
* module reads it, and the throwaway seal key `bun test` gets when the app supplied none (CI names
|
|
51
56
|
* no `ULTIMATE_SECRETS_KEY`, and every row sealed here dies with the scratch database). Every
|
|
52
57
|
* variable is put back as found: a command that exits must not leave the next one a test process.
|
|
58
|
+
*
|
|
59
|
+
* The frozen clock too, and FIRST — as `bun test`'s preload freezes it before any module loads. The
|
|
60
|
+
* scratch database is PGlite, whose Postgres arms its timers off `Date.now()`: booted on the real
|
|
61
|
+
* clock and frozen under later (the pass's room), its 10 s idle-stats timer was re-armed at
|
|
62
|
+
* real-now minus frozen-now, ~2^34 ms, clamped to 1 ms — a `TimeoutOverflowWarning` busy loop for
|
|
63
|
+
* the whole pass. One clock for the engine's whole life: the deadline and the delay agree.
|
|
53
64
|
*/
|
|
54
65
|
export async function withExerciseProcess<T>(
|
|
55
66
|
root: string,
|
|
@@ -62,10 +73,13 @@ export async function withExerciseProcess<T>(
|
|
|
62
73
|
);
|
|
63
74
|
const added = { ...pinned, ...testSealKeyEnv({ root, env: { ...env, ...pinned } }) };
|
|
64
75
|
const previous = Object.keys(added).map((key) => [key, process.env[key]] as const);
|
|
76
|
+
const clock = captureDeterminism();
|
|
77
|
+
if (!clock.installed) installDeterminism();
|
|
65
78
|
Object.assign(process.env, added);
|
|
66
79
|
try {
|
|
67
80
|
return await work();
|
|
68
81
|
} finally {
|
|
82
|
+
restoreCapturedDeterminism(clock);
|
|
69
83
|
for (const [key, value] of previous) {
|
|
70
84
|
if (value === undefined) delete process.env[key];
|
|
71
85
|
else process.env[key] = value;
|
package/src/exercise-findings.ts
CHANGED
|
@@ -57,7 +57,9 @@ const unreachedFinding = (record: ExerciseRecord): Finding => {
|
|
|
57
57
|
return {
|
|
58
58
|
code: 'X_CONTRACT_EXERCISE_UNREACHED',
|
|
59
59
|
cause: `${record.kind} ${renderCauseValue(record.name)} never reached its handler as an allowed actor in the exercise pass${stopped}${why}`,
|
|
60
|
-
fix:
|
|
60
|
+
fix:
|
|
61
|
+
record.fix ??
|
|
62
|
+
`defineExercise({ ${override} }) # paste the entry into ${EXERCISE_FILE}, or { off: '<why>' }; then: x exercise ${name} --json`,
|
|
61
63
|
docs: ERROR_DOCS_URL,
|
|
62
64
|
at: EXERCISE_FILE,
|
|
63
65
|
};
|
package/src/exercise-subject.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { identifier, sql } from '@ultimat3/db';
|
|
|
9
9
|
import type { EntityCore, RegistryEntry } from '@ultimat3/entity';
|
|
10
10
|
import type { Subject } from '@ultimat3/jobs';
|
|
11
11
|
import { planSubject } from '@ultimat3/jobs';
|
|
12
|
+
import type { SeedFactory } from '@ultimat3/testing';
|
|
12
13
|
import { entitySeedWriter, subjectProof } from '@ultimat3/testing';
|
|
13
14
|
import type { Finding } from './output';
|
|
14
15
|
|
|
@@ -39,6 +40,7 @@ export async function subjectProofFindings(
|
|
|
39
40
|
subjects: readonly Subject<never>[],
|
|
40
41
|
cores: Readonly<Record<string, EntityCore>>,
|
|
41
42
|
client: DbClient,
|
|
43
|
+
factories?: Readonly<Record<string, SeedFactory>>,
|
|
42
44
|
): Promise<readonly Finding[]> {
|
|
43
45
|
const findings: Finding[] = [];
|
|
44
46
|
for (const [index, subject] of subjects.entries()) {
|
|
@@ -49,6 +51,7 @@ export async function subjectProofFindings(
|
|
|
49
51
|
read: (entity) =>
|
|
50
52
|
client.query<Record<string, unknown>>(sql`select * from ${identifier(entity.$table)}`),
|
|
51
53
|
seed: SUBJECT_PROOF_SEED + index,
|
|
54
|
+
factories,
|
|
52
55
|
}).then(
|
|
53
56
|
(result) => result.problems.filter((p) => p.code === 'X_SUBJECT_ERASE_INCOMPLETE'),
|
|
54
57
|
(error: unknown) => (isUltimateError(error) ? [error] : Promise.reject(error)),
|
package/src/seo-routes.ts
CHANGED
|
@@ -183,7 +183,7 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
|
|
|
183
183
|
return first.answer;
|
|
184
184
|
};
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
const routes: Route[] = [
|
|
187
187
|
{
|
|
188
188
|
method: 'GET',
|
|
189
189
|
path: ROBOTS_PATH,
|
|
@@ -256,4 +256,7 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
|
|
|
256
256
|
},
|
|
257
257
|
},
|
|
258
258
|
];
|
|
259
|
+
// `seo.llms: false` hands the path to the app: an app that writes its own `/llms.txt` (plans,
|
|
260
|
+
// prices) mounted it beside this one and every boot refused with X_ROUTE_CONFLICT.
|
|
261
|
+
return options.site?.llms === false ? routes.filter((route) => route.path !== LLMS_PATH) : routes;
|
|
259
262
|
}
|
package/src/site-config.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface SiteSettings {
|
|
|
26
26
|
* `SiteSettings` literal written before it existed still compiles.
|
|
27
27
|
*/
|
|
28
28
|
readonly favicon?: SiteFavicon | undefined;
|
|
29
|
-
/** `seo.llms` — `/llms.txt` on (the default) or off. Optional for `robotsGroups`' reason. */
|
|
29
|
+
/** `seo.llms` — `/llms.txt` on (the default) or off: off, the path is the app's to mount. Optional for `robotsGroups`' reason. */
|
|
30
30
|
readonly llms?: boolean | undefined;
|
|
31
31
|
}
|
|
32
32
|
|
package/src/site-seo-write.ts
CHANGED
|
@@ -9,10 +9,17 @@ import { ROBOTS_PATH, type SiteSeo } from './site-seo';
|
|
|
9
9
|
/**
|
|
10
10
|
* Writes every file of `seo` under `out` and answers their site-relative paths, the sitemap files
|
|
11
11
|
* first. `Bun.write` creates `sitemaps/` on the way, so an index and its parts land together.
|
|
12
|
+
* `llms: false` (`seo.llms`) writes no `llms.txt`: the path is the app's, as on a running web role.
|
|
12
13
|
*/
|
|
13
|
-
export async function writeSiteSeo(
|
|
14
|
+
export async function writeSiteSeo(
|
|
15
|
+
out: string,
|
|
16
|
+
seo: SiteSeo,
|
|
17
|
+
options: { readonly llms?: boolean | undefined } = {},
|
|
18
|
+
): Promise<readonly string[]> {
|
|
14
19
|
for (const file of seo.sitemaps) await Bun.write(join(out, file.path), file.xml);
|
|
15
20
|
await Bun.write(join(out, ROBOTS_PATH), seo.robots);
|
|
21
|
+
const sitemaps = seo.sitemaps.map((file) => file.path);
|
|
22
|
+
if (options.llms === false) return [...sitemaps, ROBOTS_PATH];
|
|
16
23
|
await Bun.write(join(out, LLMS_PATH), seo.llms);
|
|
17
|
-
return [...
|
|
24
|
+
return [...sitemaps, ROBOTS_PATH, LLMS_PATH];
|
|
18
25
|
}
|
|
@@ -119,7 +119,7 @@ async function writeSeoFiles(report: PrerenderReport): Promise<readonly string[]
|
|
|
119
119
|
entries: report.entries,
|
|
120
120
|
llmsPages: report.llmsPages,
|
|
121
121
|
});
|
|
122
|
-
return await writeSiteSeo(out, seo);
|
|
122
|
+
return await writeSiteSeo(out, seo, { llms: settings.llms });
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
if (import.meta.main) {
|
|
@@ -968,6 +968,22 @@ export const postTotals = entity('post_totals', {
|
|
|
968
968
|
| Admin, MCP | listed read-only: `list`, `detail`, `search`, and no create/edit/delete route, button or admin MCP tool. An `operations` override naming a write is `X_ADMIN_VIEW_WRITE` ([Admin dashboard](Admin-Dashboard)) |
|
|
969
969
|
| Manifest | the entity's fact carries `view: { materialized }`; a table becoming a view is a **breaking** change, a view becoming a table additive |
|
|
970
970
|
|
|
971
|
+
## Read-only tables
|
|
972
|
+
|
|
973
|
+
`entity(name, { readOnly: '<why>', columns })` declares a table the app reads and never writes —
|
|
974
|
+
legacy history a trigger closed to INSERTs, a table another system owns. The reason is required. As
|
|
975
|
+
of 2026-11:
|
|
976
|
+
|
|
977
|
+
| Rule | |
|
|
978
|
+
|---|---|
|
|
979
|
+
| Writes | `insert`, `insertAll`, `upsertAll`, `update`, `delete`, `updateWhere`, `deleteWhere`, `lock`: `X_ENTITY_READ_ONLY`, in both drivers, before a statement exists, the reason in the cause |
|
|
980
|
+
| Refused at declaration | a blank reason, `view` (already read-only), `appendOnly` (it admits the inserts this refuses): `X_INVARIANT_VIOLATED` |
|
|
981
|
+
| Seeds | `x db seed --generate` and `x exercise` seed no row of it. A nullable reference into it is seeded null; a NOT NULL one is `X_SEED_GRAPH_UNSATISFIED` naming the table, its reason, and the fix: `readOnly` on the referencing table too, or `.nullable()` then `x db gen` |
|
|
982
|
+
| Subjects | `defineSubject`'s `'delete'` or `anonymize` on it is `X_SUBJECT_TREATMENT_INVALID`; `retain` is the one treatment that holds |
|
|
983
|
+
| Admin, MCP | listed read-only, as a view; an `operations` override naming a write is `X_ADMIN_READ_ONLY_WRITE` |
|
|
984
|
+
| Projection, manifest | `$describe().readOnly` and the manifest's entity fact carry the reason, absent when undeclared; becoming readOnly is **breaking**, ceasing to be additive |
|
|
985
|
+
| DDL | none: `x db gen` emits nothing for it. The trigger, grant or ownership that closes the table is yours |
|
|
986
|
+
|
|
971
987
|
## Migration workflow
|
|
972
988
|
|
|
973
989
|
| Step | Command | Behavior |
|
package/wiki/SEO.md
CHANGED
|
@@ -265,7 +265,7 @@ description, in path order, under the home page's title and description:
|
|
|
265
265
|
| served | `GET /llms.txt` from the web role (`text/markdown`, the sitemap's hour of cache); the static export writes the same bytes (`writeSiteSeo`) |
|
|
266
266
|
| listed | public, indexable `site/` pages, in the default locale, less `seo.robots.disallow`. A dynamic route (or one with a `load`) is expanded through `prerender()` page by page, each line the title and description that page's own `meta` gives it through its `load` — the export lists only the pages it emitted, as the sitemap does; a page whose `meta` says `robots: { index: false }` is left out. `prerender()` runs once per answer for the sitemap and this file together |
|
|
267
267
|
| outside production | an empty document, for `robots.txt`'s reason |
|
|
268
|
-
| opt out | `seo: { llms: false }` in `app.config.ts`:
|
|
268
|
+
| opt out | `seo: { llms: false }` in `app.config.ts`: the framework mounts no `GET /llms.txt` and the static export writes none — the path is the app's own route to serve, or a 404 |
|
|
269
269
|
| build error | while on, a listed page with no `meta.description` is `X_SEO_LLMS_DESCRIPTION_MISSING` in the `seo` step. The step reads `meta` without a database, so it checks the pages whose `meta` needs no `load`; an expanded page with no description is listed by its title alone |
|
|
270
270
|
|
|
271
271
|
The repository's own `llms.txt` is a separate, hand-kept file and is not this.
|
package/wiki/Testing.md
CHANGED
|
@@ -981,14 +981,16 @@ shape, same unreached code. `As of 2026-11`.
|
|
|
981
981
|
| Rule | |
|
|
982
982
|
|---|---|
|
|
983
983
|
| Database | a scratch one with the framework tables and every migration applied; never the developer's. The pass commits, so the tenant proof can read rows after the call |
|
|
984
|
-
| Rows | one seed graph for the pass, a fresh pair of tenants per primitive in it — the first the actor's (A), the second the tenant proof's intruder (B); every table seeded off its declaration, parents first, same-tenant keys (`x db seed --generate`'s graph) |
|
|
984
|
+
| Rows | one seed graph for the pass, a fresh pair of tenants per primitive in it — the first the actor's (A), the second the tenant proof's intruder (B); every table seeded off its declaration, parents first, same-tenant keys (`x db seed --generate`'s graph). A view and a `readOnly: '<why>'` table get no row; a NOT NULL key into a `readOnly` one is `X_SEED_GRAPH_UNSATISFIED` naming it ([Read-only tables](Entities-And-Migrations#read-only-tables)) |
|
|
985
985
|
| Actor | the first of `x policy explain`'s actors the policy allows, in the primitive's tenant. None allowed is `refused`, by design |
|
|
986
986
|
| Input | the override, then an id field whose stem names a seeded entity (`postId` → `posts`), then the schema's minimal draw. A wrong guess shows as `unreached` |
|
|
987
|
+
| A job's org | the field(s) its `tenant(input)` reads — any name, nested, or left out of the draw — are set to the tenant it is enqueued in, found by reading them: A's for the control, B's for the tenant proof (B's own job, carrying A's ids). A `tenant` no input field moves (one fixed org) is `unreached`, `X_JOB_TENANT_MISMATCH`, its `fix:` the declaration: `tenant: (input) => input.orgId` |
|
|
987
988
|
| Async input | an override's `input` may be `async`: it is awaited inside the call's request, as the call's actor, so it can mint what the action checks (a one-time token, a passkey assertion). `x drill`'s load skips an `async` input and says why |
|
|
988
|
-
| Room | network sealed, mail captured, jobs in memory, clock frozen. A call out is refused and counted, so the primitive is `unreached` |
|
|
989
|
+
| Room | network sealed, mail captured, jobs in memory, clock frozen — from before the app loads and the scratch database boots, as `bun test`'s preload does, so the embedded Postgres never sees its clock jump. A call out is refused and counted, so the primitive is `unreached` |
|
|
990
|
+
| Cost | rows are primitives × tables; statements are not: the graph is written one `insertAll` per table, each tenant-proof snapshot is one statement, and a primitive reads only its own pair's rows of the graph |
|
|
989
991
|
| Overrides | `apps/web/exercise.ts`: `export const exercise = defineExercise({ publishPost: { actor, input }, sendInvoice: { off: '<why>' } })`. Each value is `.contract({ exercise })`'s option. An unknown name is `X_DECLARATION_UNKNOWN`; a blank `off` is `X_CONTRACT_EXERCISE_UNDECLARED` |
|
|
990
|
-
| Rows a generated value cannot satisfy | a cross-column `c.satisfies(…)` reads `X_SEED_GRAPH_UNSATISFIED`: `defineExercise({ … }, { factories: { bosses: defineFactory(bosses, { defaults: (i) => ({ … }) }) } })`, keyed by entity name, the graph's keys kept. An unknown name is `X_DECLARATION_UNKNOWN` |
|
|
991
|
-
| Report | `.x/exercise.json` and `--json`: `{ seed, counts, records[] }`, each record `{ kind, name, outcome, code?, reason?, statements, effects, ms, override?, tenant?, crash? }
|
|
992
|
+
| Rows a generated value cannot satisfy | a cross-column `c.satisfies(…)` reads `X_SEED_GRAPH_UNSATISFIED`: `defineExercise({ … }, { factories: { bosses: defineFactory(bosses, { defaults: (i) => ({ … }) }) } })`, keyed by entity name, the graph's keys kept. A column that follows from a key or reference the graph picked: `derive: (row) => ({ scopeKey: … })` beside `build`/`reset`, reading `row.ownerOrgId`. A partial unique whose columns the factory builds is the database's to judge. An unknown name is `X_DECLARATION_UNKNOWN` |
|
|
993
|
+
| Report | `.x/exercise.json` and `--json`: `{ seed, counts, records[] }`, each record `{ kind, name, outcome, code?, reason?, statements, effects, ms, override?, fix?, tenant?, crash? }` — `fix` where no override reaches it. The fields before `tenant` are A's call. The human table's `tenant` and `crash` columns are the verdicts, `-` where none ran |
|
|
992
994
|
| Gate | the `exercise` step, after `job`, runs `x exercise --json` as a child and is red on its findings: `X_CONTRACT_EXERCISE_UNREACHED`, `X_TENANT_LEAK`, `X_TENANT_WRITE_CROSSED`, `X_JOB_CRASH_DIVERGED`, and — on a whole pass, not `x exercise <name>` — `X_ENTITY_TENANT_UNDECLARED`. `unreached` is never sampled or skipped: it is a finding whose `fix:` is the entry to paste |
|
|
993
995
|
|
|
994
996
|
```bash
|