@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/CLAUDE.md +694 -0
- package/README.md +467 -16
- package/package.json +6 -4
- package/src/batch-read.ts +134 -0
- package/src/batch.ts +125 -0
- package/src/bulk-write.ts +285 -0
- package/src/coalesce.ts +189 -0
- package/src/column.ts +91 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +228 -38
- package/src/count-by.ts +148 -0
- package/src/cross-tenant.ts +76 -0
- package/src/cursor.ts +17 -3
- package/src/database.ts +35 -2
- package/src/describe.ts +121 -39
- package/src/entity.ts +65 -20
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +72 -7
- package/src/invariants.ts +56 -14
- package/src/jit-preload.ts +216 -0
- package/src/n-plus-one.ts +122 -0
- package/src/pg-driver.ts +282 -35
- package/src/pg-row.ts +87 -16
- package/src/pg-sql.ts +156 -13
- package/src/plan.ts +130 -20
- package/src/preload.ts +184 -0
- package/src/query.ts +231 -27
- package/src/registry.ts +63 -5
- package/src/relations.ts +212 -0
- package/src/repo.ts +226 -12
- package/src/seed.ts +288 -19
- package/src/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +114 -12
- package/src/view.ts +8 -2
package/src/seed.ts
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
|
-
// A seed is the fixture graph, written once and
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
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
|
-
|
|
166
|
+
readonly tier: SeedTier;
|
|
167
|
+
run(options?: SeedOptions): Promise<SeedRun>;
|
|
55
168
|
}
|
|
56
169
|
|
|
57
|
-
export
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
107
|
-
*
|
|
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
|
-
|
|
117
|
-
|
|
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. */
|