@ultimat3/entity 1.2.0 → 2.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/errors.ts CHANGED
@@ -8,7 +8,16 @@ export const ENTITY_OWNED_ERROR_CODES = [
8
8
  'X_ENTITY_DUPLICATE',
9
9
  'X_INVARIANT_VIOLATED',
10
10
  'X_TENANCY_UNSCOPED',
11
+ 'X_TENANCY_ACTOR_MISMATCH',
12
+ 'X_TENANCY_ACTOR_ORG_REQUIRED',
13
+ 'X_TENANCY_CROSS_DENIED',
11
14
  'X_NOT_FOUND',
15
+ 'X_WRITE_UNFILTERED',
16
+ 'X_PATCH_EMPTY',
17
+ 'X_PRELOAD_UNKNOWN_RELATION',
18
+ 'X_N_PLUS_ONE_QUERY',
19
+ 'X_N_PLUS_ONE_WRITE',
20
+ 'X_REPO_CLIENT_PINNED',
12
21
  ] as const;
13
22
 
14
23
  /**
@@ -31,7 +40,18 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
31
40
  X_ENTITY_DUPLICATE: 'two entities claim the same name',
32
41
  X_INVARIANT_VIOLATED: 'a domain invariant rejected this row',
33
42
  X_TENANCY_UNSCOPED: 'a tenant-scoped query has no org predicate',
43
+ // "call", not "query": the same code covers a predicate that names another tenant and a row or
44
+ // patch that writes one, because they are one mistake made in two places.
45
+ X_TENANCY_ACTOR_MISMATCH: "a call named a tenant other than the actor's",
46
+ X_TENANCY_ACTOR_ORG_REQUIRED: 'the acting actor carries no tenant',
47
+ X_TENANCY_CROSS_DENIED: 'a cross-tenant read was entered without the capability',
34
48
  X_NOT_FOUND: 'no row for that id',
49
+ X_WRITE_UNFILTERED: 'a filtered write named no filter columns',
50
+ X_PATCH_EMPTY: 'a filtered update named no columns to write',
51
+ X_PRELOAD_UNKNOWN_RELATION: 'no relation of that name on this entity',
52
+ X_N_PLUS_ONE_QUERY: 'a read repeated once per row',
53
+ X_N_PLUS_ONE_WRITE: 'a write repeated once per row',
54
+ X_REPO_CLIENT_PINNED: 'a repository pinned to its own client cannot join the open transaction',
35
55
  };
36
56
 
37
57
  // Registered at module load, unconditionally, in one call. Without this the registry humanises the
@@ -54,6 +74,38 @@ export class EntityError extends UltimateError {
54
74
  }
55
75
  }
56
76
 
77
+ /**
78
+ * A value from an app, rendered for a `cause` — and it may not throw, whatever the app put there.
79
+ * `JSON.stringify` raises a `TypeError` on a bigint and on a cyclic structure, and runs any
80
+ * `toJSON` the value carries, so building the message could raise INSTEAD of the refusal: the
81
+ * caller then gets `TypeError: cannot serialize BigInt` where a tenancy denial belongs, catching
82
+ * by code finds nothing to catch, and an HTTP surface answers 500 rather than the mapped status.
83
+ * A security refusal is the last message in the framework that may be lost to its own formatting.
84
+ *
85
+ * A cause DESCRIBES, so degrading to a type name costs nothing a reader needs; the `fix:` lines
86
+ * that must parse take the stricter route beside each one — a string, or a placeholder.
87
+ * Interpolation is avoided for the same reason: `${symbol}` throws where `String(symbol)` does not.
88
+ */
89
+ const renderValue = (value: unknown): string => {
90
+ if (value === undefined) return 'undefined';
91
+ if (typeof value === 'bigint') return `${value}n`;
92
+ if (typeof value === 'symbol') return String(value);
93
+ try {
94
+ // `undefined` for a function or a symbol-keyed nothing; the type name is the honest answer.
95
+ return JSON.stringify(value) ?? `a ${typeof value}`;
96
+ } catch {
97
+ return `a ${typeof value} that cannot be rendered`;
98
+ }
99
+ };
100
+
101
+ /**
102
+ * The same value where the text has to PARSE: a string literal, or the placeholder that stands in
103
+ * for one. The placeholder is a parameter because what is missing differs — an org in one fix line,
104
+ * an actor id in another — and a fix that names the wrong thing is not one.
105
+ */
106
+ const asLiteral = (value: unknown, placeholder: string): string =>
107
+ typeof value === 'string' ? JSON.stringify(value) : placeholder;
108
+
57
109
  export const entityDuplicate = (name: string, existingTable: string): EntityError =>
58
110
  new EntityError({
59
111
  code: 'X_ENTITY_DUPLICATE',
@@ -69,14 +121,146 @@ export const invariantViolated = (
69
121
  new EntityError({
70
122
  code: 'X_INVARIANT_VIOLATED',
71
123
  cause: `${entityName}.${invariantName}: ${message}`,
72
- fix: `x entity explain ${entityName} --json # shows the invariant and its SQL CHECK`,
124
+ fix: `x entities describe ${entityName} --json # shows the invariant and its SQL CHECK`,
73
125
  });
74
126
 
75
- export const tenancyUnscoped = (entityName: string, operation: string): EntityError =>
127
+ /**
128
+ * One code, two situations, and they do not share a repair — so the cause and the fix branch on
129
+ * which one it is rather than one wording claiming the other's facts.
130
+ *
131
+ * `actorOrg` absent: no request context at all, which is a script, a boot path or a test harness.
132
+ * Nothing derived the tenant because there was no actor to derive it from, so the fix leads with
133
+ * the context and offers naming the tenant by hand as the fallback.
134
+ *
135
+ * `actorOrg` present: `assertScoped` was handed a plan somebody else built. It cannot derive — a
136
+ * verifier has nowhere to put a predicate — so the fix names the two calls that can.
137
+ */
138
+ export const tenancyUnscoped = (
139
+ entityName: string,
140
+ operation: string,
141
+ actorOrg?: string,
142
+ ): EntityError =>
76
143
  new EntityError({
77
144
  code: 'X_TENANCY_UNSCOPED',
78
- cause: `${entityName}.${operation}() was built without an org predicate but the entity has an orgId column`,
79
- fix: `pass { orgId } to ${entityName}.${operation}(), or wrap the plan with orgScoped(entity, orgId, plan)`,
145
+ cause:
146
+ actorOrg === undefined
147
+ ? `${entityName}.${operation}() was built without an org predicate but the entity has an orgId column, and no request context carried an actor to take the tenant from`
148
+ : `${entityName}.${operation}() was checked against a plan with no org predicate, though the acting actor's tenant is ${renderValue(actorOrg)} — a plan is verified here, never rewritten, so the tenant had to be on it already`,
149
+ fix:
150
+ actorOrg === undefined
151
+ ? `run it inside runWithContext(createContext({ actor: userActor({ id, orgId }) }), fn) — the actor's org scopes the plan — or name the tenant: ${entityName}.${operation}({ orgId }), which orgScoped(plan, orgId) is the plan-level form of`
152
+ : `build the plan with scopedPlan('${entityName}', tenantColumn, '${operation}', plan) — it applies the actor's tenant — or add the predicate first: orgScoped(plan, ${asLiteral(actorOrg, "'<org>'")})`,
153
+ });
154
+
155
+ /**
156
+ * The vulnerability this whole guard exists for: an `orgId` that arrived as action input, a query
157
+ * argument or a path parameter, passed into a repository call that then read somebody else's rows.
158
+ * Both values are in the cause because that is the only way a reader can tell an attack from a
159
+ * handler that threaded the wrong variable — an org id is an opaque identifier, not a secret.
160
+ *
161
+ * Refused rather than overridden: silently rewriting the predicate to the actor's org would hand
162
+ * back a correct answer to a call that asked the wrong question, and the bug would ship.
163
+ */
164
+ export const tenancyActorMismatch = (init: {
165
+ entityName: string;
166
+ operation: string;
167
+ named: unknown;
168
+ actorOrg: string;
169
+ }): EntityError => {
170
+ // `undefined` is a real case — `where('orgId', 'is-null')` names the column and no value — and
171
+ // a predicate carries whatever the app put in it, which is why both halves go through
172
+ // `renderValue`: a bigint or a cyclic value here would otherwise throw in place of the refusal.
173
+ // The fix takes the strict route — `where('orgId', 'in', [a, b])` and `is-null` both reach here,
174
+ // and neither `orgId: ["a","b"]` nor `orgId: undefined` is an org anybody can act as.
175
+ return new EntityError({
176
+ code: 'X_TENANCY_ACTOR_MISMATCH',
177
+ cause: `${init.entityName}.${init.operation}() was scoped to tenant ${renderValue(init.named)} but the acting actor's tenant is ${renderValue(init.actorOrg)}`,
178
+ fix: `drop the orgId argument from ${init.entityName}.${init.operation}() — the actor's tenant scopes it — or act as that tenant: withChildContext({ actor: userActor({ id, orgId: ${asLiteral(init.named, "'<org>'")} }) }, fn). A read that must span tenants is crossTenant('<why>', fn)`,
179
+ });
180
+ };
181
+
182
+ /**
183
+ * The write half of the same mistake, and deliberately the SAME code: a tenant the caller chose,
184
+ * named in a row or a patch instead of in a predicate. One situation, one code — the shape
185
+ * `crossTenantUpsert` and `tenancyUnscoped` already share in `bulk-write.ts` — because a caller
186
+ * catching "the tenant you named is not yours" has no reason to care which argument carried it.
187
+ * Only the repair differs, so only the `fix` does.
188
+ *
189
+ * The actor's own tenant goes in the fix because it is the value that makes the call legal and it
190
+ * is pasteable; the row's is in the cause, where a non-scalar can do no harm.
191
+ */
192
+ export const tenancyRowMismatch = (init: {
193
+ entityName: string;
194
+ operation: string;
195
+ column: string;
196
+ named: unknown;
197
+ actorOrg: string;
198
+ }): EntityError =>
199
+ new EntityError({
200
+ code: 'X_TENANCY_ACTOR_MISMATCH',
201
+ // A row literal is the least constrained input in the framework — this guard runs BEFORE
202
+ // `$assert`, on purpose, so the value has been through no parse at all when it is rendered.
203
+ cause: `${init.entityName}.${init.operation}() would write ${init.column} ${renderValue(init.named)} but the acting actor's tenant is ${renderValue(init.actorOrg)}`,
204
+ fix: `set ${init.column} to ${asLiteral(init.actorOrg, "'<org>'")} in the row passed to ${init.entityName}.${init.operation}(), or write into another tenant deliberately: crossTenant('<why>', fn)`,
205
+ });
206
+
207
+ /**
208
+ * A tenant-scoped read by an actor that carries no tenant — anonymous, or a service actor minted
209
+ * without one. Refused, and deliberately not softened into "then the caller's value stands": that
210
+ * fallback is exactly the hole, because an unauthenticated request would name any tenant it liked.
211
+ *
212
+ * Same shape as `@ultimat3/flags`' `X_FLAG_SUBJECT_REQUIRED`: an absent fact is not a satisfied
213
+ * one, and the repair is at the boundary that mints the actor, so the fix names that call.
214
+ */
215
+ export const tenancyActorOrgRequired = (init: {
216
+ entityName: string;
217
+ operation: string;
218
+ actorId: string;
219
+ actorKind: string;
220
+ }): EntityError =>
221
+ new EntityError({
222
+ code: 'X_TENANCY_ACTOR_ORG_REQUIRED',
223
+ cause: `${init.entityName}.${init.operation}() reads a tenant-scoped entity, but the ${init.actorKind} actor ${renderValue(init.actorId)} carries no orgId — there is no tenant to scope it to`,
224
+ fix: `mint the actor with its tenant at the request boundary — userActor({ id: ${asLiteral(init.actorId, "'<actor id>'")}, orgId: '<org>' }) — or, for a sweep that legitimately spans tenants, crossTenant('<why>', fn)`,
225
+ });
226
+
227
+ /**
228
+ * The escape hatch refusing to open. It names the scope string because that is what the operator
229
+ * has to grant, and it fires at `crossTenant()` itself rather than at the first query inside it —
230
+ * the call that asked for the capability is the one that has to be repaired.
231
+ */
232
+ export const crossTenantDenied = (init: {
233
+ reason: string;
234
+ actor: string;
235
+ scope: string;
236
+ }): EntityError =>
237
+ new EntityError({
238
+ code: 'X_TENANCY_CROSS_DENIED',
239
+ cause: `crossTenant(${JSON.stringify(init.reason)}) was entered by ${init.actor}, which does not carry the ${JSON.stringify(init.scope)} scope`,
240
+ fix: `grant the capability where the actor is minted — serviceActor({ id: 'reconciler', scopes: ['${init.scope}'] }) — and run the sweep inside runWithContext(createContext({ actor }), fn); an ordinary request scopes to its own tenant instead and needs no crossTenant()`,
241
+ });
242
+
243
+ /**
244
+ * A repository built with an explicit `client`, used while a transaction is open.
245
+ *
246
+ * Refused rather than resolved, because neither answer is available. `withTransaction` reserves
247
+ * ONE connection and runs `BEGIN` on it — the ambient pool's, or a reservation of its own
248
+ * `client:` option — while a repository pinned through `postgresDriver({ client })` sends every
249
+ * statement straight to that client, which takes a different connection out of the pool. So the
250
+ * write commits immediately and survives the rollback, and the read cannot see the rows the
251
+ * transaction has already written; both are silent. Joining the transaction instead would be the
252
+ * worse half of the same guess: a `DbTx` does not name the client it was opened on, so "is this
253
+ * even the same database" is not a question this layer can ask, and on a sharded app the answer
254
+ * is no.
255
+ *
256
+ * The fix names the ambient seam because that is the one path a repository joins a transaction
257
+ * through: `db()` resolves `currentTx()` first, which is exactly what a pinned client skips.
258
+ */
259
+ export const repoClientPinned = (entityName: string): EntityError =>
260
+ new EntityError({
261
+ code: 'X_REPO_CLIENT_PINNED',
262
+ cause: `${entityName} is served by a repository pinned to its own client, and a transaction is open — its statements would run on another connection, outside that transaction, committed whether it commits or rolls back`,
263
+ fix: `setDbClient(client) at boot and build the repository with no client: — postgresDriver() then resolves the open transaction through db() — or run this call outside withTransaction()`,
80
264
  });
81
265
 
82
266
  export const dbDrift = (tableName: string, columnName: string): EntityError =>
@@ -90,5 +274,162 @@ export const notFound = (entityName: string, id: string): EntityError =>
90
274
  new EntityError({
91
275
  code: 'X_NOT_FOUND',
92
276
  cause: `${entityName} ${id} does not exist (or is soft-deleted)`,
93
- fix: `x db query "select id from ${entityName} limit 5" --json # confirm the id you expect`,
277
+ fix: `repo.findMany({ includeDeleted: true, limit: 5 }) # a soft-deleted row answers there and never from findById`,
278
+ });
279
+
280
+ /**
281
+ * A filtered write with no filter is refused rather than read as "every row": an empty filter is
282
+ * what a forgotten variable produces, and the two intentions look identical at the call site.
283
+ *
284
+ * One code for `deleteWhere` and `updateWhere`, not one each. The situation is a single one — a
285
+ * filtered write that named no filter — and the remedy is a single edit; splitting it by verb
286
+ * would give two codes the same `fix` and make a caller decide which to catch.
287
+ */
288
+ export const writeUnfiltered = (
289
+ entityName: string,
290
+ operation: string,
291
+ primaryKey: readonly string[],
292
+ ): EntityError =>
293
+ new EntityError({
294
+ code: 'X_WRITE_UNFILTERED',
295
+ cause: `${entityName}.${operation}() named no filter columns — an empty filter would reach every row`,
296
+ fix: `${entityName}.${operation}({ ${primaryKey.join(', ')} }, …) # name the columns that bound it. A deliberate whole-table write is a migration: x db gen "<name>"`,
297
+ });
298
+
299
+ /**
300
+ * An empty patch is refused for the same reason an empty filter is, and it is the same mistake one
301
+ * argument along: `updateWhere(filter, { lastReadAt })` on a variable that came back undefined
302
+ * reduces to `{}`. Reporting "n rows updated" for a statement that wrote nothing is exactly the
303
+ * silent no-op the count was added to make impossible.
304
+ */
305
+ /**
306
+ * The declared names go in the `fix` because there is nowhere to go and read them: a relation is
307
+ * derived from a `references()` column, never declared, so a schema file lists foreign keys and
308
+ * not relation names. The first one is spelled as a call the reader can paste — a name alone
309
+ * still leaves them writing the expression — and the rest follow it.
310
+ *
311
+ * An entity with no foreign key at all is the other mistake, and the declaration it needs names an
312
+ * entity this error cannot know. So it leads with the command that lists the ones to pick from
313
+ * rather than with a placeholder nobody can resolve.
314
+ */
315
+ export const preloadUnknownRelation = (
316
+ entityName: string,
317
+ relation: string,
318
+ declared: readonly string[],
319
+ ): EntityError => {
320
+ const [first, ...rest] = declared;
321
+ return new EntityError({
322
+ code: 'X_PRELOAD_UNKNOWN_RELATION',
323
+ cause: `${entityName} has no relation named "${relation}"`,
324
+ fix:
325
+ first === undefined
326
+ ? `x entities list --json # then add .references(() => <target>.id) to the ${entityName} column that points at one`
327
+ : `relationNamed('${entityName}', '${first}')` +
328
+ (rest.length === 0 ? '' : ` # or: ${rest.join(', ')}`),
329
+ });
330
+ };
331
+
332
+ export const patchEmpty = (
333
+ entityName: string,
334
+ operation: string,
335
+ columns: readonly string[],
336
+ ): EntityError =>
337
+ new EntityError({
338
+ code: 'X_PATCH_EMPTY',
339
+ cause: `${entityName}.${operation}() named no columns to write`,
340
+ fix: `${entityName}.${operation}(filter, { <column>: <value> }) # pick a column from: ${columns.join(', ')}`,
341
+ });
342
+
343
+ /**
344
+ * One chain that would have read the loop's rows with the page that caused it. `from` is the entity
345
+ * being paged and `relation` its own name for the edge — both derived from a `references()` column,
346
+ * never invented here, so the fix is a call that already resolves.
347
+ */
348
+ export interface PreloadCandidate {
349
+ readonly from: string;
350
+ readonly relation: string;
351
+ }
352
+
353
+ /**
354
+ * How the repeated read is read once instead, in the order the fix prefers. A relation the schema
355
+ * declared gives the exact `preload()` call; an entity with no such relation still has the `in`
356
+ * form of the statement it was already sending; a statement no repository sent has neither — there
357
+ * is no chain to name, and the batched form of hand-written SQL is the author's own.
358
+ */
359
+ export type QueryLoopBatch =
360
+ | {
361
+ readonly form: 'preload';
362
+ /** Non-empty by construction: a `preload` fix with nothing to name is an empty fix line. */
363
+ readonly candidates: readonly [PreloadCandidate, ...PreloadCandidate[]];
364
+ }
365
+ | { readonly form: 'in'; readonly entity: string }
366
+ | { readonly form: 'sql' };
367
+
368
+ /** The same three-way choice for a loop that writes: the entity's bulk call, or hand-written SQL. */
369
+ export type WriteLoopBatch =
370
+ | { readonly form: 'bulk'; readonly entity: string; readonly op: string | undefined }
371
+ | { readonly form: 'sql' };
372
+
373
+ /**
374
+ * The first candidate is spelled as a call to paste and the rest follow it, exactly as
375
+ * `preloadUnknownRelation` spells its names: one loop can be answered from more than one page —
376
+ * two entities may both reference the one being looked up — and the diagnostic saw the repeated
377
+ * statement, never the `for … of` above it, so it names them all rather than guessing which page
378
+ * this request was iterating.
379
+ */
380
+ const preloadCalls = (candidates: readonly [PreloadCandidate, ...PreloadCandidate[]]): string => {
381
+ const [first, ...rest] = candidates.map(
382
+ ({ from, relation }) => `db.${from}.preload('${relation}')`,
383
+ );
384
+ return `${first} # one statement for the whole page${rest.length === 0 ? '' : `, or: ${rest.join(', ')}`}`;
385
+ };
386
+
387
+ /**
388
+ * The bulk form of each single-row write — the call a loop of it collapses into. `deleteWhere` is
389
+ * here too: the three are one situation, and naming only the two the code's name mentions would
390
+ * hand a delete loop a fix for someone else's.
391
+ */
392
+ const BULK_WRITE_CALLS: Readonly<Record<string, string>> = {
393
+ insert: 'insertAll(rows)',
394
+ update: 'updateWhere(filter, patch)',
395
+ delete: 'deleteWhere(filter)',
396
+ };
397
+
398
+ /** An op with no bulk form of its own — a batch loop, or a name this table has not met — names both. */
399
+ const bulkWriteCall = (entityName: string, op: string | undefined): string => {
400
+ const call = op === undefined ? undefined : BULK_WRITE_CALLS[op];
401
+ return call !== undefined
402
+ ? `db.${entityName}.${call}`
403
+ : `db.${entityName}.insertAll(rows), or db.${entityName}.updateWhere(filter, patch) for a loop of patches`;
404
+ };
405
+
406
+ /**
407
+ * A read issued once per row of a page. Reported as a `Finding` by whatever installed the statement
408
+ * observer — `x dev`, which never throws it, and `@ultimat3/testing`'s `statements` fixture, which
409
+ * throws it at the statement that crossed the threshold — which is why the count is in the cause
410
+ * rather than a threshold in the fix, and why the fix is a chain and never a flag to turn the
411
+ * warning off. `expectedQueryLoop(reason, fn)` is the one way to say a loop is deliberate, and it
412
+ * silences the count upstream of this error rather than answering it.
413
+ */
414
+ export const nPlusOneQuery = (subject: string, count: number, batch: QueryLoopBatch): EntityError =>
415
+ new EntityError({
416
+ code: 'X_N_PLUS_ONE_QUERY',
417
+ cause: `${subject} ran ${count} times in one request — one read per row`,
418
+ fix:
419
+ batch.form === 'preload'
420
+ ? preloadCalls(batch.candidates)
421
+ : batch.form === 'in'
422
+ ? `db.${batch.entity}.andWhere('id', 'in', ids).all() # read the set once, then look each row up in memory`
423
+ : `send one statement for the set — "where <key> = any($1)" — or expectedQueryLoop('<why one per row is optimal>', fn) when the loop is deliberate`,
424
+ });
425
+
426
+ /** The same loop, writing. One statement per row is one round trip and one transaction entry each. */
427
+ export const nPlusOneWrite = (subject: string, count: number, batch: WriteLoopBatch): EntityError =>
428
+ new EntityError({
429
+ code: 'X_N_PLUS_ONE_WRITE',
430
+ cause: `${subject} ran ${count} times in one request — one write per row`,
431
+ fix:
432
+ batch.form === 'bulk'
433
+ ? `${bulkWriteCall(batch.entity, batch.op)} # one statement for the whole set`
434
+ : `send one statement for the set — "insert … values" over every row, or "update … where id = any($1)" — or expectedQueryLoop('<why one per row is optimal>', fn) when the loop is deliberate`,
94
435
  });
package/src/expr.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // does not know this rule — silently pretending it reached Postgres would be worse.
10
10
 
11
11
  import { invariantViolated } from './errors';
12
+ import type { ColumnMap } from './types';
12
13
 
13
14
  export type Row = Readonly<Record<string, unknown>>;
14
15
 
@@ -48,13 +49,21 @@ export interface ColumnExpr {
48
49
 
49
50
  type RowPredicate = (...values: never[]) => boolean;
50
51
 
51
- export type InvariantColumns = {
52
- readonly [column: string]: ColumnExpr;
52
+ /**
53
+ * A mapped type over the declared columns, never an index signature: under
54
+ * `noUncheckedIndexedAccess` an index signature makes `c.title` `ColumnExpr | undefined`, so every
55
+ * invariant needed a `!`. Mapped, `c.title` is a `ColumnExpr` and `c.titel` is a compile error
56
+ * that suggests the real name. `entity()` supplies `C` because `invariants` is a callback — a
57
+ * per-element `invariant(name, build)` call is checked before `C` is fixed, so `K` fell back to
58
+ * `string` and nothing reached it.
59
+ */
60
+ export type InvariantColumns<C extends ColumnMap = ColumnMap> = {
61
+ readonly [K in keyof C]: ColumnExpr;
53
62
  } & {
54
63
  /** Decided by the database — a single row cannot see a duplicate. */
55
- unique(columns: readonly string[]): Expr;
64
+ unique(columns: readonly (keyof C & string)[]): Expr;
56
65
  /** Lifts a domain predicate over several columns. App-only by construction. */
57
- satisfies(predicate: RowPredicate, columns: readonly string[]): Expr;
66
+ satisfies(predicate: RowPredicate, columns: readonly (keyof C & string)[]): Expr;
58
67
  };
59
68
 
60
69
  const terms = new WeakMap<ColumnExpr, Term>();
@@ -68,6 +77,33 @@ const walk = (row: Row, path: readonly string[]): unknown =>
68
77
  const literal = (value: unknown): string =>
69
78
  typeof value === 'string' ? `'${value.replaceAll("'", "''")}'` : String(value);
70
79
 
80
+ /**
81
+ * The Postgres operator a `RegExp`'s flags mean — the second half of "one declaration, two
82
+ * enforcement points". `toSql` used to emit `~ <pattern.source>` and nothing else, so
83
+ * `c.slug.matches(/^[A-Z]+$/i)` approved `'abc'` in the app (`pattern.test`, flags intact) while
84
+ * the CHECK it generated was case-SENSITIVE and refused the same row — the app's own invariant
85
+ * bypassed, and the write coming back as a raw constraint error rather than
86
+ * `X_INVARIANT_VIOLATED`.
87
+ *
88
+ * `i` is the one flag with an operator (`~*`). Every other flag is REFUSED rather than dropped:
89
+ * `m` and `s` change what the pattern matches, `g` makes `pattern.test` stateful across calls so
90
+ * even `holds` stops being a function of the row, and `u`/`v`/`y`/`d` have no POSIX equivalent at
91
+ * all. A CHECK quietly missing a flag is the same disagreement one character along.
92
+ */
93
+ const matchOperator = (pattern: RegExp): string => {
94
+ const flags = pattern.flags.replaceAll('i', '');
95
+ if (flags !== '') {
96
+ throw invariantViolated(
97
+ 'invariant',
98
+ 'matches',
99
+ `/${pattern.source}/${pattern.flags} carries the flag${flags.length === 1 ? '' : 's'} ` +
100
+ `"${flags}", which Postgres has no operator for — drop it and fold the behaviour into ` +
101
+ `the pattern, or pass a function instead: matches((value) => /${pattern.source}/${pattern.flags}.test(value)), which is app-only and reports sql: null`,
102
+ );
103
+ }
104
+ return pattern.ignoreCase ? '~*' : '~';
105
+ };
106
+
71
107
  const check = (
72
108
  paths: readonly (readonly string[])[],
73
109
  message: string,
@@ -101,7 +137,12 @@ const expr = (term: Term): ColumnExpr => {
101
137
  one(
102
138
  `${term.label} must be at least ${length} character${length === 1 ? '' : 's'}`,
103
139
  (resolve) => `char_length(${term.sql(resolve)}) >= ${length}`,
104
- (value) => typeof value === 'string' && value.length >= length,
140
+ // `[...value].length`, never `value.length`: JS counts UTF-16 code units and
141
+ // `char_length()` counts CHARACTERS, so `'👍'` was 2 here and 1 in Postgres — the app
142
+ // approved a row the CHECK then refused, which reaches the caller as a raw constraint
143
+ // error instead of `X_INVARIANT_VIOLATED`. Code points, not graphemes: that is what
144
+ // Postgres counts, and agreeing with the database is the whole point of this file.
145
+ (value) => typeof value === 'string' && [...value].length >= length,
105
146
  ),
106
147
 
107
148
  contains: (value) =>
@@ -111,15 +152,22 @@ const expr = (term: Term): ColumnExpr => {
111
152
  (actual) => typeof actual === 'string' && actual.includes(value),
112
153
  ),
113
154
 
114
- matches: (pattern) =>
115
- one(
155
+ matches: (pattern) => {
156
+ // Read at DECLARATION, not inside `toSql`: an unsupported flag is the author's mistake and
157
+ // the entity file is where it is repaired, so the refusal lands on the line that wrote it
158
+ // rather than during migration generation, where the entity name is all anyone would see.
159
+ const emitted =
160
+ pattern instanceof RegExp
161
+ ? `${matchOperator(pattern)} ${literal(pattern.source)}`
162
+ : undefined;
163
+ return one(
116
164
  `${term.label} must match ${pattern instanceof RegExp ? pattern.source : pattern.name || 'the rule'}`,
117
- (resolve) =>
118
- pattern instanceof RegExp ? `${term.sql(resolve)} ~ ${literal(pattern.source)}` : null,
165
+ (resolve) => (emitted === undefined ? null : `${term.sql(resolve)} ${emitted}`),
119
166
  (value) =>
120
167
  typeof value === 'string' &&
121
168
  (pattern instanceof RegExp ? pattern.test(value) : pattern(value)),
122
- ),
169
+ );
170
+ },
123
171
 
124
172
  atLeast: (bound) =>
125
173
  one(
@@ -205,13 +253,15 @@ const satisfies = (predicate: RowPredicate, columns: readonly string[]): Expr =>
205
253
  );
206
254
 
207
255
  /**
208
- * The `c` an invariant is written against. A Proxy so a typo in a column name is a
209
- * declaration-time error naming the columns that do exist, not `undefined is not a function`.
256
+ * The `c` an invariant is written against. Still a Proxy even though `InvariantColumns<C>` now
257
+ * catches a typo at compile time: a JS caller, a dynamically built rule and a `satisfies()` column
258
+ * list all reach it untyped, and the thrown message names the columns that do exist rather than
259
+ * failing later as `undefined is not a function`.
210
260
  */
211
- export const invariantColumns = (
261
+ export const invariantColumns = <C extends ColumnMap>(
212
262
  entity: string,
213
263
  properties: readonly string[],
214
- ): InvariantColumns => {
264
+ ): InvariantColumns<C> => {
215
265
  const known = new Set(properties);
216
266
  const helpers = { unique, satisfies };
217
267
  return new Proxy(helpers, {
@@ -227,5 +277,5 @@ export const invariantColumns = (
227
277
  }
228
278
  return expr(columnTerm(property));
229
279
  },
230
- }) as InvariantColumns;
280
+ }) as unknown as InvariantColumns<C>;
231
281
  };
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  /** Re-exported so an `entity` file needs one import, not two. Same object as schema's. */
4
4
  export type { Infer } from '@ultimat3/schema';
5
5
  export { t } from '@ultimat3/schema';
6
+ export type { BatchIterator } from './batch';
6
7
  export type { TextOptions } from './columns';
7
8
  export {
8
9
  boolean,
@@ -17,12 +18,23 @@ export {
17
18
  url,
18
19
  uuid,
19
20
  } from './columns';
21
+ // `crossTenantReason` stays internal: an app that could read the flag would have a second way to
22
+ // reason about tenant scope — branch on it — next to the one way, which is entering the scope.
23
+ export { CROSS_TENANT_SCOPE, crossTenant } from './cross-tenant';
20
24
  export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
21
- export { database, memoryDriver } from './database';
25
+ export { database, defaultDriver, memoryDriver } from './database';
22
26
  export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
23
27
  export { entity, SOFT_DELETE_COLUMN } from './entity';
24
- export type { EntityErrorCode } from './errors';
28
+ export type {
29
+ EntityErrorCode,
30
+ PreloadCandidate,
31
+ QueryLoopBatch,
32
+ WriteLoopBatch,
33
+ } from './errors';
34
+ // Every error factory this package owns, its three tenancy siblings included: `Driver` is public,
35
+ // so a third-party driver has to be able to raise the same refusals the two shipped ones do.
25
36
  export {
37
+ crossTenantDenied,
26
38
  dbDrift,
27
39
  ENTITY_ERROR_CODES,
28
40
  ENTITY_ERROR_TITLES,
@@ -30,7 +42,16 @@ export {
30
42
  entityDuplicate,
31
43
  invariantViolated,
32
44
  notFound,
45
+ nPlusOneQuery,
46
+ nPlusOneWrite,
47
+ patchEmpty,
48
+ preloadUnknownRelation,
49
+ repoClientPinned,
50
+ tenancyActorMismatch,
51
+ tenancyActorOrgRequired,
52
+ tenancyRowMismatch,
33
53
  tenancyUnscoped,
54
+ writeUnfiltered,
34
55
  } from './errors';
35
56
  export type { ColumnExpr, Expr, InvariantColumns, Resolve } from './expr';
36
57
  export type { Invariant, InvariantDef, InvariantKind } from './invariants';
@@ -39,16 +60,25 @@ export {
39
60
  constraintName,
40
61
  invariant,
41
62
  invariantsToSql,
63
+ MAX_ASSERTED_ROWS,
42
64
  toSql,
43
65
  } from './invariants';
66
+ export type { StatementLoop } from './n-plus-one';
67
+ export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
44
68
  export type { PostgresDriverOptions } from './pg-driver';
45
69
  export { postgresDriver, postgresRepo, postgresTransactor } from './pg-driver';
46
- export type { ReadBuilder, Table } from './query';
70
+ // The two page bounds, beside `N_PLUS_ONE_THRESHOLD` and for the same reason: an app validating
71
+ // its own `pageSize` input against a hardcoded 10_000 is a second declaration of one number.
72
+ export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from './plan';
73
+ export type { RelatedTable, RelatedTables } from './preload';
74
+ export type { Preloaded, ReadBuilder, Table } from './query';
47
75
  export { tableFor } from './query';
48
76
  export type {
49
77
  ColumnDescription,
50
78
  EntityDescription,
79
+ IndexDescription,
51
80
  InvariantDescription,
81
+ ReferenceDescription,
52
82
  RegistryEntry,
53
83
  } from './registry';
54
84
  export {
@@ -57,13 +87,26 @@ export {
57
87
  entityNames,
58
88
  getEntity,
59
89
  registerEntity,
90
+ registeredEntities,
60
91
  } from './registry';
61
- export type { FindManyArgs, Page, Repo, RepoOptions, Transactor, Tx } from './repo';
92
+ export type { EntityRelations, Relation, RelationKind, RelationMap } from './relations';
93
+ export { relationMap, relationNamed, relationsFor, relationsOf } from './relations';
94
+ export type {
95
+ FindManyArgs,
96
+ MemoryRepo,
97
+ Page,
98
+ Repo,
99
+ RepoOptions,
100
+ Transactor,
101
+ Tx,
102
+ UpsertArgs,
103
+ } from './repo';
62
104
  export { memoryRepo, memoryTransactor } from './repo';
63
105
  export type { Seed, SeedContext, SeedOptions } from './seed';
64
106
  export { defineSeed, seedId } from './seed';
65
107
  export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
66
108
  export {
109
+ assertRowTenant,
67
110
  assertScoped,
68
111
  describePlan,
69
112
  emptyPlan,
@@ -71,6 +114,7 @@ export {
71
114
  isOrgScoped,
72
115
  ORG_COLUMN,
73
116
  orgScoped,
117
+ scopedPlan,
74
118
  tenantColumnOf,
75
119
  } from './tenancy';
76
120
  export type {
@@ -80,6 +124,7 @@ export type {
80
124
  ColumnKind,
81
125
  ColumnMap,
82
126
  ColumnMeta,
127
+ IdOf,
83
128
  IndexDef,
84
129
  Insertable,
85
130
  MoneyInput,