@ultimat3/testing 30.3.0 → 30.4.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 CHANGED
@@ -51,13 +51,13 @@ import is `registry-isolation.ts`, own entry point, off the barrel. Full rule: `
51
51
  | A passkey test signs for real | `softAuthenticator` is `@ultimat3/auth/soft-authenticator`'s, never re-exported here (one value, one path: `flight-copies`) and never a second one (auth's own suites need it, and auth cannot import testing) |
52
52
  | A reached job is crash-proven | `exercise-crash.ts`: `crashProof` with the control's input and actor, after the tenant proof; only `diverged` is red (`X_JOB_CRASH_DIVERGED`). Committed: job bodies escape transactions |
53
53
  | The tenant proof is taint only | `tenant-proof.ts`: B, A, B; B is A's role minus `tenancy:cross`. Leak = seeded canary or unhanded id (`tenant-canary.ts`); write = moved digest. Only `crossed` is red |
54
- | Every statement the pass sent is planned | `plan-sweep.ts` (`sweepPlans`): first relation an app table, `PREPARE` in a READ-ONLY transaction with seq/bitmap scans and sorts off, judged by `plan-oracle.ts`; red = `X_QUERY_UNINDEXED` / `X_QUERY_SORT_UNSERVED` with the `indexes:` entry (`plan-sweep-fix.ts`). `scan:` or `expectedSeqScan` is exempt, listed; no generic plan is `X_QUERY_PLAN_UNAVAILABLE`, never a pass |
54
+ | Every statement the pass sent is planned | `plan-sweep.ts` (`sweepPlans`): first relation an app table, `PREPARE` in a READ-ONLY transaction with seq/bitmap scans and sorts off, judged by `plan-oracle.ts`; red = `X_QUERY_UNINDEXED` / `X_QUERY_SORT_UNSERVED` with the `indexes:` entry (`plan-sweep-fix.ts`). `scan:` or `expectedSeqScan` is exempt, listed; credited: a partial unique whose predicate the statement states (one row), a declared equality-then-range index under a limit; no generic plan is `X_QUERY_PLAN_UNAVAILABLE`, never a pass |
55
55
  | A shape that grows with the rows is red | `scale-sweep-run.ts`: two more tenants at `SCALE_SIZES` (2, 8 rows a table), every reached primitive once in each; `judgeScale` (pure) reds `X_QUERY_SCALES_WITH_ROWS`. Under `expectedQueryLoop` it is listed with its reason |
56
56
  | An erase is PROVED, not asserted | `subject-proof.ts` (`subjectProof`, run by cli's `exercise-subject.ts`): canaries in every table, one person erased, every stored row searched; a survivor outside `retain` is `X_SUBJECT_ERASE_INCOMPLETE`, naming table and column, never the value |
57
57
  | A captured failure replays as a test | `repro.ts`: `reproduce(bundle, { driver })` seeds the rows it read, freezes the clock at its instant, invokes as its actor; masked fields are stood in (`repro-stand-in.ts`). An unregistered name ANSWERS as the failure. `reproJob(runJobs, failure)` for a dead job. What `x repro --write` emits |
58
58
  | A vendor's answers are a cassette | `cassette.ts`: replay = one `mockFetch` route per recorded URL, seal kept (`X_CASSETTE_MISSING`, `X_CASSETTE_UNUSED`). Record = `ULTIMATE_CASSETTE=record` + `record.hosts`, refused under `CI` (`X_CASSETTE_RECORD_REFUSED`), headers allow-listed, credential keys masked, bytes scanned before disk (`X_CASSETTE_SECRET`). The request side is never written |
59
59
  | One seed writer | `entitySeedWriter({ entities, client \| driver })` (`seed-writer.ts`) writes through each entity's own repository; `snapshot` is required; `entity` loads on the first write, so the barrel stays entity-free |
60
- | Factory values are DECLARED | `factoriesFor` and `seedGraph` share `seed-graph-values.ts`: default, enum, kind, through `$parse`, never the name. `seed-graph*.ts` type-import `entity` only |
60
+ | Factory values are DECLARED | `factoriesFor` and `seedGraph` share `seed-graph-values.ts`: default, enum, kind, a `matches()` pattern (`seed-pattern-sample.ts`, read off the CHECK by `seed-graph-patterns.ts`), `$meta.schema` (sampler `require`d on first use), through `$parse`, never the name. `seed-graph*.ts` type-import `entity` only |
61
61
  | e2e without a driver | `e2eTest` becomes `test.skip`; the gate reports the step SKIPPED (or `X_VERIFY_SUITE_VANISHED` when `x.verify.json` requires it), never passed |
62
62
  | The seam has an inverse | `resetE2eDriver()`: `useE2eDriver` writes MODULE scope, so a file that installs a browser undoes it |
63
63
  | Built vs declared | `clock` `mail` `network` `runJobs` `statements` `subscribe` are built in-process; `page` `budget` `signIn` `deploy` are declared and wait for a driver (`X_TEST_FIXTURE_UNAVAILABLE`). |
package/README.md CHANGED
@@ -34,7 +34,8 @@ unitTest('the clock moves only when the test moves it', ({ clock }) => {
34
34
  | `seed-graph-error.ts` | `X_SEED_GRAPH_UNSATISFIED`, its fix naming the `defineFactory` to write; `X_SEED_GRAPH_ENTITY_UNKNOWN`, a plan and a writer built from two entity sets |
35
35
  | `seed-writer.ts` | `entitySeedWriter({ entities, client })` or `({ entities, driver? })` — the `SeedWriter` that writes a graph through each entity's own repository: Postgres or PGlite through a client, or a driver. `@ultimat3/entity` loads on the first write. `snapshot(rows)`, required on every `SeedWriter`, reads rows back as one digest each (`md5(t::text)` over a client), `null` for a row gone |
36
36
  | `exercise.ts` | `runExercisePass({ entities, writer, roles, seed?, overrides?, only? })` — every registered action, query and job once, each on its own committed tenant pair, one `ExerciseRecord` per primitive, the tenant proof's verdict on its `tenant`. What `x exercise` runs |
37
- | `define-exercise.ts` | `defineExercise({ <name>: { actor, input?, runs? } \| { off: '<why>' } }, { actor?: <entity>, factories?: { <entity name>: defineFactory(…) } })` — an app's overrides (the second argument names the entity whose rows are the actors, and the factories that build a table no generated row satisfies — a cross-column `satisfies` — keyed by entity name, unknown ones `X_DECLARATION_UNKNOWN`), `apps/web/exercise.ts`'s one export; a blank `off` is `X_CONTRACT_EXERCISE_UNDECLARED`, an unknown name `X_DECLARATION_UNKNOWN` when the pass runs |
37
+ | `define-exercise.ts` | `defineExercise({ <name>: { actor, input?, runs? } \| { off: '<why>' } }, { actor?: <entity>, factories?: { <entity name>: defineFactory(…) }, setup?: () => mockFetch(…) \| cassette(…) })` — an app's overrides (the second argument names the entity whose rows are the actors, and the factories that build a table no generated row satisfies — a cross-column `satisfies` — keyed by entity name, unknown ones `X_DECLARATION_UNKNOWN`), `apps/web/exercise.ts`'s one export; a blank `off` is `X_CONTRACT_EXERCISE_UNDECLARED`, an unknown name `X_DECLARATION_UNKNOWN` when the pass runs |
38
+ | `exercise-setup.ts` | `withExerciseSetup(room, setup)`: the app's `setup` run inside the sealed room before the first row, its handle disposed before the room restores the network; an `allowHost` in it is `X_EXERCISE_SETUP_UNSEALED`. Types `ExerciseSetup`, `ExerciseSetupHandle` |
38
39
  | `exercise-actor.ts` · `exercise-input.ts` · `exercise-report.ts` | the first `x policy explain` actor the policy allows, minted in the primitive's tenant · the input: override, then a seeded id by field stem, then the minimal draw (`@ultimat3/schema/sample`, loaded dynamically) · the room one run executes in (network sealed, mail captured, clock frozen) and its record, `overrideLine` the entry to paste |
39
40
  | `tenant-proof.ts` | internal (`CROSS_TENANT_SCOPE` is `@ultimat3/entity`'s): `proveTenant` — B with A's ids, A's own call, B again; each B leg between two snapshots of A's rows. `withoutCrossTenant` strips `tenancy:cross` (`CROSS_TENANT_SCOPE`) from B; `publicReason` decides `public` |
40
41
  | `tenant-canary.ts` | internal: `tenantTaint` — one tenant's rows, ids and marks (row canaries, key parts of `MIN_TAINT_ID_LENGTH` 8 or more no other tenant shares); `findTaint` — every path holding one |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "30.3.0",
3
+ "version": "30.4.0",
4
4
  "description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -46,21 +46,21 @@
46
46
  "test": "bun test"
47
47
  },
48
48
  "dependencies": {
49
- "@ultimat3/action": "30.3.0",
50
- "@ultimat3/cache": "30.3.0",
51
- "@ultimat3/core": "30.3.0",
52
- "@ultimat3/db": "30.3.0",
53
- "@ultimat3/entity": "30.3.0",
54
- "@ultimat3/http": "30.3.0",
55
- "@ultimat3/i18n": "30.3.0",
56
- "@ultimat3/jobs": "30.3.0",
57
- "@ultimat3/mail": "30.3.0",
58
- "@ultimat3/policy": "30.3.0",
59
- "@ultimat3/pwa": "30.3.0",
60
- "@ultimat3/query": "30.3.0",
61
- "@ultimat3/realtime": "30.3.0",
62
- "@ultimat3/render": "30.3.0",
63
- "@ultimat3/schema": "30.3.0",
64
- "@ultimat3/time": "30.3.0"
49
+ "@ultimat3/action": "30.4.0",
50
+ "@ultimat3/cache": "30.4.0",
51
+ "@ultimat3/core": "30.4.0",
52
+ "@ultimat3/db": "30.4.0",
53
+ "@ultimat3/entity": "30.4.0",
54
+ "@ultimat3/http": "30.4.0",
55
+ "@ultimat3/i18n": "30.4.0",
56
+ "@ultimat3/jobs": "30.4.0",
57
+ "@ultimat3/mail": "30.4.0",
58
+ "@ultimat3/policy": "30.4.0",
59
+ "@ultimat3/pwa": "30.4.0",
60
+ "@ultimat3/query": "30.4.0",
61
+ "@ultimat3/realtime": "30.4.0",
62
+ "@ultimat3/render": "30.4.0",
63
+ "@ultimat3/schema": "30.4.0",
64
+ "@ultimat3/time": "30.4.0"
65
65
  }
66
66
  }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { ExerciseOptions } from '@ultimat3/action';
7
7
  import { renderCauseValue, renderFixLiteral, UltimateError } from '@ultimat3/core';
8
+ import type { ExerciseSetup } from './exercise-setup';
8
9
  import type { SeedFactory } from './seed-graph-plan';
9
10
 
10
11
  /** 39's option type, exactly: `{ actor, input?, runs? }` or `{ off: '<reason>' }`. */
@@ -18,6 +19,8 @@ export interface ExerciseOverrides {
18
19
  readonly actorEntity?: string;
19
20
  /** By entity name: the pass's graph builds those rows with them (`seedGraph`'s `factories`). */
20
21
  readonly factories?: Readonly<Record<string, SeedFactory>>;
22
+ /** The app's hook, run once inside the pass's sealed room (`exercise-setup.ts`). */
23
+ readonly setup?: ExerciseSetup;
21
24
  }
22
25
 
23
26
  /** What the whole pass needs to know of the app, beside the per-primitive entries. */
@@ -34,6 +37,13 @@ export interface ExerciseAppOptions {
34
37
  * refused when the pass runs.
35
38
  */
36
39
  readonly factories?: Readonly<Record<string, SeedFactory>>;
40
+ /**
41
+ * Run in the exercise process once the network is sealed, before the first row: install the
42
+ * `mockFetch` routes or `cassette()` your vendor-calling handlers need, so they run for real.
43
+ * What it returns (a cassette, any `Disposable`) is disposed after the pass. `allowHost` here is
44
+ * `X_EXERCISE_SETUP_UNSEALED` — the pass never sends a call out.
45
+ */
46
+ readonly setup?: ExerciseSetup;
37
47
  }
38
48
 
39
49
  /** `X_CONTRACT_EXERCISE_UNDECLARED`, 39's code for the same silence: an `off` with no reason. */
@@ -59,6 +69,7 @@ export function defineExercise(
59
69
  entries,
60
70
  ...(app.actor === undefined ? {} : { actorEntity: app.actor.$name }),
61
71
  ...(app.factories === undefined ? {} : { factories: app.factories }),
72
+ ...(app.setup === undefined ? {} : { setup: app.setup }),
62
73
  });
63
74
  }
64
75
 
package/src/errors.ts CHANGED
@@ -69,6 +69,7 @@ export const TESTING_ERROR_CODES = [
69
69
  'X_CASSETTE_SECRET',
70
70
  'X_CASSETTE_RECORD_REFUSED',
71
71
  'X_TEST_MODULE_MOCK_UNRESTORABLE',
72
+ 'X_EXERCISE_SETUP_UNSEALED',
72
73
  ] as const;
73
74
 
74
75
  export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
@@ -133,6 +134,7 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
133
134
  X_CASSETTE_SECRET: 'a cassette about to be written holds a secret value',
134
135
  X_CASSETTE_RECORD_REFUSED: 'a cassette recording was refused',
135
136
  X_TEST_MODULE_MOCK_UNRESTORABLE: 'a mocked module could not be put back at the file boundary',
137
+ X_EXERCISE_SETUP_UNSEALED: 'an exercise setup let a host through the seal',
136
138
  };
137
139
 
138
140
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -31,8 +31,15 @@ const answer = (handle: TenantOf, input: unknown): string | undefined => {
31
31
  }
32
32
  };
33
33
 
34
+ /** How deep an absent parent is filled in to read past it: `payload.input.orgId` is two. */
35
+ const ABSENT_DEPTH = 4;
36
+
37
+ /** The value at `path`, `undefined` when any step of it is absent. */
38
+ const getAt = (input: unknown, path: Path): unknown =>
39
+ path.reduce<unknown>((value, key) => (isPlainObject(value) ? value[key] : undefined), input);
40
+
34
41
  /** Every property path `tenantFor` read, at any depth of plain objects — a missing one included. */
35
- function pathsRead(handle: TenantOf, input: unknown): readonly Path[] {
42
+ function readOnce(handle: TenantOf, input: unknown): readonly Path[] {
36
43
  const read: Path[] = [];
37
44
  const watch = (value: unknown, at: Path): unknown =>
38
45
  !isPlainObject(value)
@@ -49,8 +56,28 @@ function pathsRead(handle: TenantOf, input: unknown): readonly Path[] {
49
56
  return read;
50
57
  }
51
58
 
59
+ /**
60
+ * Every path read, past absent parents too: a read that found nothing is filled with `{}` and read
61
+ * again, so `payload.input.orgId` — a scoped backfill's tenant, its `input` left out of the
62
+ * minimal draw — is found, not lost to the throw reading `orgId` of `undefined`.
63
+ */
64
+ function pathsRead(handle: TenantOf, input: unknown, depth = 0): readonly Path[] {
65
+ const reads = readOnce(handle, input);
66
+ if (depth >= ABSENT_DEPTH) return reads;
67
+ const deeper = reads
68
+ .filter((path) => getAt(input, path) === undefined)
69
+ .flatMap((path) => pathsRead(handle, setAt(input, path, {}), depth + 1));
70
+ const seen = new Set<string>();
71
+ return [...reads, ...deeper].filter((path) => {
72
+ const key = JSON.stringify(path);
73
+ if (seen.has(key)) return false;
74
+ seen.add(key);
75
+ return true;
76
+ });
77
+ }
78
+
52
79
  /** `input` with `path` set to `value`, copied along the path only. */
53
- function setAt(input: unknown, path: Path, value: string): unknown {
80
+ function setAt(input: unknown, path: Path, value: unknown): unknown {
54
81
  const [head, ...rest] = path;
55
82
  if (head === undefined) return value;
56
83
  const base = isPlainObject(input) ? input : {};
@@ -13,6 +13,7 @@ import {
13
13
  import type { RowObserver } from '@ultimat3/entity';
14
14
  import { captureDeterminism, installDeterminism, restoreCapturedDeterminism } from './determinism';
15
15
  import { testMail } from './fixture-mail';
16
+ import { routeMatches } from './mock-route';
16
17
  import type { PlanProof, SentStatement } from './plan-sweep';
17
18
  import type { ScaleProof, StatementTally } from './scale-sweep';
18
19
  import {
@@ -228,6 +229,10 @@ export async function openExerciseRoom(): Promise<ExerciseRoom> {
228
229
  },
229
230
  end: (): Observed => {
230
231
  const calls = requestLog().slice(logFrom);
232
+ // A call a route answered never left the process: the app's `setup` installed it.
233
+ const { mocks } = captureNetwork();
234
+ const refused = (call: (typeof calls)[number]): boolean =>
235
+ !local(call.url) && !mocks.some((route) => routeMatches(route, call));
231
236
  const effects: ExerciseEffect[] = [
232
237
  ...[...tally(rows)].map(([key, count]): ExerciseEffect => {
233
238
  const [entity = '', op = ''] = key.split('\u0000');
@@ -241,11 +246,11 @@ export async function openExerciseRoom(): Promise<ExerciseRoom> {
241
246
  kind: 'network',
242
247
  method: call.method,
243
248
  host: hostOf(call.url),
244
- refused: !local(call.url),
249
+ refused: refused(call),
245
250
  }),
246
251
  ),
247
252
  ];
248
- const refusedHosts = calls.filter((call) => !local(call.url)).map((c) => hostOf(c.url));
253
+ const refusedHosts = calls.filter(refused).map((c) => hostOf(c.url));
249
254
  const payloads: EffectPayload[] = [
250
255
  ...mail.outbox().map(({ message }, i) => ({
251
256
  path: `effect.mail[${i}]`,
@@ -0,0 +1,68 @@
1
+ // `defineExercise(…, { setup })`, run: the app's hook inside the pass's sealed room, before the
2
+ // first row is seeded, disposed before the room restores the network. What it installs — a
3
+ // `mockFetch` route, a `cassette()` — answers a vendor call; `allowHost` would send one for real.
4
+
5
+ import { renderCauseValue, UltimateError } from '@ultimat3/core';
6
+ import type { ExerciseRoom } from './exercise-report';
7
+ import { captureNetwork } from './sealed-network';
8
+
9
+ /** What a setup may hand back to be undone after the pass: a `cassette()`, a `using` resource. */
10
+ export type ExerciseSetupHandle = Disposable | AsyncDisposable;
11
+
12
+ /**
13
+ * Runs once per pass, in the exercise process, after the seal and before any primitive. Returns
14
+ * nothing when there is nothing to undo — a `mockFetch` route goes with the room.
15
+ */
16
+ export type ExerciseSetup = () =>
17
+ | void
18
+ | ExerciseSetupHandle
19
+ | Promise<void>
20
+ | Promise<ExerciseSetupHandle>
21
+ | Promise<ExerciseSetupHandle | undefined>;
22
+
23
+ const setupUnsealed = (hosts: readonly string[]): UltimateError =>
24
+ new UltimateError({
25
+ code: 'X_EXERCISE_SETUP_UNSEALED',
26
+ cause: `defineExercise(…, { setup }) called allowHost(${hosts.map((host) => renderCauseValue(host)).join(', ')}): the pass would send a primitive's call out for real, where every vendor is answered by mockFetch or cassette()`,
27
+ fix: "mockFetch('https://<host>/*', handler) # or cassette(import.meta.dir, '<name>') — in apps/web/exercise.ts's setup, in place of allowHost('<host>')",
28
+ meta: { hosts },
29
+ });
30
+
31
+ async function release(handle: unknown): Promise<void> {
32
+ if (typeof handle !== 'object' || handle === null) return;
33
+ if (Symbol.asyncDispose in handle) await (handle as AsyncDisposable)[Symbol.asyncDispose]();
34
+ else if (Symbol.dispose in handle) (handle as Disposable)[Symbol.dispose]();
35
+ }
36
+
37
+ /**
38
+ * `room` with `setup` run inside it. Its dispose releases what setup returned FIRST — a cassette's
39
+ * own check reads the routes the pass used — then the room. A setup that throws, or lets a host
40
+ * through, disposes both before the pass refuses.
41
+ */
42
+ export async function withExerciseSetup(
43
+ room: ExerciseRoom,
44
+ setup: ExerciseSetup | undefined,
45
+ ): Promise<ExerciseRoom> {
46
+ if (setup === undefined) return room;
47
+ const before = new Set(captureNetwork().allowed);
48
+ let handle: unknown;
49
+ try {
50
+ handle = await setup();
51
+ } catch (error) {
52
+ await room[Symbol.asyncDispose]();
53
+ throw error;
54
+ }
55
+ const dispose = async (): Promise<void> => {
56
+ try {
57
+ await release(handle);
58
+ } finally {
59
+ await room[Symbol.asyncDispose]();
60
+ }
61
+ };
62
+ const opened = captureNetwork().allowed.filter((host) => !before.has(host));
63
+ if (opened.length > 0) {
64
+ await dispose();
65
+ throw setupUnsealed(opened);
66
+ }
67
+ return { ...room, [Symbol.asyncDispose]: dispose };
68
+ }
@@ -3,7 +3,7 @@
3
3
  // (`exercise-job-tenant.ts`): a drawn org is a random one, and the enqueue refuses it.
4
4
 
5
5
  import type { Actor } from '@ultimat3/core';
6
- import { renderCauseValue, renderFixShellArg } from '@ultimat3/core';
6
+ import { renderCauseValue, renderFixShellArg, UltimateError } from '@ultimat3/core';
7
7
  import type { AnyJobHandle, JobExecution } from '@ultimat3/jobs';
8
8
  import { registeredJobs } from '@ultimat3/jobs';
9
9
  import type { Policy } from '@ultimat3/policy';
@@ -58,8 +58,22 @@ export function fixedTenant(name: string, declared: string | undefined) {
58
58
 
59
59
  const ANSWERED: ReadonlySet<string> = new Set(['completed', 'suspended']);
60
60
 
61
- const statusOfJobFailure = (run: JobExecution | undefined): string =>
62
- run === undefined ? 'the job never ran' : (run.error ?? `the run ended ${run.outcome}`);
61
+ /**
62
+ * What a failed run hands the verdict. A run whose error carried a code is that code again, so
63
+ * core's `exerciseDefect` judges it by the one rule an action's refusal meets: a coded refusal
64
+ * under 500 is the job answering "no", a 5xx or an uncoded throw is a defect. The message alone
65
+ * read every coded refusal — an app's 409 included — as an uncoded throw: X_CONTRACT_DEFECT.
66
+ */
67
+ const jobFailure = (run: JobExecution | undefined): unknown => {
68
+ if (run === undefined) return 'the job never ran';
69
+ const message = run.error ?? `the run ended ${run.outcome}`;
70
+ if (run.code === undefined) return message;
71
+ return new UltimateError({
72
+ code: run.code,
73
+ cause: message,
74
+ fix: `x errors explain ${renderFixShellArg(run.code, '<code>')} --json`,
75
+ });
76
+ };
63
77
 
64
78
  const jobSubject = (handle: AnyJobHandle): Subject => ({
65
79
  kind: 'job',
@@ -73,11 +87,11 @@ const jobSubject = (handle: AnyJobHandle): Subject => ({
73
87
  const queued = await jobs.enqueue(handle, input, { tenantId: tenant });
74
88
  const trace = await jobs.drain(actor === null ? {} : { actor });
75
89
  const own = trace.executions.find((run) => run.jobId === queued.id);
76
- // The worker's failure is a string on the row, never a throw: it is the attempt's error.
90
+ // The worker's failure is on the row, never a throw: the attempt's error and its code.
77
91
  // Suspended is a body that ran and parked (`step.sleep`, `waitForEvent`): reached.
78
92
  return own !== undefined && ANSWERED.has(own.outcome)
79
93
  ? { output: own.result }
80
- : { failure: statusOfJobFailure(own) };
94
+ : { failure: jobFailure(own) };
81
95
  },
82
96
  });
83
97
 
package/src/exercise.ts CHANGED
@@ -34,12 +34,13 @@ import type {
34
34
  Observed,
35
35
  } from './exercise-report';
36
36
  import { openExerciseRoom, overrideLine } from './exercise-report';
37
+ import { withExerciseSetup } from './exercise-setup';
37
38
  import type { Subject } from './exercise-subjects';
38
39
  import { fixedTenant, seatIn, subjectsOf } from './exercise-subjects';
39
40
  import { actorRowsOf, attachSweeps, seated, sizedCall } from './exercise-sweeps';
40
41
  import type { RunJobs } from './fixture-jobs';
41
42
  import { testJobs } from './fixture-jobs';
42
- import { pairGraph, seedPass, tenantPair } from './seed-graph-pass';
43
+ import { deferredMayStayNull, pairGraph, seedPass, tenantPair } from './seed-graph-pass';
43
44
  import type { SeedGraph, SeedWriter } from './seed-graph-plan';
44
45
  import type { SeedEntity } from './seed-graph-shape';
45
46
  import { tenantTaint } from './tenant-canary';
@@ -343,7 +344,8 @@ export async function runExercisePass(
343
344
  const sampler = await loadSampler();
344
345
  // Not `Math.random`: the frozen RNG answers the same sequence in every process.
345
346
  const passSeed = options.seed ?? (crypto.getRandomValues(new Uint32Array(1))[0] as number);
346
- const room = await openExerciseRoom();
347
+ // The app's setup runs inside the seal, before the first row: its routes answer every call.
348
+ const room = await withExerciseSetup(await openExerciseRoom(), options.overrides?.setup);
347
349
  const live = chosen.filter((subject) => !isOff(options.overrides, subject.name));
348
350
  const records: ExerciseRecord[] = [];
349
351
  let graph: SeedGraph;
@@ -372,7 +374,8 @@ export async function runExercisePass(
372
374
  return attachSweeps(records, {
373
375
  subjects: chosen.filter((subject) => reached.has(subject.name)),
374
376
  entities: options.entities,
375
- writer: options.writer,
377
+ // The sized graphs close their cycles as the pass's did: a refused deferred key stays null.
378
+ writer: deferredMayStayNull(options.writer),
376
379
  main: graph,
377
380
  seed: passSeed,
378
381
  room,
@@ -5,7 +5,8 @@
5
5
  import type { EntityLike, Factory, FactoryIds } from './factories';
6
6
  import { defineFactory } from './factories';
7
7
  import { seedGraphUnsatisfied } from './seed-graph-error';
8
- import { SEED_SOFT_DELETE_PROPERTY } from './seed-graph-shape';
8
+ import { declaredPatterns } from './seed-graph-patterns';
9
+ import { SEED_SOFT_DELETE_PROPERTY, type SeedEntity } from './seed-graph-shape';
9
10
  import { candidatesFor, isSeedColumn } from './seed-graph-values';
10
11
 
11
12
  export type EntityRegistry = Readonly<Record<string, EntityLike>>;
@@ -25,13 +26,14 @@ export function factoriesFor<TRegistry extends EntityRegistry>(
25
26
  ): FactoryRegistry<TRegistry> {
26
27
  const out: Record<string, Factory<Record<string, unknown>>> = {};
27
28
  for (const [name, entity] of Object.entries(registry)) {
29
+ const patterns = hasInvariants(entity) ? declaredPatterns(entity) : new Map();
28
30
  out[name] = defineFactory(entity, {
29
31
  ...(seed === undefined ? {} : { seed }),
30
32
  defaults: (index, ids): Record<string, unknown> => {
31
33
  const row: Record<string, unknown> = {};
32
34
  Object.entries(entity.$columns).forEach(([property, column], position) => {
33
35
  row[property] = defaultFor(
34
- { table: entity.$table, property, column, position },
36
+ { table: entity.$table, property, column, position, patterns: patterns.get(property) },
35
37
  index,
36
38
  ids,
37
39
  );
@@ -43,12 +45,21 @@ export function factoriesFor<TRegistry extends EntityRegistry>(
43
45
  return out as FactoryRegistry<TRegistry>;
44
46
  }
45
47
 
48
+ /** An `entity()` value, which declares rules; a hand-built `EntityLike` may declare none. */
49
+ const hasInvariants = (entity: object): entity is Pick<SeedEntity, '$invariants' | '$describe'> =>
50
+ '$invariants' in entity &&
51
+ Array.isArray(entity.$invariants) &&
52
+ '$describe' in entity &&
53
+ typeof entity.$describe === 'function';
54
+
46
55
  /** One column of one entity: what `defaultFor` reads. The name labels a refusal, nothing more. */
47
56
  export interface DefaultColumn {
48
57
  readonly table: string;
49
58
  readonly property: string;
50
59
  readonly column: unknown;
51
60
  readonly position: number;
61
+ /** The `matches()` patterns the entity holds this column to (`declaredPatterns`). */
62
+ readonly patterns?: readonly RegExp[] | undefined;
52
63
  }
53
64
 
54
65
  /**
@@ -68,6 +79,7 @@ export function defaultFor(target: DefaultColumn, index: number, ids: FactoryIds
68
79
  ordinal: index,
69
80
  canary: String(index),
70
81
  position: target.position,
82
+ patterns: target.patterns,
71
83
  uuid: () => ids.uuid(),
72
84
  })
73
85
  : [];
package/src/index.ts CHANGED
@@ -212,6 +212,7 @@ export type {
212
212
  TenantProof,
213
213
  } from './exercise-report';
214
214
  export { overrideLine } from './exercise-report';
215
+ export type { ExerciseSetup, ExerciseSetupHandle } from './exercise-setup';
215
216
  export type {
216
217
  Association,
217
218
  AssociationMap,
@@ -72,9 +72,12 @@ export function planRoot(answer: unknown): unknown {
72
72
 
73
73
  const IDENT = /(?:"?([A-Za-z_][\w$]*)"?\.)?"?([A-Za-z_][\w$]*)"?/g;
74
74
  const EQUALITY = /(?<![<>!])=(?!>)/;
75
+ /** `IS NULL` pins one value as `=` does, and a btree serves it as an index condition. */
76
+ const IS_NULL = /\bIS\s+NULL\b/i;
75
77
 
76
78
  /**
77
- * The columns of `scan` a condition names, split by how they are compared. A conjunct qualified by
79
+ * The columns of `scan` a condition names, split by how they are compared: `=` and `IS NULL` are
80
+ * equality, anything else a range. A conjunct qualified by
78
81
  * another alias is a join's other side; an unqualified name counts when the table has the column.
79
82
  */
80
83
  function columnsIn(
@@ -86,7 +89,7 @@ function columnsIn(
86
89
  const range: string[] = [];
87
90
  for (const condition of conditions) {
88
91
  for (const conjunct of condition.split(/\bAND\b/)) {
89
- const into = EQUALITY.test(conjunct) ? equality : range;
92
+ const into = EQUALITY.test(conjunct) || IS_NULL.test(conjunct) ? equality : range;
90
93
  for (const [, qualifier, name] of conjunct.replace(/'[^']*'/g, '').matchAll(IDENT)) {
91
94
  if (name === undefined || !columns.has(name)) continue;
92
95
  if (qualifier !== undefined && qualifier !== scan.alias && qualifier !== scan.table)
@@ -18,20 +18,66 @@ export interface PlanFinding {
18
18
  }
19
19
 
20
20
  /** Every column set the table holds unique: the primary key, unique columns, total unique rules. */
21
- function uniqueKeys(entity: SeedEntity): readonly (readonly string[])[] {
21
+ function uniqueKeys(entity: SeedEntity, statement: string): readonly (readonly string[])[] {
22
22
  const description = entity.$describe();
23
+ const holds = (where: string | null): boolean => where === null || stated(where, statement);
23
24
  return [
24
25
  description.primaryKey,
25
26
  ...description.columns.filter((column) => column.unique).map((column) => [column.column]),
26
27
  ...description.indexes
27
- .filter((index) => index.unique && index.where === null)
28
+ .filter((index) => index.unique && holds(index.where))
28
29
  .map((index) => index.columns),
29
30
  ...description.invariants
30
- .filter((rule) => rule.kind === 'unique' && rule.where === null)
31
+ .filter((rule) => rule.kind === 'unique' && holds(rule.where))
31
32
  .map((rule) => rule.columns),
32
33
  ].filter((key) => key.length > 0);
33
34
  }
34
35
 
36
+ /** Lower case outside literals, no identifier quotes, no parentheses, single spaces. */
37
+ const normalized = (sql: string): string =>
38
+ sql
39
+ .split(/('[^']*')/)
40
+ .map((part, at) => (at % 2 === 1 ? part : part.toLowerCase().replace(/["()]/g, ' ')))
41
+ .join('')
42
+ .replace(/\s+/g, ' ')
43
+ .trim();
44
+
45
+ /**
46
+ * A partial rule holds for a statement that states its predicate, conjunct for conjunct:
47
+ * `deleted_at is null` is in every soft-deleted entity's `one()`. Text, so a predicate the
48
+ * statement implies some other way is not credited — a finding, never a false pass.
49
+ */
50
+ function stated(where: string, statement: string): boolean {
51
+ const text = ` ${normalized(statement)} `;
52
+ return normalized(where)
53
+ .split(/\band\b/)
54
+ .every((conjunct) => text.includes(` ${conjunct.trim()} `));
55
+ }
56
+
57
+ /** Every non-partial index the entity declares, in column order: primary key and unique ones too. */
58
+ function declaredIndexes(entity: SeedEntity, statement: string): readonly (readonly string[])[] {
59
+ const description = entity.$describe();
60
+ return [
61
+ ...uniqueKeys(entity, statement),
62
+ ...description.indexes.filter((index) => index.where === null).map((index) => index.columns),
63
+ ];
64
+ }
65
+
66
+ /**
67
+ * A walk filtered by a range is served by an index that leads with the equality columns and then
68
+ * a range column: it bounds the rows and the planner sorts the few left. A generic plan with sorts
69
+ * off still prefers the ordered walk, so the oracle cannot see it. Credited, never re-proven.
70
+ */
71
+ const servedByRange = (keys: readonly (readonly string[])[], verdict: PlanVerdict): boolean =>
72
+ keys.some((key) => {
73
+ const next = key[verdict.equality.length];
74
+ return (
75
+ next !== undefined &&
76
+ verdict.range.includes(next) &&
77
+ key.slice(0, verdict.equality.length).every((column) => verdict.equality.includes(column))
78
+ );
79
+ });
80
+
35
81
  const IDENTIFIER = /"([A-Za-z_][\w$]*)"|\b([A-Za-z_][\w$]*)\b/g;
36
82
 
37
83
  /**
@@ -95,16 +141,22 @@ export function planFindings(
95
141
  const entity = byTable.get(verdict.table);
96
142
  if (entity === undefined) return [];
97
143
  if (verdict.code === 'X_QUERY_UNINDEXED' && sorted.has(verdict.table)) return [];
98
- const pinned = uniqueKeys(entity).some((key) =>
144
+ const pinned = uniqueKeys(entity, statement).some((key) =>
99
145
  key.every((column) => verdict.equality.includes(column)),
100
146
  );
101
147
  if (pinned) return [];
102
148
  const description = entity.$describe();
103
149
  const byColumn = new Map(description.columns.map((c) => [c.column, c.property]));
104
150
  const walked = verdict.code === 'X_QUERY_UNINDEXED' && verdict.node !== 'Seq Scan';
105
- const order = walked
106
- ? orderOf(statement, new Set(description.columns.map((c) => c.column)))
107
- : verdict.order;
151
+ // A walk filtered by a range already has its order: what it lacks is the bound. A btree serves
152
+ // no order after a range column, so the index is the equality columns, then the range.
153
+ const ranged = walked && verdict.range.length > 0;
154
+ if (ranged && servedByRange(declaredIndexes(entity, statement), verdict)) return [];
155
+ const order = ranged
156
+ ? []
157
+ : walked
158
+ ? orderOf(statement, new Set(description.columns.map((c) => c.column)))
159
+ : verdict.order;
108
160
  if (servedByPartial(entity, verdict, order)) return [];
109
161
  const tenant = description.columns.find((c) => c.property === entity.$tenantColumn)?.column;
110
162
  const lead = tenant !== undefined && verdict.equality.includes(tenant) ? [tenant] : [];
@@ -24,7 +24,25 @@ const excerpt = (statement: string): string =>
24
24
  const indexLine = (finding: PlanFinding): string =>
25
25
  `indexes: [{ on: [${finding.index.map((p) => `'${p}'`).join(', ')}] }]`;
26
26
 
27
+ /**
28
+ * A read that compares no column of the table — a whole-table count, an unfiltered export — is
29
+ * narrowed by no index: `on: []` is a line `x db gen` refuses. What ends it is saying why it may
30
+ * read every row, around the call or on the entity, or giving it a condition.
31
+ */
32
+ function wholeRead(finding: PlanFinding, senders: readonly ExerciseRecord[]): SweepFinding {
33
+ const by = senders.map(primitive).join(', ');
34
+ const entity = renderFixLiteral(finding.entity, '<entity>');
35
+ const rerun = renderFixShellArg(senders[0]?.name ?? '', '<primitive>');
36
+ return {
37
+ code: finding.code,
38
+ cause: `${by} sent ${renderCauseValue(excerpt(finding.statement))}, which reads ${finding.table} whole (${finding.node}) and compares no column of it: no index narrows it, so it slows with every row the table gains`,
39
+ fix: `x exercise ${rerun} --json # after expectedSeqScan('<why it reads every row>', fn) around the read, or scan: '<why it stays small>' in entity(${entity}), or a where clause that bounds it`,
40
+ at: senders[0] === undefined ? finding.entity : primitive(senders[0]),
41
+ };
42
+ }
43
+
27
44
  function planFinding(finding: PlanFinding, senders: readonly ExerciseRecord[]): SweepFinding {
45
+ if (finding.index.length === 0) return wholeRead(finding, senders);
28
46
  const by = senders.map(primitive).join(', ');
29
47
  const entity = renderFixLiteral(finding.entity, '<entity>');
30
48
  const name = renderFixShellArg(`${finding.table}-${finding.index.join('-')}-index`, '<name>');
@@ -72,9 +90,10 @@ export function planSweepFindings(records: readonly ExerciseRecord[]): readonly
72
90
  /** One line per red: `action listPosts · X_QUERY_UNINDEXED posts on [orgId, authorId]`. */
73
91
  export function planSweepLines(records: readonly ExerciseRecord[]): readonly string[] {
74
92
  return records.flatMap((record) => [
75
- ...(record.plans?.findings ?? []).map(
76
- (finding) =>
77
- `${primitive(record)} · ${finding.code} ${finding.table} on [${finding.index.join(', ')}]`,
93
+ ...(record.plans?.findings ?? []).map((finding) =>
94
+ finding.index.length === 0
95
+ ? `${primitive(record)} · ${finding.code} ${finding.table}, every row (no column to index)`
96
+ : `${primitive(record)} · ${finding.code} ${finding.table} on [${finding.index.join(', ')}]`,
78
97
  ),
79
98
  ...(record.scale?.verdict === 'grows' ? record.scale.shapes : []).map(
80
99
  (shape) =>
@@ -39,7 +39,7 @@ import {
39
39
  * | locales / catalogs | `resetCatalogs` | `@ultimat3/i18n` | yes |
40
40
  * | permissions / roles | `restorePermissions` / `restoreRoles` | `@ultimat3/policy` | yes |
41
41
  * | routes | `clearRoutes` | `@ultimat3/render` | **no** |
42
- * | jobs | `resetJobs` | `@ultimat3/jobs` | **no** |
42
+ * | jobs | `resetJobs` / `restoreJobs` | `@ultimat3/jobs` | **no** — isolated per test |
43
43
  * | tasks | `restoreTasks` | `@ultimat3/jobs` | yes (22.7) |
44
44
  * | measurement actor | `resetMeasurementActor` | `@ultimat3/core` | yes |
45
45
  * | actions | `resetActions` | `@ultimat3/action` | **no** |
@@ -50,7 +50,7 @@ import {
50
50
  *
51
51
  * Every "no" needs the SAME two halves the two "yes" rows have: a reader and a writer in the owning
52
52
  * package (`restorePermissions` was added to `@ultimat3/policy` for exactly this), and one line
53
- * here. `@ultimat3/jobs`, `@ultimat3/action`, `@ultimat3/query` and `@ultimat3/mail` publish a
53
+ * here. `@ultimat3/action`, `@ultimat3/query` and `@ultimat3/mail` publish a
54
54
  * lister and a reset but no restore, so the change is theirs first and this file's second — and it
55
55
  * has to land as ONE change, because the cost of the rest is a second edit of this same shape.
56
56
  */
@@ -1,7 +1,7 @@
1
1
  // Insert order for a seed graph: every parent before its children, over the foreign keys the
2
- // entities themselves resolve (`$references()`, the record `relationsOf` is built from). A cycle is
3
- // broken at a NULLABLE edge — inserted null, set by an update once its target exists; a cycle with
4
- // no nullable edge is refused, because no insert order can satisfy it.
2
+ // entities themselves resolve (`$references()`). A cycle is broken at a NULLABLE edge — inserted
3
+ // null, set by an update once its target exists — preferring a table that takes updates (never
4
+ // `appendOnly`); a cycle with no nullable edge is refused: no insert order can satisfy it.
5
5
 
6
6
  import type { ReferenceDescription } from '@ultimat3/entity';
7
7
  import { seedGraphUnsatisfied } from './seed-graph-error';
@@ -9,6 +9,8 @@ import { seedGraphUnsatisfied } from './seed-graph-error';
9
9
  export interface OrderInput {
10
10
  readonly name: string;
11
11
  readonly references: readonly ReferenceDescription[];
12
+ /** False for a table whose rows are never updated (`appendOnly`). Default true. */
13
+ readonly updatable?: boolean;
12
14
  }
13
15
 
14
16
  export interface InsertOrder {
@@ -44,6 +46,7 @@ const reaches = (
44
46
  export function insertOrder(entities: readonly OrderInput[]): InsertOrder {
45
47
  const names = new Set(entities.map((entity) => entity.name));
46
48
  const edges = new Map(entities.map((entity) => [entity.name, entity.references]));
49
+ const frozen = new Set(entities.filter((e) => e.updatable === false).map((e) => e.name));
47
50
  const deferred = new Set<string>();
48
51
  // A self-reference never orders anything: the row's own table is already being written.
49
52
  const live = (from: string, reference: ReferenceDescription): boolean =>
@@ -76,9 +79,10 @@ export function insertOrder(entities: readonly OrderInput[]): InsertOrder {
76
79
  !placed.has(reference.targetEntity) &&
77
80
  reaches(edges, live, reference.targetEntity, name),
78
81
  )
79
- .map((reference) => edgeKey(name, reference.property)),
82
+ .map((reference) => [name, edgeKey(name, reference.property)] as const),
80
83
  );
81
- const edge = breakable[0];
84
+ // Name order still decides between equals, so the same set always breaks at the same edge.
85
+ const edge = (breakable.find(([name]) => !frozen.has(name)) ?? breakable[0])?.[1];
82
86
  if (edge === undefined) {
83
87
  const cyclic = stuck.filter((name) =>
84
88
  (edges.get(name) ?? []).some(
@@ -41,6 +41,31 @@ const refused = (write: SeedRow | SeedUpdate, error: unknown): unknown => {
41
41
  });
42
42
  };
43
43
 
44
+ const refusedRow = (error: unknown): boolean =>
45
+ !isUltimateError(error) || ROW_REFUSALS.has(error.code) || error.code === 'X_ENTITY_APPEND_ONLY';
46
+
47
+ /**
48
+ * A deferred key the database refuses to set is left null: only a nullable key is ever deferred
49
+ * (`seed-graph-order.ts`), so the row as inserted is already a valid one. The refusal is a rule no
50
+ * declaration carries — a trigger that refuses every UPDATE of the table. An environment failure
51
+ * still rejects with its own code. `onRefused` hears each update left out.
52
+ */
53
+ export const deferredMayStayNull = (
54
+ writer: SeedWriter,
55
+ onRefused: (update: SeedUpdate) => void = () => undefined,
56
+ ): SeedWriter => ({
57
+ insert: (row) => writer.insert(row),
58
+ ...(writer.insertMany === undefined
59
+ ? {}
60
+ : { insertMany: (rows: readonly SeedRow[]) => writer.insertMany?.(rows) ?? Promise.resolve() }),
61
+ update: (update) =>
62
+ writer.update(update).catch((error: unknown) => {
63
+ if (!refusedRow(error)) return Promise.reject(error);
64
+ onRefused(update);
65
+ }),
66
+ snapshot: (rows) => writer.snapshot(rows),
67
+ });
68
+
44
69
  /** Every write coded: a row refusal names its factory, an environment failure keeps its own fix. */
45
70
  const coded = (writer: SeedWriter): SeedWriter => ({
46
71
  insert: (row) =>
@@ -146,6 +171,10 @@ export async function seedPass(
146
171
  seed: options.seed,
147
172
  ...(options.factories === undefined ? {} : { factories: options.factories }),
148
173
  });
149
- await insertSeedGraph(graph, coded(writer));
150
- return graph;
174
+ const refused = new Set<SeedUpdate>();
175
+ await insertSeedGraph(graph, coded(deferredMayStayNull(writer, (update) => refused.add(update))));
176
+ // What was written: an update the database refused is no part of the seeded state.
177
+ return refused.size === 0
178
+ ? graph
179
+ : { ...graph, updates: graph.updates.filter((update) => !refused.has(update)) };
151
180
  }
@@ -0,0 +1,51 @@
1
+ // The `matches(/…/)` patterns an entity declares, per property. Read back from each invariant's
2
+ // own CHECK text, which `expr.ts` builds as `<column> ~ <literal(source)>` (or `btrim(<column>)`,
3
+ // `~*` for `/i`) — the same source `pattern.test` runs, so nothing here is a guess. A rule of any
4
+ // other shape (`iff`, a predicate, two columns) is not a pattern and is skipped.
5
+
6
+ import type { SeedEntity } from './seed-graph-shape';
7
+
8
+ /** `col ~ E'src'` / `btrim(col) ~* 'src'`: the whole CHECK, one column, one quoted literal. */
9
+ const PATTERN_CHECK = /^(?:btrim\(([a-z_][a-z0-9_$]*)\)|([a-z_][a-z0-9_$]*)) (~\*?) (E?)'(.*)'$/s;
10
+
11
+ /**
12
+ * The `RegExp` one CHECK enforces on `column`, or `undefined`. The literal is undone exactly as
13
+ * `@ultimat3/db`'s `literal()` made it: `''` is one quote, and under `E'…'` `\\` is one backslash.
14
+ */
15
+ export function patternOfCheck(sql: string, column: string): RegExp | undefined {
16
+ const match = PATTERN_CHECK.exec(sql);
17
+ if (match === null || (match[1] ?? match[2]) !== column) return undefined;
18
+ const body = match[5] ?? '';
19
+ // A lone quote inside the body is not something `literal()` writes: not this shape after all.
20
+ if (body.replaceAll("''", '').includes("'")) return undefined;
21
+ const unquoted = body.replaceAll("''", "'");
22
+ const source = match[4] === 'E' ? unquoted.replaceAll('\\\\', '\\') : unquoted;
23
+ try {
24
+ return new RegExp(source, match[3] === '~*' ? 'i' : '');
25
+ } catch {
26
+ // A source JavaScript cannot compile is no pattern to sample from; the solver still judges
27
+ // every candidate by the rule itself, so skipping it loses a hint and never a check.
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ /** Property -> the patterns its `check` invariants declare. Empty for an entity with none. */
33
+ export function declaredPatterns(
34
+ entity: Pick<SeedEntity, '$invariants' | '$describe'>,
35
+ ): ReadonlyMap<string, readonly RegExp[]> {
36
+ const propertyOf = new Map(
37
+ entity.$describe().columns.map((column) => [column.column, column.property] as const),
38
+ );
39
+ const out = new Map<string, RegExp[]>();
40
+ for (const invariant of entity.$invariants) {
41
+ const [column] = invariant.columns;
42
+ if (invariant.kind !== 'check' || invariant.sql === null || invariant.columns.length !== 1) {
43
+ continue;
44
+ }
45
+ const property = column === undefined ? undefined : propertyOf.get(column);
46
+ const pattern = column === undefined ? undefined : patternOfCheck(invariant.sql, column);
47
+ if (property === undefined || pattern === undefined) continue;
48
+ out.set(property, [...(out.get(property) ?? []), pattern]);
49
+ }
50
+ return out;
51
+ }
@@ -1,8 +1,12 @@
1
1
  // A value for one column, read off the column's own declaration — its kind, nullability, enumerated
2
- // values, length, money parts and default — never off its name. Every candidate is run through the
3
- // column's `$parse`, which refuses exactly what its CHECK refuses, so what survives is storable.
2
+ // values, length, money parts, default, `matches()` patterns and `json(schema)` — never off its
3
+ // name. Every candidate is run through the column's `$parse`, which refuses exactly what its CHECK
4
+ // refuses, so what survives is storable.
4
5
 
6
+ import { fnv1a } from '@ultimat3/core';
5
7
  import type { ColumnMeta } from '@ultimat3/entity';
8
+ import type * as Sample from '@ultimat3/schema/sample';
9
+ import { patternSamples } from './seed-pattern-sample';
6
10
 
7
11
  /** A column as `entity()` declares it, structurally: this barrel never loads `@ultimat3/entity`. */
8
12
  export interface SeedColumn {
@@ -18,6 +22,11 @@ export interface ValueContext {
18
22
  readonly canary: string;
19
23
  /** Declaration position of the column, so two instants on one row are ordered as declared. */
20
24
  readonly position: number;
25
+ /**
26
+ * The `matches(/…/)` patterns the entity's invariants hold this column to (`declaredPatterns`).
27
+ * Each one adds strings built from the pattern itself, so a rule no fixed shape meets is met.
28
+ */
29
+ readonly patterns?: readonly RegExp[] | undefined;
21
30
  uuid(): string;
22
31
  }
23
32
 
@@ -59,7 +68,46 @@ const textCandidates = (property: string, meta: ColumnMeta, ctx: ValueContext):
59
68
  `https://seed.test/${word}/${ctx.canary}`,
60
69
  ctx.canary,
61
70
  ];
62
- return meta.length === undefined ? shapes : [...shapes, ctx.canary.slice(0, meta.length)];
71
+ const sized = meta.length === undefined ? shapes : [...shapes, ctx.canary.slice(0, meta.length)];
72
+ return [...sized, ...fromPatterns(property, ctx)];
73
+ };
74
+
75
+ /** Strings drawn from each declared pattern, kept only where every other pattern agrees. */
76
+ const fromPatterns = (property: string, ctx: ValueContext): string[] => {
77
+ const patterns = ctx.patterns ?? [];
78
+ return patterns
79
+ .flatMap((pattern) => patternSamples(pattern, `${property}:${ctx.canary}`))
80
+ .filter((sample) => patterns.every((pattern) => pattern.test(sample)));
81
+ };
82
+
83
+ let loadedSampler: typeof Sample | undefined;
84
+
85
+ /**
86
+ * `@ultimat3/schema/sample`, loaded on the first `json(schema)` column and never at import: this
87
+ * module is on the barrel, and the walker is a dynamic-only dependency there (`CLAUDE.md`).
88
+ * `require` and not `import()` because every caller of `candidatesFor` is synchronous.
89
+ */
90
+ const sampler = (): typeof Sample => {
91
+ loadedSampler ??= require('@ultimat3/schema/sample') as typeof Sample;
92
+ return loadedSampler;
93
+ };
94
+
95
+ /**
96
+ * Values the column's own schema accepts, seeded by the row so two rows differ and a rerun does
97
+ * not; then the bare `{}`/`[]`, which a schema-less `jsonb` column stores.
98
+ */
99
+ const jsonCandidates = (property: string, meta: ColumnMeta, ctx: ValueContext): unknown[] => {
100
+ const schema = meta.schema;
101
+ if (schema === undefined) return [{}, []];
102
+ const { arbitrary, sampleInput, BOUNDARY_RUNS } = sampler();
103
+ const seed = fnv1a(`${property}:${ctx.canary}`);
104
+ return [
105
+ arbitrary(schema, { seed, run: BOUNDARY_RUNS }),
106
+ arbitrary(schema, { seed, run: 0 }),
107
+ sampleInput(schema),
108
+ {},
109
+ [],
110
+ ];
63
111
  };
64
112
 
65
113
  const byKind = (property: string, meta: ColumnMeta, ctx: ValueContext): unknown[] => {
@@ -84,7 +132,7 @@ const byKind = (property: string, meta: ColumnMeta, ctx: ValueContext): unknown[
84
132
  case 'date':
85
133
  return [`2026-01-${String((n % 28) + 1).padStart(2, '0')}`];
86
134
  case 'jsonb':
87
- return [{}, []];
135
+ return jsonCandidates(property, meta, ctx);
88
136
  case 'bytea':
89
137
  return [new Uint8Array([n % 256])];
90
138
  case 'array':
@@ -123,9 +171,20 @@ export function candidatesFor(
123
171
  out.push(result.ok);
124
172
  }
125
173
  }
126
- return out;
174
+ return keepsPatternsFirst(out, ctx.patterns ?? []);
127
175
  }
128
176
 
177
+ /**
178
+ * Stable: what keeps every declared pattern moves ahead, so `factoriesFor`, which takes the FIRST
179
+ * candidate and runs no solver, builds a row its own rules accept.
180
+ */
181
+ const keepsPatternsFirst = (values: unknown[], patterns: readonly RegExp[]): unknown[] => {
182
+ if (patterns.length === 0) return values;
183
+ const keeps = (value: unknown): boolean =>
184
+ typeof value === 'string' && patterns.every((pattern) => pattern.test(value));
185
+ return [...values.filter(keeps), ...values.filter((value) => !keeps(value))];
186
+ };
187
+
129
188
  /** Candidates offered verbatim (a factory's value), kept only where the column stores them. */
130
189
  export function storable(column: SeedColumn, values: readonly unknown[]): readonly unknown[] {
131
190
  return values.flatMap((value) => {
package/src/seed-graph.ts CHANGED
@@ -7,6 +7,7 @@ import type { ReferenceDescription } from '@ultimat3/entity';
7
7
  import { DEFAULT_SEED, seededUuid } from './determinism';
8
8
  import { seedGraphUnsatisfied } from './seed-graph-error';
9
9
  import { edgeKey, insertOrder } from './seed-graph-order';
10
+ import { declaredPatterns } from './seed-graph-patterns';
10
11
  import type {
11
12
  SeedCanary,
12
13
  SeedGraph,
@@ -91,6 +92,7 @@ export function seedGraph(
91
92
  [...shapes.values()].map((shape) => ({
92
93
  name: shape.name,
93
94
  references: [...shape.references.values()],
95
+ updatable: shape.entity.$describe().appendOnly !== true,
94
96
  })),
95
97
  );
96
98
  const tenantStream = seededUuid(streamSeed(seed, '$tenants'));
@@ -163,6 +165,8 @@ export function seedGraph(
163
165
  const allUniques = [...shape.uniques, ...shape.partialUniques];
164
166
  const taken = allUniques.map(() => new Set<string>());
165
167
  const rootProperty = roots.get(name);
168
+ // Read once per table: each `matches(/…/)` an invariant holds a column to, sampled from.
169
+ const patterns = declaredPatterns(entity);
166
170
  const buckets: readonly (string | null)[] = scoped(name) ? tenants : [null];
167
171
  let ordinal = 0;
168
172
  for (const [bucket, tenant] of buckets.entries()) {
@@ -195,6 +199,7 @@ export function seedGraph(
195
199
  ordinal,
196
200
  canary,
197
201
  position,
202
+ patterns: patterns.get(property),
198
203
  uuid: stream,
199
204
  });
200
205
  return [
@@ -257,6 +262,8 @@ export function seedGraph(
257
262
  const updates: SeedUpdate[] = [];
258
263
  for (const name of order) {
259
264
  const shape = shapes.get(name) as EntityShape;
265
+ // An appendOnly row is never updated (its trigger refuses): a key deferred there stays null.
266
+ if (shape.entity.$describe().appendOnly === true) continue;
260
267
  const later = shape.columns.filter(([property]) => deferred.has(edgeKey(name, property)));
261
268
  if (later.length === 0) continue;
262
269
  for (const { tenant, row, index } of placed.get(name) ?? []) {
@@ -0,0 +1,216 @@
1
+ // Strings a declared `matches(/…/)` pattern accepts, built by walking the pattern rather than
2
+ // guessing shapes. The common subset only — literals, classes, `.`, groups, alternation,
3
+ // quantifiers, anchors — and an honest `[]` for anything past it (lookaround, backreference, `\b`,
4
+ // `\p{…}`): a pattern this cannot read leaves the column's other candidates to the solver.
5
+
6
+ import { fnv1a } from '@ultimat3/core';
7
+ import { seededRandom } from './determinism';
8
+
9
+ type Node =
10
+ | { readonly kind: 'chars'; readonly chars: readonly string[] }
11
+ | { readonly kind: 'alt'; readonly branches: readonly (readonly Piece[])[] }
12
+ | { readonly kind: 'empty' };
13
+
14
+ interface Piece {
15
+ readonly node: Node;
16
+ readonly min: number;
17
+ readonly max: number;
18
+ }
19
+
20
+ /** How far past its floor an unbounded quantifier draws: enough to vary, short enough to store. */
21
+ const UNBOUNDED_SPAN = 6;
22
+
23
+ /** The universe a class or `.` picks from: printable ASCII, so every sample is plain text. */
24
+ const PRINTABLE = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i));
25
+ const DIGITS = PRINTABLE.filter((c) => c >= '0' && c <= '9');
26
+ const WORD = PRINTABLE.filter((c) => /\w/.test(c));
27
+ const SPACE = [' ', '\t'];
28
+
29
+ const SHORTHAND: Readonly<Record<string, readonly string[]>> = {
30
+ d: DIGITS,
31
+ D: PRINTABLE.filter((c) => !DIGITS.includes(c)),
32
+ w: WORD,
33
+ W: PRINTABLE.filter((c) => !WORD.includes(c)),
34
+ s: SPACE,
35
+ S: PRINTABLE.filter((c) => c !== ' '),
36
+ };
37
+
38
+ const CONTROL: Readonly<Record<string, string>> = { t: '\t', n: '\n', r: '\r', f: '\f', v: '\v' };
39
+
40
+ class Parser {
41
+ private at = 0;
42
+ /** The first construct past the subset; set once, and it ends the walk. */
43
+ private refused: string | undefined;
44
+ constructor(private readonly source: string) {}
45
+
46
+ /** The tree, or `undefined` when any part of the pattern is past the subset. */
47
+ parse(): Node | undefined {
48
+ const node = this.alternation();
49
+ if (this.refused === undefined && this.at < this.source.length) this.refuse('a stray ")"');
50
+ return this.refused === undefined ? node : undefined;
51
+ }
52
+
53
+ /** Records the refusal and moves to the end, so every loop above stops on its next look. */
54
+ private refuse(construct: string): readonly string[] {
55
+ this.refused ??= construct;
56
+ this.at = this.source.length;
57
+ return [];
58
+ }
59
+
60
+ private peek(): string | undefined {
61
+ return this.source[this.at];
62
+ }
63
+
64
+ private alternation(): Node {
65
+ const branches: Piece[][] = [this.sequence()];
66
+ while (this.peek() === '|') {
67
+ this.at += 1;
68
+ branches.push(this.sequence());
69
+ }
70
+ return { kind: 'alt', branches };
71
+ }
72
+
73
+ private sequence(): Piece[] {
74
+ const pieces: Piece[] = [];
75
+ for (let c = this.peek(); c !== undefined && c !== '|' && c !== ')'; c = this.peek()) {
76
+ const node = this.atom();
77
+ const [min, max] = this.quantifier();
78
+ pieces.push({ node, min, max });
79
+ }
80
+ return pieces;
81
+ }
82
+
83
+ private quantifier(): readonly [number, number] {
84
+ const c = this.peek();
85
+ const bounds = /^\{(\d+)(,(\d*))?\}/.exec(this.source.slice(this.at));
86
+ let range: readonly [number, number] | undefined;
87
+ if (c === '*') range = [0, UNBOUNDED_SPAN];
88
+ else if (c === '+') range = [1, 1 + UNBOUNDED_SPAN];
89
+ else if (c === '?') range = [0, 1];
90
+ else if (bounds !== null) {
91
+ const low = Number(bounds[1]);
92
+ const high =
93
+ bounds[2] === undefined ? low : bounds[3] === '' ? low + UNBOUNDED_SPAN : Number(bounds[3]);
94
+ range = [low, high];
95
+ }
96
+ if (range === undefined) return [1, 1];
97
+ this.at += bounds !== null && c === '{' ? bounds[0].length : 1;
98
+ // A lazy quantifier matches the same strings; only the engine's preference differs.
99
+ if (this.peek() === '?') this.at += 1;
100
+ return range;
101
+ }
102
+
103
+ private atom(): Node {
104
+ const c = this.peek() as string;
105
+ this.at += 1;
106
+ if (c === '^' || c === '$') return { kind: 'empty' };
107
+ if (c === '.') return { kind: 'chars', chars: PRINTABLE };
108
+ if (c === '[') return { kind: 'chars', chars: this.charClass() };
109
+ if (c === '(') return this.group();
110
+ if (c === '\\') return { kind: 'chars', chars: this.escape(false) };
111
+ if (c === '*' || c === '+' || c === '?' || c === '{')
112
+ return { kind: 'chars', chars: this.refuse(`a bare "${c}"`) };
113
+ return { kind: 'chars', chars: [c] };
114
+ }
115
+
116
+ private group(): Node {
117
+ if (this.peek() === '?') {
118
+ const head = this.source.slice(this.at, this.at + 3);
119
+ if (head.startsWith('?:')) this.at += 2;
120
+ else if (/^\?<[A-Za-z_$]/.test(head) && this.source.includes('>', this.at)) {
121
+ this.at = this.source.indexOf('>', this.at) + 1;
122
+ } else return { kind: 'chars', chars: this.refuse(`the lookaround "(${head}"`) };
123
+ }
124
+ const inner = this.alternation();
125
+ if (this.peek() !== ')') return { kind: 'chars', chars: this.refuse('an unclosed group') };
126
+ this.at += 1;
127
+ return inner;
128
+ }
129
+
130
+ private escape(inClass: boolean): readonly string[] {
131
+ const c = this.peek();
132
+ if (c === undefined) return this.refuse('a trailing backslash');
133
+ this.at += 1;
134
+ const shorthand = Object.hasOwn(SHORTHAND, c) ? SHORTHAND[c] : undefined;
135
+ if (shorthand !== undefined) return shorthand;
136
+ const control = Object.hasOwn(CONTROL, c) ? CONTROL[c] : undefined;
137
+ if (control !== undefined) return [control];
138
+ const hex = c === 'x' ? 2 : c === 'u' ? 4 : 0;
139
+ if (hex > 0) {
140
+ const digits = this.source.slice(this.at, this.at + hex);
141
+ if (!/^[0-9a-fA-F]+$/.test(digits) || digits.length !== hex) return this.refuse(`\\${c}`);
142
+ this.at += hex;
143
+ return [String.fromCharCode(Number.parseInt(digits, 16))];
144
+ }
145
+ // `\b`, `\B`, a backreference, `\p{…}`, `\k<…>`, `\0`: a position or a reference, not a char.
146
+ if (/[A-Za-z0-9]/.test(c)) return this.refuse(`\\${c}${inClass ? ' in a class' : ''}`);
147
+ return [c];
148
+ }
149
+
150
+ /** The members of `[…]`, drawn from printable ASCII; a negated class is its complement there. */
151
+ private charClass(): readonly string[] {
152
+ const negated = this.peek() === '^';
153
+ if (negated) this.at += 1;
154
+ const members = new Set<string>();
155
+ let first = true;
156
+ for (let c = this.peek(); c !== ']' || first; c = this.peek()) {
157
+ if (c === undefined) return this.refuse('an unclosed class');
158
+ first = false;
159
+ const low = this.classMember();
160
+ if (low.length === 1 && this.peek() === '-' && this.source[this.at + 1] !== ']') {
161
+ this.at += 1;
162
+ const high = this.classMember();
163
+ if (high.length !== 1) return this.refuse('a range ending in a shorthand');
164
+ const [from, to] = [(low[0] as string).charCodeAt(0), (high[0] as string).charCodeAt(0)];
165
+ // Past printable ASCII the range's own first member stands in, so `[À-ÿ]` still yields one.
166
+ const inside = PRINTABLE.filter((ch) => ch.charCodeAt(0) >= from && ch.charCodeAt(0) <= to);
167
+ for (const ch of inside.length > 0 ? inside : [String.fromCharCode(from)]) members.add(ch);
168
+ } else {
169
+ for (const ch of low) members.add(ch);
170
+ }
171
+ }
172
+ this.at += 1;
173
+ return negated ? PRINTABLE.filter((ch) => !members.has(ch)) : [...members];
174
+ }
175
+
176
+ private classMember(): readonly string[] {
177
+ const c = this.peek() as string;
178
+ this.at += 1;
179
+ return c === '\\' ? this.escape(true) : [c];
180
+ }
181
+ }
182
+
183
+ const draw = (node: Node, random: () => number): string => {
184
+ const pick = <T>(items: readonly T[]): T | undefined =>
185
+ items[Math.floor(random() * items.length)];
186
+ if (node.kind === 'empty') return '';
187
+ if (node.kind === 'chars') return pick(node.chars) ?? '';
188
+ const branch = pick(node.branches) ?? [];
189
+ return branch
190
+ .map((piece) => {
191
+ const count = piece.min + Math.floor(random() * (piece.max - piece.min + 1));
192
+ return Array.from({ length: count }, () => draw(piece.node, random)).join('');
193
+ })
194
+ .join('');
195
+ };
196
+
197
+ /** Tries per sample: a draw that crosses an anchor (`a^b`) or a flag is retried, never returned. */
198
+ const ATTEMPTS = 8;
199
+
200
+ /**
201
+ * Up to `count` distinct strings `pattern.test` accepts, the same ones for the same `seed` on any
202
+ * machine. Empty when the pattern is past the subset, or when no draw lands inside it.
203
+ */
204
+ export function patternSamples(pattern: RegExp, seed: string, count = 4): readonly string[] {
205
+ const tree = new Parser(pattern.source).parse();
206
+ if (tree === undefined) return [];
207
+ const random = seededRandom(fnv1a(`${pattern.source}/${pattern.flags}:${seed}`) || 1);
208
+ // `lastIndex` would make `test` stateful under `g`/`y`; a copy without them judges each draw.
209
+ const judge = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
210
+ const out: string[] = [];
211
+ for (let attempt = 0; attempt < count * ATTEMPTS && out.length < count; attempt += 1) {
212
+ const sample = draw(tree, random);
213
+ if (judge.test(sample) && !out.includes(sample)) out.push(sample);
214
+ }
215
+ return out;
216
+ }
@@ -8,6 +8,7 @@ import { ctxOf, finiteOption, runWithContext, serviceActor } from '@ultimat3/cor
8
8
  import type { Subject, SubjectPlan } from '@ultimat3/jobs';
9
9
  import { memoryStepStore, planSubject, stepRunner, subjectEraseTables } from '@ultimat3/jobs';
10
10
  import { seedGraph } from './seed-graph';
11
+ import { deferredMayStayNull } from './seed-graph-pass';
11
12
  import type { SeedFactory, SeedGraph, SeedWriter } from './seed-graph-plan';
12
13
  import { insertSeedGraph } from './seed-graph-plan';
13
14
  import type { SeedEntity } from './seed-graph-shape';
@@ -111,7 +112,8 @@ export async function subjectProof<I>(
111
112
  tenants: 2,
112
113
  ...(options.factories === undefined ? {} : { factories: options.factories }),
113
114
  });
114
- await insertSeedGraph(graph, options.writer);
115
+ // The pass's rule: a nullable cycle key the database refuses to set stays null.
116
+ await insertSeedGraph(graph, deferredMayStayNull(options.writer));
115
117
  const tenant = graph.tenants[0] ?? null;
116
118
  const person = graph.inserts.find(
117
119
  (i) => i.entity === subject.entity && (i.tenant === tenant || i.tenant === null),