@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 +2 -2
- package/README.md +2 -1
- package/package.json +17 -17
- package/src/define-exercise.ts +11 -0
- package/src/errors.ts +2 -0
- package/src/exercise-job-tenant.ts +29 -2
- package/src/exercise-report.ts +7 -2
- package/src/exercise-setup.ts +68 -0
- package/src/exercise-subjects.ts +19 -5
- package/src/exercise.ts +6 -3
- package/src/factory-registry.ts +14 -2
- package/src/index.ts +1 -0
- package/src/plan-oracle.ts +5 -2
- package/src/plan-sweep-fix.ts +59 -7
- package/src/plan-sweep-report.ts +22 -3
- package/src/registry-leak-guard.ts +2 -2
- package/src/seed-graph-order.ts +9 -5
- package/src/seed-graph-pass.ts +31 -2
- package/src/seed-graph-patterns.ts +51 -0
- package/src/seed-graph-values.ts +64 -5
- package/src/seed-graph.ts +7 -0
- package/src/seed-pattern-sample.ts +216 -0
- package/src/subject-proof.ts +3 -1
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
|
+
"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.
|
|
50
|
-
"@ultimat3/cache": "30.
|
|
51
|
-
"@ultimat3/core": "30.
|
|
52
|
-
"@ultimat3/db": "30.
|
|
53
|
-
"@ultimat3/entity": "30.
|
|
54
|
-
"@ultimat3/http": "30.
|
|
55
|
-
"@ultimat3/i18n": "30.
|
|
56
|
-
"@ultimat3/jobs": "30.
|
|
57
|
-
"@ultimat3/mail": "30.
|
|
58
|
-
"@ultimat3/policy": "30.
|
|
59
|
-
"@ultimat3/pwa": "30.
|
|
60
|
-
"@ultimat3/query": "30.
|
|
61
|
-
"@ultimat3/realtime": "30.
|
|
62
|
-
"@ultimat3/render": "30.
|
|
63
|
-
"@ultimat3/schema": "30.
|
|
64
|
-
"@ultimat3/time": "30.
|
|
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
|
}
|
package/src/define-exercise.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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 : {};
|
package/src/exercise-report.ts
CHANGED
|
@@ -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:
|
|
249
|
+
refused: refused(call),
|
|
245
250
|
}),
|
|
246
251
|
),
|
|
247
252
|
];
|
|
248
|
-
const refusedHosts = calls.filter(
|
|
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
|
+
}
|
package/src/exercise-subjects.ts
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/factory-registry.ts
CHANGED
|
@@ -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 {
|
|
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
package/src/plan-oracle.ts
CHANGED
|
@@ -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
|
|
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)
|
package/src/plan-sweep-fix.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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] : [];
|
package/src/plan-sweep-report.ts
CHANGED
|
@@ -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
|
-
|
|
77
|
-
`${primitive(record)} · ${finding.code} ${finding.table}
|
|
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/
|
|
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
|
*/
|
package/src/seed-graph-order.ts
CHANGED
|
@@ -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()
|
|
3
|
-
//
|
|
4
|
-
// no nullable edge is refused
|
|
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
|
-
|
|
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(
|
package/src/seed-graph-pass.ts
CHANGED
|
@@ -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
|
-
|
|
150
|
-
|
|
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
|
+
}
|
package/src/seed-graph-values.ts
CHANGED
|
@@ -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
|
|
3
|
-
// column's `$parse`, which refuses exactly what its CHECK
|
|
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
|
-
|
|
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
|
+
}
|
package/src/subject-proof.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|