@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/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
|
|
124
|
+
fix: `x entities describe ${entityName} --json # shows the invariant and its SQL CHECK`,
|
|
73
125
|
});
|
|
74
126
|
|
|
75
|
-
|
|
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:
|
|
79
|
-
|
|
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: `
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
209
|
-
*
|
|
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,7 +3,10 @@
|
|
|
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 {
|
|
6
|
+
export type { BatchIterator } from './batch';
|
|
7
|
+
export type { MoneyColumns } from './column';
|
|
8
|
+
export { columnName, moneyColumns, snake } from './column';
|
|
9
|
+
export type { MoneyOptions, TextOptions } from './columns';
|
|
7
10
|
export {
|
|
8
11
|
boolean,
|
|
9
12
|
enumerated,
|
|
@@ -17,12 +20,30 @@ export {
|
|
|
17
20
|
url,
|
|
18
21
|
uuid,
|
|
19
22
|
} from './columns';
|
|
23
|
+
// The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
|
|
24
|
+
// are decisions this framework made for a table it was going to create, and these are the shapes
|
|
25
|
+
// a table already has (`docs`: Entities-And-Migrations, "Adopting an existing database").
|
|
26
|
+
export type { DecimalOptions } from './columns-data';
|
|
27
|
+
export { arrayOf, bigint, bytes, date, decimal, json } from './columns-data';
|
|
28
|
+
// `crossTenantReason` stays internal: an app that could read the flag would have a second way to
|
|
29
|
+
// reason about tenant scope — branch on it — next to the one way, which is entering the scope.
|
|
30
|
+
export { CROSS_TENANT_SCOPE, crossTenant } from './cross-tenant';
|
|
20
31
|
export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
|
|
21
|
-
export { database, memoryDriver } from './database';
|
|
32
|
+
export { database, defaultDriver, memoryDriver } from './database';
|
|
33
|
+
export type { DescribeInput } from './describe';
|
|
34
|
+
export { sqlTypeOf } from './describe';
|
|
22
35
|
export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
|
|
23
36
|
export { entity, SOFT_DELETE_COLUMN } from './entity';
|
|
24
|
-
export type {
|
|
37
|
+
export type {
|
|
38
|
+
EntityErrorCode,
|
|
39
|
+
PreloadCandidate,
|
|
40
|
+
QueryLoopBatch,
|
|
41
|
+
WriteLoopBatch,
|
|
42
|
+
} from './errors';
|
|
43
|
+
// Every error factory this package owns, its three tenancy siblings included: `Driver` is public,
|
|
44
|
+
// so a third-party driver has to be able to raise the same refusals the two shipped ones do.
|
|
25
45
|
export {
|
|
46
|
+
crossTenantDenied,
|
|
26
47
|
dbDrift,
|
|
27
48
|
ENTITY_ERROR_CODES,
|
|
28
49
|
ENTITY_ERROR_TITLES,
|
|
@@ -30,7 +51,16 @@ export {
|
|
|
30
51
|
entityDuplicate,
|
|
31
52
|
invariantViolated,
|
|
32
53
|
notFound,
|
|
54
|
+
nPlusOneQuery,
|
|
55
|
+
nPlusOneWrite,
|
|
56
|
+
patchEmpty,
|
|
57
|
+
preloadUnknownRelation,
|
|
58
|
+
repoClientPinned,
|
|
59
|
+
tenancyActorMismatch,
|
|
60
|
+
tenancyActorOrgRequired,
|
|
61
|
+
tenancyRowMismatch,
|
|
33
62
|
tenancyUnscoped,
|
|
63
|
+
writeUnfiltered,
|
|
34
64
|
} from './errors';
|
|
35
65
|
export type { ColumnExpr, Expr, InvariantColumns, Resolve } from './expr';
|
|
36
66
|
export type { Invariant, InvariantDef, InvariantKind } from './invariants';
|
|
@@ -39,16 +69,25 @@ export {
|
|
|
39
69
|
constraintName,
|
|
40
70
|
invariant,
|
|
41
71
|
invariantsToSql,
|
|
72
|
+
MAX_ASSERTED_ROWS,
|
|
42
73
|
toSql,
|
|
43
74
|
} from './invariants';
|
|
75
|
+
export type { StatementLoop } from './n-plus-one';
|
|
76
|
+
export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
|
|
44
77
|
export type { PostgresDriverOptions } from './pg-driver';
|
|
45
78
|
export { postgresDriver, postgresRepo, postgresTransactor } from './pg-driver';
|
|
46
|
-
|
|
79
|
+
// The two page bounds, beside `N_PLUS_ONE_THRESHOLD` and for the same reason: an app validating
|
|
80
|
+
// its own `pageSize` input against a hardcoded 10_000 is a second declaration of one number.
|
|
81
|
+
export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from './plan';
|
|
82
|
+
export type { RelatedTable, RelatedTables } from './preload';
|
|
83
|
+
export type { Preloaded, ReadBuilder, Table } from './query';
|
|
47
84
|
export { tableFor } from './query';
|
|
48
85
|
export type {
|
|
49
86
|
ColumnDescription,
|
|
50
87
|
EntityDescription,
|
|
88
|
+
IndexDescription,
|
|
51
89
|
InvariantDescription,
|
|
90
|
+
ReferenceDescription,
|
|
52
91
|
RegistryEntry,
|
|
53
92
|
} from './registry';
|
|
54
93
|
export {
|
|
@@ -57,13 +96,36 @@ export {
|
|
|
57
96
|
entityNames,
|
|
58
97
|
getEntity,
|
|
59
98
|
registerEntity,
|
|
99
|
+
registeredEntities,
|
|
60
100
|
} from './registry';
|
|
61
|
-
export type {
|
|
101
|
+
export type { EntityRelations, Relation, RelationKind, RelationMap } from './relations';
|
|
102
|
+
export { relationMap, relationNamed, relationsFor, relationsOf } from './relations';
|
|
103
|
+
export type {
|
|
104
|
+
FindManyArgs,
|
|
105
|
+
MemoryRepo,
|
|
106
|
+
Page,
|
|
107
|
+
Repo,
|
|
108
|
+
RepoOptions,
|
|
109
|
+
Transactor,
|
|
110
|
+
Tx,
|
|
111
|
+
UpsertArgs,
|
|
112
|
+
} from './repo';
|
|
62
113
|
export { memoryRepo, memoryTransactor } from './repo';
|
|
63
|
-
export type {
|
|
64
|
-
|
|
114
|
+
export type {
|
|
115
|
+
Seed,
|
|
116
|
+
SeedContext,
|
|
117
|
+
SeedInit,
|
|
118
|
+
SeedKey,
|
|
119
|
+
SeedMetrics,
|
|
120
|
+
SeedOptions,
|
|
121
|
+
SeedRun,
|
|
122
|
+
SeedTier,
|
|
123
|
+
SeedWrite,
|
|
124
|
+
} from './seed';
|
|
125
|
+
export { defineSeed, isSeed, SEED_TIERS, seedId, seedTiersFor } from './seed';
|
|
65
126
|
export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
|
66
127
|
export {
|
|
128
|
+
assertRowTenant,
|
|
67
129
|
assertScoped,
|
|
68
130
|
describePlan,
|
|
69
131
|
emptyPlan,
|
|
@@ -71,6 +133,7 @@ export {
|
|
|
71
133
|
isOrgScoped,
|
|
72
134
|
ORG_COLUMN,
|
|
73
135
|
orgScoped,
|
|
136
|
+
scopedPlan,
|
|
74
137
|
tenantColumnOf,
|
|
75
138
|
} from './tenancy';
|
|
76
139
|
export type {
|
|
@@ -80,8 +143,10 @@ export type {
|
|
|
80
143
|
ColumnKind,
|
|
81
144
|
ColumnMap,
|
|
82
145
|
ColumnMeta,
|
|
146
|
+
IdOf,
|
|
83
147
|
IndexDef,
|
|
84
148
|
Insertable,
|
|
149
|
+
MoneyColumnNames,
|
|
85
150
|
MoneyInput,
|
|
86
151
|
MoneyValue,
|
|
87
152
|
OnDelete,
|