@ultimat3/entity 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/seed.ts CHANGED
@@ -1,17 +1,27 @@
1
- // A seed is the fixture graph, written once and replayed anywhere. `id('post:tenancy')` is a
2
- // UUID v5 of the label, so the same row gets the same id on every machine and a bug reproduced
3
- // locally reproduces in CI. Rows go through `entity.$parse` and the invariants, which makes a
4
- // seed a test of the schema as well as data for one.
1
+ // A seed is the fixture graph, written once and REPLAYED anywhere: a second run writes nothing new
2
+ // and raises nothing. Two write verbs, because only the author knows which key identifies a row
3
+ // `insert` where the seed owns the id (`id('post:tenancy')` is a v5 uuid of the label, the same on
4
+ // every machine), `upsert` where the table owns it and a natural key is all there is.
5
5
 
6
6
  import { createHash } from 'node:crypto';
7
+ import { type Environment, resolveEnvironment, systemClock } from '@ultimat3/core';
7
8
  import type { Driver } from './database';
8
9
  import { memoryDriver } from './database';
9
- import type { EntityCore } from './entity';
10
+ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
11
+ import { EntityError } from './errors';
12
+ import type { Predicate } from './tenancy';
10
13
  import type { ColumnMap, Insertable } from './types';
11
14
 
12
15
  /** Framework namespace for seed labels. Fixed forever: changing it moves every seeded id. */
13
16
  const NAMESPACE = 'a3c1f0d6-5c2b-4a3e-9f1b-6d4e7c8a9b02';
14
17
 
18
+ /**
19
+ * What a replay never overwrites, unless `preserve` says otherwise. Spelled here as a constant for
20
+ * the reason `SOFT_DELETE_COLUMN` is: the timestamp convention is the framework's, so the column a
21
+ * seed must not reset is decided once.
22
+ */
23
+ const CREATED_AT_COLUMN = 'createdAt';
24
+
15
25
  const bytesOf = (uuid: string): Uint8Array =>
16
26
  Uint8Array.from((uuid.replaceAll('-', '').match(/../g) ?? []).map((pair) => parseInt(pair, 16)));
17
27
 
@@ -35,35 +45,294 @@ export const seedId = (label: string): string => {
35
45
  ].join('-');
36
46
  };
37
47
 
48
+ /**
49
+ * Which deploys a seed belongs to, declared as DATA on the seed and never inferred from its name.
50
+ *
51
+ * `reference` is data the app is wrong without — currencies, plans, service tiers, locations — and
52
+ * it ships to production through this same mechanism. `dev` is fixture data, and production is the
53
+ * one environment it must not reach by accident. The word is the seed's; the refusal is the
54
+ * caller's (`x db seed`), because an app that seeds its own database from its boot code has
55
+ * DECIDED to (axiom 8) and a library that overruled that would break it.
56
+ */
57
+ export const SEED_TIERS = ['reference', 'dev'] as const;
58
+
59
+ export type SeedTier = (typeof SEED_TIERS)[number];
60
+
61
+ /**
62
+ * The tiers a run takes when nothing asked for one: everything, except that production leaves
63
+ * `dev` out. `requested` is both the selection AND the consent, one word doing one job — a cluster
64
+ * that sets `ULTIMATE_ENV=production` on every box (staging included) still loads its dev seeds by
65
+ * naming the tier, instead of by lying about the environment.
66
+ */
67
+ export const seedTiersFor = (
68
+ environment: Environment,
69
+ requested?: SeedTier | undefined,
70
+ ): readonly SeedTier[] => {
71
+ if (requested !== undefined) return [requested];
72
+ return environment === 'production' ? ['reference'] : [...SEED_TIERS];
73
+ };
74
+
75
+ /** What one `upsert` did. `skipped` is a row already stored with these values — no statement. */
76
+ export type SeedWrite = 'inserted' | 'updated' | 'skipped';
77
+
78
+ /** One run's tally, in the three words every seed report is built from. */
79
+ export interface SeedMetrics {
80
+ inserted: number;
81
+ updated: number;
82
+ skipped: number;
83
+ }
84
+
85
+ export interface SeedKey<Row> {
86
+ /**
87
+ * The columns of the unique constraint this row is identified by — its NATURAL key, which is the
88
+ * only key a seed writing into an existing table can know. A target no declared constraint
89
+ * matches is refused by `upsertPlan` before a statement is sent (`42P10` otherwise).
90
+ */
91
+ readonly by: readonly (keyof Row & string)[];
92
+ /**
93
+ * Columns a collision leaves alone. `createdAt` by default, and that default is the point: a
94
+ * replay must not reset when a row first arrived. The conflict target, the primary key and the
95
+ * soft-delete stamp are spared by `upsertPlan` already.
96
+ */
97
+ readonly preserve?: readonly (keyof Row & string)[];
98
+ }
99
+
38
100
  export interface SeedContext {
101
+ /**
102
+ * Rows whose ids the SEED chose, written in one statement and replayable by primary key: a row
103
+ * already stored is left exactly as it is (`on conflict … do nothing`). The bulk verb — one
104
+ * statement per call, not one per row.
105
+ */
39
106
  insert<Row, C extends ColumnMap>(
40
107
  entity: EntityCore<Row, C>,
41
108
  rows: readonly Insertable<C>[],
42
109
  ): Promise<void>;
110
+ /**
111
+ * One row whose id the TABLE owns, matched on the natural key `by` names. Reads first so the
112
+ * answer can be `'skipped'`, then writes with a single `on conflict … do update`, which is what
113
+ * settles the race between two containers booting at once — the read is for the report, never
114
+ * for the decision.
115
+ */
116
+ upsert<Row, C extends ColumnMap>(
117
+ entity: EntityCore<Row, C>,
118
+ key: SeedKey<Row>,
119
+ values: Insertable<C>,
120
+ ): Promise<SeedWrite>;
121
+ /**
122
+ * The other unit of idempotency: the FILE. Bulk volume data has no natural key worth upserting
123
+ * ten thousand rows against, so the guard is a sentinel — `if (await exists(reports)) return;`
124
+ * at the top of the seed.
125
+ */
126
+ exists<Row, C extends ColumnMap>(
127
+ entity: EntityCore<Row, C>,
128
+ where?: Partial<Row>,
129
+ ): Promise<boolean>;
130
+ count<Row, C extends ColumnMap>(
131
+ entity: EntityCore<Row, C>,
132
+ where?: Partial<Row>,
133
+ ): Promise<number>;
134
+ /** Scoped wipe before a regenerate. Refused on a soft-deleting entity — see `softDeleteWipe`. */
135
+ deleteWhere<Row, C extends ColumnMap>(
136
+ entity: EntityCore<Row, C>,
137
+ where: Partial<Row>,
138
+ ): Promise<number>;
43
139
  /** Deterministic id for a label. Same label, same uuid, every run. */
44
140
  id(label: string): string;
141
+ /** One instant for the whole run, so every row a bulk pass stamps carries the same timestamp. */
142
+ readonly now: Date;
143
+ readonly environment: Environment;
144
+ readonly tier: SeedTier;
145
+ /** Reads still run; every write short-circuits and is counted as what it WOULD have written. */
146
+ readonly dryRun: boolean;
147
+ readonly metrics: SeedMetrics;
45
148
  }
46
149
 
47
150
  export interface SeedOptions {
48
151
  /** Defaults to a fresh in-memory driver, so a seed runs with no database at all. */
49
152
  readonly driver?: Driver;
153
+ readonly dryRun?: boolean;
154
+ /** Injected for a test; `process.env` otherwise. Read once, by `resolveEnvironment`. */
155
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
156
+ }
157
+
158
+ export interface SeedRun {
159
+ readonly name: string;
160
+ readonly tier: SeedTier;
161
+ readonly metrics: SeedMetrics;
50
162
  }
51
163
 
52
164
  export interface Seed {
53
165
  readonly name: string;
54
- run(options?: SeedOptions): Promise<void>;
166
+ readonly tier: SeedTier;
167
+ run(options?: SeedOptions): Promise<SeedRun>;
55
168
  }
56
169
 
57
- export const defineSeed = (name: string, build: (context: SeedContext) => Promise<void>): Seed => ({
58
- name,
59
- run: async (options = {}) => {
60
- const driver = options.driver ?? memoryDriver();
61
- await build({
62
- insert: async (entity, rows) => {
63
- const repo = driver.repo(entity);
64
- for (const row of rows) await repo.insert(entity.$parse(row));
65
- },
66
- id: seedId,
67
- });
68
- },
69
- });
170
+ export interface SeedInit {
171
+ /** Defaults to `dev`: fixture data is what a seed is until its author says otherwise. */
172
+ readonly tier?: SeedTier;
173
+ }
174
+
175
+ /** What `x db seed` picks out of a module. Same shape rule as `isRouteConfig`. */
176
+ export const isSeed = (value: unknown): value is Seed =>
177
+ typeof value === 'object' &&
178
+ value !== null &&
179
+ typeof (value as { name?: unknown }).name === 'string' &&
180
+ typeof (value as { run?: unknown }).run === 'function' &&
181
+ (SEED_TIERS as readonly unknown[]).includes((value as { tier?: unknown }).tier);
182
+
183
+ /** Property access on a parsed row without `any`: `$parse` fills every declared column. */
184
+ const cellOf = (row: unknown, property: string): unknown =>
185
+ (row as Readonly<Record<string, unknown>>)[property];
186
+
187
+ /**
188
+ * Two cells, as a replay has to compare them: a `Date` is not `===` a `Date` and money is an
189
+ * object, so both are compared by value and everything else by identity.
190
+ */
191
+ const sameCell = (left: unknown, right: unknown): boolean => {
192
+ if (left instanceof Date || right instanceof Date) {
193
+ return left instanceof Date && right instanceof Date && left.getTime() === right.getTime();
194
+ }
195
+ if (typeof left === 'object' && left !== null && typeof right === 'object' && right !== null) {
196
+ return JSON.stringify(left) === JSON.stringify(right);
197
+ }
198
+ return left === right;
199
+ };
200
+
201
+ const equalityPredicates = <Row>(where: Partial<Row>): readonly Predicate[] =>
202
+ Object.entries(where).map(([column, value]): Predicate => ({ column, op: 'eq', value }));
203
+
204
+ /**
205
+ * The entity's own key as a conflict target. `$primaryKey` is `readonly string[]` because an
206
+ * entity does not know its row type at that field, and `onConflict` is typed by the row.
207
+ */
208
+ const primaryKeyTarget = <Row>(entity: EntityCore<Row>): readonly (keyof Row & string)[] =>
209
+ entity.$primaryKey as readonly (keyof Row & string)[];
210
+
211
+ /**
212
+ * A primary key the row leaves to a GENERATED default is a different id on every run, so the
213
+ * conflict target finds nothing and each replay inserts one more copy. `$parse` refuses a key with
214
+ * no value at all; this is the half it cannot see, because filling that column is what it does.
215
+ */
216
+ const generatedKey = (entity: EntityCore, missing: string, position: number): EntityError =>
217
+ new EntityError({
218
+ code: 'X_INVARIANT_VIOLATED',
219
+ cause: `${entity.$name} seed row ${position + 1} leaves "${missing}" to a generated default, and a primary key generated fresh on every run is a row every replay inserts a second copy of`,
220
+ fix: `insert(${entity.$name}, rows.map((row, index) => ({ ...row, ${missing}: id(\`${entity.$name}:\${index}\`) }))) # id() is a uuid v5 of the label: same row, same id, every run`,
221
+ });
222
+
223
+ /**
224
+ * Deleting from a soft-deleting entity inside a seed, refused rather than documented. The stamp is
225
+ * what makes it unrecoverable: `upsertPlan` spares the soft-delete column on purpose and the stored
226
+ * row still occupies its unique key, so the replay that was supposed to bring the rows back writes
227
+ * nothing at all and the fixture is gone until the database is.
228
+ */
229
+ const softDeleteWipe = (entity: EntityCore): EntityError =>
230
+ new EntityError({
231
+ code: 'X_INVARIANT_VIOLATED',
232
+ cause: `${entity.$name} declares ${SOFT_DELETE_COLUMN}, so deleteWhere() would stamp its seeded rows rather than remove them — the stamped row keeps its unique key, and no replay of this seed can clear it`,
233
+ fix: 'x db reset --json # the only wipe a soft-deleting entity has; drop the deleteWhere() call from the seed',
234
+ });
235
+
236
+ /** The row an update may write: everything the caller named, less what a match must not move. */
237
+ const withoutPreserved = <Row>(row: Row, preserve: readonly string[]): Row => {
238
+ const copy: Record<string, unknown> = { ...(row as Record<string, unknown>) };
239
+ for (const property of preserve) delete copy[property];
240
+ // `Repo` is typed for whole rows, and a partial one is exactly what keeps a column OUT of the
241
+ // update set — `namedProperties` reads what the row owns. Same assertion the bulk live test takes.
242
+ return copy as Row;
243
+ };
244
+
245
+ export const defineSeed = (
246
+ name: string,
247
+ build: (context: SeedContext) => Promise<void>,
248
+ init: SeedInit = {},
249
+ ): Seed => {
250
+ const tier = init.tier ?? 'dev';
251
+ return {
252
+ name,
253
+ tier,
254
+ run: async (options = {}) => {
255
+ const driver = options.driver ?? memoryDriver();
256
+ const dryRun = options.dryRun ?? false;
257
+ const metrics: SeedMetrics = { inserted: 0, updated: 0, skipped: 0 };
258
+ const context: SeedContext = {
259
+ insert: async (entity, rows) => {
260
+ // Judged on the row as WRITTEN, before `$parse` fills the column that would hide it.
261
+ for (const [position, row] of rows.entries()) {
262
+ const missing = entity.$primaryKey.find(
263
+ (property) =>
264
+ entity.$columns[property]?.$meta.default?.kind === 'generated' &&
265
+ !Object.hasOwn(row, property),
266
+ );
267
+ if (missing !== undefined) throw generatedKey(entity, missing, position);
268
+ }
269
+ const parsed = rows.map((row) => entity.$parse(row));
270
+ if (dryRun) {
271
+ metrics.inserted += parsed.length;
272
+ return;
273
+ }
274
+ const written = await driver.repo(entity).upsertAll(parsed, {
275
+ onConflict: primaryKeyTarget(entity),
276
+ // Never `'update'`: a do-nothing conflict needs no tenant column in the target, so this
277
+ // is the one form that replays on a tenant-scoped entity whose unique keys are global.
278
+ onMatch: 'nothing',
279
+ });
280
+ metrics.inserted += written.length;
281
+ metrics.skipped += parsed.length - written.length;
282
+ },
283
+
284
+ upsert: async (entity, key, values) => {
285
+ const row = entity.$parse(values);
286
+ const repo = driver.repo(entity);
287
+ const where = Object.fromEntries(
288
+ key.by.map((property) => [property, cellOf(row, property)]),
289
+ );
290
+ const found = await repo.findMany({ where: equalityPredicates(where), limit: 1 });
291
+ const stored = found.rows[0];
292
+ const preserve: readonly string[] = key.preserve ?? [CREATED_AT_COLUMN];
293
+ const compared = Object.keys(row as Record<string, unknown>).filter(
294
+ (property) => !preserve.includes(property),
295
+ );
296
+ if (
297
+ stored !== undefined &&
298
+ compared.every((property) => sameCell(cellOf(stored, property), cellOf(row, property)))
299
+ ) {
300
+ metrics.skipped += 1;
301
+ return 'skipped';
302
+ }
303
+ const write: SeedWrite = stored === undefined ? 'inserted' : 'updated';
304
+ if (!dryRun) {
305
+ await repo.upsertAll([stored === undefined ? row : withoutPreserved(row, preserve)], {
306
+ onConflict: key.by,
307
+ onMatch: 'update',
308
+ });
309
+ }
310
+ metrics[write === 'inserted' ? 'inserted' : 'updated'] += 1;
311
+ return write;
312
+ },
313
+
314
+ count: async (entity, where) =>
315
+ driver
316
+ .repo(entity)
317
+ .count(where === undefined ? {} : { where: equalityPredicates(where) }),
318
+
319
+ exists: async (entity, where) => (await context.count(entity, where)) > 0,
320
+
321
+ deleteWhere: async (entity, where) => {
322
+ if (entity.$softDelete) throw softDeleteWipe(entity);
323
+ if (dryRun) return context.count(entity, where);
324
+ return driver.repo(entity).deleteWhere(where);
325
+ },
326
+
327
+ id: seedId,
328
+ now: systemClock.now(),
329
+ environment: resolveEnvironment({ env: options.env }),
330
+ tier,
331
+ dryRun,
332
+ metrics,
333
+ };
334
+ await build(context);
335
+ return { name, tier, metrics };
336
+ },
337
+ };
338
+ };
package/src/tenancy.ts CHANGED
@@ -1,8 +1,16 @@
1
- // Multi-tenancy is a guard, not a convention. An entity with a tenant column can only be read
2
- // through a plan that carries an org predicate; building one without it throws
3
- // `X_TENANCY_UNSCOPED` at the seam instead of leaking another tenant's rows.
1
+ // Multi-tenancy is a guard, not a convention. An entity with a tenant column is read AND written
2
+ // under the acting actor's tenant derived from the ambient context, never taken from an argument
3
+ // and a plan or a row that names a different one is refused rather than carried out.
4
4
 
5
- import { EntityError, tenancyUnscoped } from './errors';
5
+ import { tryUseContext } from '@ultimat3/core';
6
+ import { assertCrossTenant, crossTenantReason } from './cross-tenant';
7
+ import {
8
+ EntityError,
9
+ tenancyActorMismatch,
10
+ tenancyActorOrgRequired,
11
+ tenancyRowMismatch,
12
+ tenancyUnscoped,
13
+ } from './errors';
6
14
  import type { ColumnMap } from './types';
7
15
 
8
16
  export type Operator =
@@ -67,8 +75,7 @@ export const resolveTenantColumn = (
67
75
  columns: ColumnMap,
68
76
  declared: string | undefined,
69
77
  ): string | null => {
70
- if (declared === undefined) return tenantColumnOf(columns);
71
- if (!Object.hasOwn(columns, declared)) {
78
+ if (declared !== undefined && !Object.hasOwn(columns, declared)) {
72
79
  const available = Object.keys(columns).join(', ');
73
80
  // Not `invariantViolated`: its fix points at `x entity explain`, which describes invariants
74
81
  // the author never wrote. What repairs this is one edit to the declaration, so the error
@@ -79,7 +86,24 @@ export const resolveTenantColumn = (
79
86
  fix: `set tenant to one of ${available} in entity('${entityName}'), or remove the tenant key — inference then takes the .tenant() column, else one named ${ORG_COLUMN}`,
80
87
  });
81
88
  }
82
- return declared;
89
+ const property = declared ?? tenantColumnOf(columns);
90
+ if (property === null) return null;
91
+ // A NULLABLE tenant column is refused here, at the declaration, and this is the same guard as
92
+ // the one above rather than an extra rule: `assertRowTenant` returns early on a row that names
93
+ // no tenant — "left alone, and the column's NOT NULL answers it" — so on a nullable column the
94
+ // delegation has nothing to delegate to. The row lands with a null tenant, and a null is
95
+ // matched by no `org_id = $1`: it is invisible to every tenant-scoped read, so it never appears
96
+ // in an export, never goes in an offboarding sweep, and sits in the table owned by nobody.
97
+ // A tenant that may be absent is a table that is only sometimes multi-tenant, which is not a
98
+ // shape this layer can enforce — so it is refused where the author can see it.
99
+ if (columns[property]?.$meta.notNull === false) {
100
+ throw new EntityError({
101
+ code: 'X_INVARIANT_VIOLATED',
102
+ cause: `${entityName}.${property} is the tenant column and is nullable — a row written with no ${property} is matched by no tenant-scoped query, so it belongs to nobody and no sweep can ever find it`,
103
+ fix: `drop .nullable() from ${property} in entity('${entityName}'), then x db gen "backfill ${entityName} ${property}" — a row with no tenant needs one before the column can refuse it`,
104
+ });
105
+ }
106
+ return property;
83
107
  };
84
108
 
85
109
  export const emptyPlan = (entity: string, limit = 50): QueryPlan => ({
@@ -89,10 +113,19 @@ export const emptyPlan = (entity: string, limit = 50): QueryPlan => ({
89
113
  limit,
90
114
  });
91
115
 
116
+ /**
117
+ * Whether the plan mentions the tenant column at all — never whether it mentions the right VALUE,
118
+ * which is why this is not the guard. `scopedPlan` compares against the actor; this answers the
119
+ * narrower question the derivation asks before it appends a predicate that already exists.
120
+ */
92
121
  export const hasOrgPredicate = (plan: QueryPlan, column: string = ORG_COLUMN): boolean =>
93
122
  plan.where.some((predicate) => predicate.column === column);
94
123
 
95
- /** Adds the org predicate exactly once; calling it twice is not an error. */
124
+ /**
125
+ * Adds the org predicate exactly once; calling it twice is not an error. The explicit form of what
126
+ * `scopedPlan` does from the actor — and inside a request it must name that same tenant, or the
127
+ * plan is refused as `X_TENANCY_ACTOR_MISMATCH`. It adds a predicate; it never authorises one.
128
+ */
96
129
  export const orgScoped = (
97
130
  plan: QueryPlan,
98
131
  orgId: string,
@@ -103,8 +136,101 @@ export const orgScoped = (
103
136
  : { ...plan, where: [...plan.where, { column, op: 'eq', value: orgId }] };
104
137
 
105
138
  /**
106
- * Called by every repository operation. Runtime here, and a build-time check in `x verify`
107
- * that no query for a tenant-scoped entity is constructed without it.
139
+ * The tenant every plan for a scoped entity runs under: the acting actor's own, or `undefined`
140
+ * when there is no request context to take one from.
141
+ *
142
+ * An actor that carries no tenant is refused rather than allowed to name one — anonymous is the
143
+ * case that must not read a tenant table by asking nicely, and a service actor minted without an
144
+ * org is a boundary that forgot to resolve it. `crossTenant()` is the way to mean it on purpose.
145
+ *
146
+ * No context at all is a different situation and not a caller-reachable one: every entry point in
147
+ * the framework runs its handler inside `runWithContext`, so this is a script, a boot path or a
148
+ * test harness, with no identity to check a value against. Those callers still have to name the
149
+ * tenant themselves — `verifyScope` refuses an unscoped plan exactly as it always did.
150
+ */
151
+ const actorTenant = (entityName: string, operation: string): string | undefined => {
152
+ const ctx = tryUseContext();
153
+ if (ctx === undefined) return undefined;
154
+ const { actor } = ctx;
155
+ if (actor.orgId === undefined) {
156
+ throw tenancyActorOrgRequired({
157
+ entityName,
158
+ operation,
159
+ actorId: actor.id,
160
+ actorKind: actor.kind,
161
+ });
162
+ }
163
+ return actor.orgId;
164
+ };
165
+
166
+ /**
167
+ * Every predicate on the tenant column has to be `eq` the actor's own tenant. Every one, and `eq`
168
+ * only: `where('orgId', 'in', [mine, theirs])` names a tenant that is not the actor's just as
169
+ * plainly as `where('orgId', 'eq', theirs)` does, and a plan carrying both predicates is answered
170
+ * by the narrower of the two — so checking "one of them matches" would pass a plan whose rows come
171
+ * from a set the actor never proved they own.
172
+ */
173
+ const verifyScope = (
174
+ entityName: string,
175
+ tenantColumn: string,
176
+ operation: string,
177
+ plan: QueryPlan,
178
+ actorOrg: string | undefined,
179
+ ): void => {
180
+ const named = plan.where.filter((predicate) => predicate.column === tenantColumn);
181
+ // `actorOrg` goes in so the refusal states which of the two situations this is: `scopedPlan`
182
+ // never reaches here with an actor (it derives first), but `assertScoped` verifies plans it did
183
+ // not build, and telling that caller "no actor carried a tenant" would be false.
184
+ if (named.length === 0) throw tenancyUnscoped(entityName, operation, actorOrg);
185
+ if (actorOrg === undefined) return;
186
+ for (const predicate of named) {
187
+ if (predicate.op !== 'eq' || predicate.value !== actorOrg) {
188
+ throw tenancyActorMismatch({ entityName, operation, named: predicate.value, actorOrg });
189
+ }
190
+ }
191
+ };
192
+
193
+ /**
194
+ * The plan a tenant-scoped operation actually runs, with the actor's tenant applied. Called by
195
+ * every repository operation through `readPlan`, so both drivers and every read, write and count
196
+ * pass through this one derivation.
197
+ *
198
+ * Runtime only. There is no build-time tenancy step in `x verify` — its 17 steps check none — and
199
+ * there cannot usefully be one: the tenant is a request-time value, so a compiler could only prove
200
+ * that some argument was passed, which is exactly the thing that was never a guarantee. That is
201
+ * why this is the seam every plan is built through rather than a lint.
202
+ */
203
+ export const scopedPlan = (
204
+ entityName: string,
205
+ tenantColumn: string | null,
206
+ operation: string,
207
+ plan: QueryPlan,
208
+ ): QueryPlan => {
209
+ if (tenantColumn === null) return plan;
210
+ const crossing = crossTenantReason();
211
+ // Re-proved per plan, not trusted from the scope's own entry: `withChildContext({ actor })`
212
+ // swaps the actor without closing the scope.
213
+ if (crossing !== undefined) {
214
+ assertCrossTenant(crossing);
215
+ return plan;
216
+ }
217
+ const actorOrg = actorTenant(entityName, operation);
218
+ // Derived only when the caller named nothing: a predicate that is already there is checked
219
+ // rather than joined by a second one, so a disagreement is refused instead of being answered by
220
+ // whichever of the two the driver applies first.
221
+ const scoped =
222
+ actorOrg !== undefined && !hasOrgPredicate(plan, tenantColumn)
223
+ ? orgScoped(plan, actorOrg, tenantColumn)
224
+ : plan;
225
+ verifyScope(entityName, tenantColumn, operation, scoped, actorOrg);
226
+ return scoped;
227
+ };
228
+
229
+ /**
230
+ * The same guard, verifying a plan that is already built — for a caller holding one this layer did
231
+ * not construct. It cannot derive (there is nowhere to put the predicate), so a plan that names no
232
+ * tenant is `X_TENANCY_UNSCOPED` even where the actor carries one; `scopedPlan` is the path that
233
+ * fills it in.
108
234
  */
109
235
  export const assertScoped = (
110
236
  entityName: string,
@@ -113,8 +239,64 @@ export const assertScoped = (
113
239
  plan: QueryPlan,
114
240
  ): void => {
115
241
  if (tenantColumn === null) return;
116
- if (hasOrgPredicate(plan, tenantColumn)) return;
117
- throw tenancyUnscoped(entityName, operation);
242
+ const crossing = crossTenantReason();
243
+ if (crossing !== undefined) {
244
+ assertCrossTenant(crossing);
245
+ return;
246
+ }
247
+ verifyScope(entityName, tenantColumn, operation, plan, actorTenant(entityName, operation));
248
+ };
249
+
250
+ /**
251
+ * The tenant a row or a patch names, or `undefined` for one that names none. `undefined` is read
252
+ * as "not named" rather than as a value: it is what `namedColumns` already drops from a filter,
253
+ * and a row whose tenant column is genuinely missing is refused one step later by the column's own
254
+ * `NOT NULL` — by the declaration, which is where that rule belongs.
255
+ */
256
+ const tenantValueOf = (values: unknown, column: string): unknown => {
257
+ if (typeof values !== 'object' || values === null || !Object.hasOwn(values, column)) {
258
+ return undefined;
259
+ }
260
+ return (values as Record<string, unknown>)[column];
261
+ };
262
+
263
+ /**
264
+ * The write half of the guard: a row or a patch may name the acting actor's tenant, or none, and
265
+ * nothing else. Called wherever a driver is about to write values — `insert`, `insertAll`,
266
+ * `upsertAll`, `update`, `updateWhere` — because those build no read plan, so `scopedPlan` never
267
+ * sees them and an `orgId` in a row literal would otherwise be a tenant the caller chose.
268
+ *
269
+ * **Refuse, never stamp.** A row that names no tenant is left alone rather than filled in from the
270
+ * actor. Stamping is the ergonomic half and it is deliberately not here: `namedProperties` decides
271
+ * an `upsertAll`'s column list by `Object.hasOwn`, so a stamped column would change which columns
272
+ * the statement writes, silence the uneven-batch refusal that exists because `excluded.<col>` is a
273
+ * default and not "leave it alone", and — where the conflict target includes the tenant column —
274
+ * let ambient state decide which stored row a collision lands on. A write that creates data from
275
+ * the ambient context is a bigger decision than this guard, and it is not needed for the security
276
+ * property: a wrong tenant is refused either way.
277
+ */
278
+ export const assertRowTenant = (
279
+ entityName: string,
280
+ tenantColumn: string | null,
281
+ operation: string,
282
+ values: unknown,
283
+ ): void => {
284
+ if (tenantColumn === null) return;
285
+ // Before the "names no tenant" shortcut, exactly as `scopedPlan` proves it before deriving: an
286
+ // insert builds no plan, so this is the only place a write inside somebody else's sweep re-proves
287
+ // the capability, and a row that names nothing is still a row written under that scope.
288
+ const crossing = crossTenantReason();
289
+ if (crossing !== undefined) {
290
+ assertCrossTenant(crossing);
291
+ return;
292
+ }
293
+ const named = tenantValueOf(values, tenantColumn);
294
+ if (named === undefined) return;
295
+ const actorOrg = actorTenant(entityName, operation);
296
+ // No request context: no actor to check the value against, and the same fallback the read path
297
+ // takes — a script, a seed or a migration writes the tenant it names.
298
+ if (actorOrg === undefined || named === actorOrg) return;
299
+ throw tenancyRowMismatch({ entityName, operation, column: tenantColumn, named, actorOrg });
118
300
  };
119
301
 
120
302
  /** Debug and `x db explain` rendering. Values stay out: a plan is safe to log. */