@ultimat3/testing 30.2.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/src/exercise.ts CHANGED
@@ -8,10 +8,8 @@
8
8
 
9
9
  import type { Actor, ExerciseAttempt, ExerciseVerdict } from '@ultimat3/core';
10
10
  import { isUltimateError, renderThrowable, runExercise } from '@ultimat3/core';
11
- import type { AnyJobHandle, JobExecution, JobRecord } from '@ultimat3/jobs';
11
+ import type { JobExecution, JobRecord } from '@ultimat3/jobs';
12
12
  import { registeredJobs } from '@ultimat3/jobs';
13
- import type { Policy } from '@ultimat3/policy';
14
- import type { StandardSchemaV1 } from '@ultimat3/schema';
15
13
  import type { ExerciseOverride, ExerciseOverrides } from './define-exercise';
16
14
  import { assertOverridesKnown, unknownDeclaration } from './define-exercise';
17
15
  import type { RoleGrants } from './exercise-actor';
@@ -36,11 +34,13 @@ import type {
36
34
  Observed,
37
35
  } from './exercise-report';
38
36
  import { openExerciseRoom, overrideLine } from './exercise-report';
39
- import { withExerciseRequest } from './exercise-request';
37
+ import { withExerciseSetup } from './exercise-setup';
38
+ import type { Subject } from './exercise-subjects';
39
+ import { fixedTenant, seatIn, subjectsOf } from './exercise-subjects';
40
40
  import { actorRowsOf, attachSweeps, seated, sizedCall } from './exercise-sweeps';
41
41
  import type { RunJobs } from './fixture-jobs';
42
42
  import { testJobs } from './fixture-jobs';
43
- import { seedPass, tenantPair } from './seed-graph-pass';
43
+ import { deferredMayStayNull, pairGraph, seedPass, tenantPair } from './seed-graph-pass';
44
44
  import type { SeedGraph, SeedWriter } from './seed-graph-plan';
45
45
  import type { SeedEntity } from './seed-graph-shape';
46
46
  import { tenantTaint } from './tenant-canary';
@@ -62,86 +62,6 @@ export interface ExercisePassOptions {
62
62
  readonly roles: RoleGrants;
63
63
  }
64
64
 
65
- /** One primitive, as the pass drives it: the schema to draw from and the one call to make. */
66
- interface Subject {
67
- readonly kind: ExerciseRecord['kind'];
68
- readonly name: string;
69
- readonly input: StandardSchemaV1;
70
- readonly policy: Policy | undefined;
71
- /** Every permission the policy references — what `x policy explain` matches grants against. */
72
- readonly permissions: readonly string[];
73
- /** A job's handle: what the crash proof kills at every step boundary once the control reached it. */
74
- readonly handle?: AnyJobHandle;
75
- /** Resolves to the failure a worker recorded on the row (a job's), or what the call answered. */
76
- call(input: unknown, actor: Actor | null, tenant: string, jobs: RunJobs): Promise<Answer>;
77
- }
78
-
79
- /** `failure` set: the worker recorded one (a job's). Absent: answered, with `output`. */
80
- interface Answer {
81
- readonly failure?: unknown;
82
- readonly output?: unknown;
83
- }
84
-
85
- const ANSWERED: ReadonlySet<string> = new Set(['completed', 'suspended']);
86
-
87
- const statusOfJobFailure = (run: JobExecution | undefined): string =>
88
- run === undefined ? 'the job never ran' : (run.error ?? `the run ended ${run.outcome}`);
89
-
90
- async function subjectsOf(): Promise<readonly Subject[]> {
91
- // On demand, as every fixture here loads them: the barrel stays off `action` and `query`.
92
- const { invoke, listActions } = await import('@ultimat3/action');
93
- const { listQueries, runQuery } = await import('@ultimat3/query');
94
- const job = (handle: AnyJobHandle): Subject => ({
95
- kind: 'job',
96
- name: handle.name,
97
- input: handle.input,
98
- policy: undefined,
99
- permissions: [],
100
- handle,
101
- call: async (input, actor, tenant, jobs) => {
102
- const queued = await jobs.enqueue(handle, input, { tenantId: tenant });
103
- const trace = await jobs.drain(actor === null ? {} : { actor });
104
- const own = trace.executions.find((run) => run.jobId === queued.id);
105
- // The worker's failure is a string on the row, never a throw: it is the attempt's error.
106
- // Suspended is a body that ran and parked (`step.sleep`, `waitForEvent`): reached.
107
- return own !== undefined && ANSWERED.has(own.outcome)
108
- ? { output: own.result }
109
- : { failure: statusOfJobFailure(own) };
110
- },
111
- });
112
- return [
113
- ...listActions().map(
114
- (target): Subject => ({
115
- kind: 'action',
116
- name: target.name,
117
- input: target.input,
118
- policy: target.policy,
119
- permissions: target.describe().permissions,
120
- call: async (input, actor) => ({
121
- output: await withExerciseRequest(target.name, actor, () =>
122
- invoke(target, input, { surface: 'http' }),
123
- ),
124
- }),
125
- }),
126
- ),
127
- ...listQueries().map(
128
- (target): Subject => ({
129
- kind: 'query',
130
- name: target.name,
131
- input: target.input,
132
- policy: target.policy,
133
- permissions: target.describe().permissions,
134
- call: async (input, actor) => ({
135
- output: await withExerciseRequest(target.name, actor, () =>
136
- runQuery(target, input, { surface: 'http' }),
137
- ),
138
- }),
139
- }),
140
- ),
141
- ...registeredJobs().map(job),
142
- ];
143
- }
144
-
145
65
  const isOff = (overrides: ExerciseOverrides | undefined, name: string): boolean => {
146
66
  const override = overrides?.entries.get(name);
147
67
  return override !== undefined && 'off' in override;
@@ -264,7 +184,8 @@ async function control(
264
184
  draw: (at, n) => drawInput(sampler, subject.input, at, declaredRuns === undefined ? 0 : n),
265
185
  bind: async (drawn) => {
266
186
  const bound = bindIds(drawn, lookups[binds++ === 0 ? 0 : 1]);
267
- lastInput = bindInput === undefined ? bound : await bindInput(bound, actor);
187
+ const given = bindInput === undefined ? bound : await bindInput(bound, actor);
188
+ lastInput = seatIn(subject, given, tenant);
268
189
  return lastInput;
269
190
  },
270
191
  accepts: async (input) =>
@@ -306,13 +227,16 @@ async function attack(
306
227
  await using jobs = await testJobs();
307
228
  room.begin(subject.name);
308
229
  let output: unknown;
230
+ // A job's org is re-seated in B's: B cannot enqueue a payload naming A (refused at the enqueue,
231
+ // which proves nothing), so the attack that matters is a job of B's carrying A's ids.
232
+ const sent = seatIn(subject, input, tenant);
309
233
  // Every `crossTenant()` B's call opened: a job's declared sweep is told from a leak by it alone.
310
234
  // Loaded here, never at the top: the barrel stays entity-free (`seed-writer.ts`'s rule).
311
235
  const { setCrossTenantObserver } = await import('@ultimat3/entity');
312
236
  const crossings: string[] = [];
313
237
  setCrossTenantObserver((reason) => crossings.push(reason));
314
238
  try {
315
- const answer = await subject.call(input, actor, tenant, jobs);
239
+ const answer = await subject.call(sent, actor, tenant, jobs);
316
240
  output = answer.output;
317
241
  } catch {
318
242
  // B's refusal is the answer a scoped primitive gives: only what B got back, and what B's call
@@ -374,7 +298,18 @@ async function exerciseOne(
374
298
  };
375
299
  // A's own first input, A's ids in it: B sends exactly what A legitimately would.
376
300
  const drawn = bindIds(drawInput(sampler, subject.input, 0, 0), lookups[0]);
377
- const input = bindInput === undefined ? drawn : await bindInput(drawn, actor);
301
+ const given = bindInput === undefined ? drawn : await bindInput(drawn, actor);
302
+ const seat = subject.seat?.(given, tenant);
303
+ if (seat?.kind === 'fixed') {
304
+ const fixed = fixedTenant(name, seat.declared);
305
+ return {
306
+ ...none,
307
+ outcome: 'unreached',
308
+ ...fixed,
309
+ tenant: { verdict: 'unproven', reason: fixed.reason },
310
+ };
311
+ }
312
+ const input = seat === undefined ? given : seat.input;
378
313
  let reachedWith: unknown;
379
314
  const record = await proveTenant({
380
315
  taint: tenantTaint(graph, tenant),
@@ -409,7 +344,8 @@ export async function runExercisePass(
409
344
  const sampler = await loadSampler();
410
345
  // Not `Math.random`: the frozen RNG answers the same sequence in every process.
411
346
  const passSeed = options.seed ?? (crypto.getRandomValues(new Uint32Array(1))[0] as number);
412
- 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);
413
349
  const live = chosen.filter((subject) => !isOff(options.overrides, subject.name));
414
350
  const records: ExerciseRecord[] = [];
415
351
  let graph: SeedGraph;
@@ -425,8 +361,9 @@ export async function runExercisePass(
425
361
  // In order, one at a time: the seams the room installs are the process's, not the primitive's.
426
362
  for (const subject of chosen) {
427
363
  const index = live.indexOf(subject);
428
- const pair = tenantPair(graph, Math.max(0, index));
429
- records.push(await exerciseOne(subject, options, pair, graph, room, sampler));
364
+ const at = Math.max(0, index);
365
+ const view = pairGraph(graph, at);
366
+ records.push(await exerciseOne(subject, options, tenantPair(graph, at), view, room, sampler));
430
367
  }
431
368
  } catch (error) {
432
369
  await room[Symbol.asyncDispose]();
@@ -437,7 +374,8 @@ export async function runExercisePass(
437
374
  return attachSweeps(records, {
438
375
  subjects: chosen.filter((subject) => reached.has(subject.name)),
439
376
  entities: options.entities,
440
- 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),
441
379
  main: graph,
442
380
  seed: passSeed,
443
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
  */
@@ -12,6 +12,8 @@ export interface SeedGraphUnsatisfiedInput {
12
12
  readonly what: string;
13
13
  /** The columns the factory has to supply. Empty when no column is to blame (a cycle). */
14
14
  readonly columns: readonly string[];
15
+ /** When no factory is the repair — a key into a `readOnly` table no row is seeded in. */
16
+ readonly fix?: string;
15
17
  }
16
18
 
17
19
  const ENTITY_PLACEHOLDER = '<the entity the cause names>';
@@ -27,7 +29,9 @@ export class SeedGraphUnsatisfiedError extends UltimateError {
27
29
  super({
28
30
  code: 'X_SEED_GRAPH_UNSATISFIED',
29
31
  cause: `${input.entity}: ${input.what}`,
30
- fix: `defineFactory(<the ${input.entity} entity>, { defaults: (index, ids) => ({ ${body} }) }), then seedGraph(entities, { factories: { ${name}: thatFactory } }) — for x exercise, defineExercise({}, { factories: { ${name}: thatFactory } }) in apps/web/exercise.ts`,
32
+ fix:
33
+ input.fix ??
34
+ `defineFactory(<the ${input.entity} entity>, { defaults: (index, ids) => ({ ${body} }) }), then seedGraph(entities, { factories: { ${name}: thatFactory } }) — for x exercise, defineExercise({}, { factories: { ${name}: thatFactory } }) in apps/web/exercise.ts`,
31
35
  });
32
36
  }
33
37
  }
@@ -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(
@@ -6,7 +6,14 @@
6
6
  import { isUltimateError, renderCauseValue, renderThrowable, UltimateError } from '@ultimat3/core';
7
7
  import { seedGraph } from './seed-graph';
8
8
  import { seedGraphUnsatisfied } from './seed-graph-error';
9
- import type { SeedFactory, SeedGraph, SeedRow, SeedUpdate, SeedWriter } from './seed-graph-plan';
9
+ import type {
10
+ SeedCanary,
11
+ SeedFactory,
12
+ SeedGraph,
13
+ SeedRow,
14
+ SeedUpdate,
15
+ SeedWriter,
16
+ } from './seed-graph-plan';
10
17
  import { insertSeedGraph } from './seed-graph-plan';
11
18
  import type { SeedEntity } from './seed-graph-shape';
12
19
 
@@ -34,10 +41,43 @@ const refused = (write: SeedRow | SeedUpdate, error: unknown): unknown => {
34
41
  });
35
42
  };
36
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
+
37
69
  /** Every write coded: a row refusal names its factory, an environment failure keeps its own fix. */
38
70
  const coded = (writer: SeedWriter): SeedWriter => ({
39
71
  insert: (row) =>
40
72
  writer.insert(row).catch((error: unknown) => Promise.reject(refused(row, error))),
73
+ ...(writer.insertMany === undefined
74
+ ? {}
75
+ : {
76
+ insertMany: (rows: readonly SeedRow[]) =>
77
+ (writer.insertMany?.(rows) ?? Promise.resolve()).catch((error: unknown) =>
78
+ Promise.reject(refused(rows[0] as SeedRow, error)),
79
+ ),
80
+ }),
41
81
  update: (update) =>
42
82
  writer.update(update).catch((error: unknown) => Promise.reject(refused(update, error))),
43
83
  // A read, not a write: nothing a database refuses here is the generated row's fault.
@@ -50,6 +90,57 @@ export const tenantPair = (graph: SeedGraph, index: number): readonly [string, s
50
90
  graph.tenants[index * TENANTS_PER_PRIMITIVE + 1] as string,
51
91
  ];
52
92
 
93
+ interface TenantIndex {
94
+ readonly inserts: ReadonlyMap<string | null, readonly number[]>;
95
+ readonly updates: ReadonlyMap<string | null, readonly SeedUpdate[]>;
96
+ }
97
+
98
+ const indexes = new WeakMap<SeedGraph, TenantIndex>();
99
+
100
+ const grouped = <T>(items: readonly T[], tenantOf: (item: T) => string | null) => {
101
+ const out = new Map<string | null, T[]>();
102
+ for (const item of items) {
103
+ const key = tenantOf(item);
104
+ const bucket = out.get(key);
105
+ if (bucket === undefined) out.set(key, [item]);
106
+ else bucket.push(item);
107
+ }
108
+ return out;
109
+ };
110
+
111
+ const indexOf = (graph: SeedGraph): TenantIndex => {
112
+ let index = indexes.get(graph);
113
+ if (index === undefined) {
114
+ const positions = graph.inserts.map((_, i) => i);
115
+ index = {
116
+ inserts: grouped(positions, (i) => (graph.inserts[i] as SeedRow).tenant),
117
+ updates: grouped(graph.updates, (update) => update.tenant),
118
+ };
119
+ indexes.set(graph, index);
120
+ }
121
+ return index;
122
+ };
123
+
124
+ /**
125
+ * What primitive `index` can see of the pass's graph: the rows of no tenant and its own pair's —
126
+ * the same rows, in the same order, `canaries` still aligned. Everything a primitive reads off the
127
+ * graph (ids to bind, its taint, its actor rows) reads this, so its cost is its pair's, never the
128
+ * whole pass's: a filter of every row per primitive was primitives² over the pass.
129
+ */
130
+ export function pairGraph(graph: SeedGraph, index: number): SeedGraph {
131
+ const pair = tenantPair(graph, index);
132
+ const { inserts, updates } = indexOf(graph);
133
+ const at = [null, ...pair].flatMap((tenant) => inserts.get(tenant) ?? []).sort((a, b) => a - b);
134
+ return {
135
+ seed: graph.seed,
136
+ tenants: pair,
137
+ order: graph.order,
138
+ inserts: at.map((i) => graph.inserts[i] as SeedRow),
139
+ canaries: at.map((i) => graph.canaries[i] as SeedCanary),
140
+ updates: [null, ...pair].flatMap((tenant) => updates.get(tenant) ?? []),
141
+ };
142
+ }
143
+
53
144
  /** A factory keyed by a name no seeded entity has would build nothing, and the red would stay. */
54
145
  const unknownFactory = (names: readonly string[], known: ReadonlySet<string>): UltimateError =>
55
146
  new UltimateError({
@@ -80,6 +171,10 @@ export async function seedPass(
80
171
  seed: options.seed,
81
172
  ...(options.factories === undefined ? {} : { factories: options.factories }),
82
173
  });
83
- await insertSeedGraph(graph, coded(writer));
84
- 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)) };
85
180
  }