@ultimat3/entity 3.0.0 → 4.1.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 +60 -5
- package/README.md +7 -0
- package/package.json +5 -5
- package/src/bulk-write.ts +9 -7
- package/src/clock.ts +18 -0
- package/src/column.ts +15 -0
- package/src/count-by.ts +2 -1
- package/src/cursor.ts +23 -2
- package/src/describe.ts +5 -0
- package/src/entity.ts +2 -2
- package/src/index.ts +3 -0
- package/src/jit-preload.ts +66 -10
- package/src/memory-match.ts +169 -0
- package/src/pg-driver.ts +2 -2
- package/src/pg-row.ts +4 -4
- package/src/pg-sql.ts +19 -6
- package/src/plan.ts +5 -4
- package/src/query.ts +7 -7
- package/src/registry.ts +10 -0
- package/src/relations.ts +4 -1
- package/src/repo.ts +70 -99
- package/src/seed.ts +3 -2
- package/src/types.ts +56 -14
package/src/repo.ts
CHANGED
|
@@ -7,17 +7,19 @@
|
|
|
7
7
|
// table silently skips and repeats rows. A keyset cursor is stable because it names a
|
|
8
8
|
// position in the sort order, not a row count.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { keyOf } from './batch-read';
|
|
11
11
|
import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
|
|
12
|
+
import { entityNow } from './clock';
|
|
12
13
|
import { narrowMoney } from './columns';
|
|
13
14
|
import { countsFrom, groupColumnOf } from './count-by';
|
|
14
|
-
import { cursorFor, seekFrom, valueAt } from './cursor';
|
|
15
|
+
import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
|
|
15
16
|
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
16
17
|
import { notFound } from './errors';
|
|
18
|
+
import { compareByKind, matchesPredicate } from './memory-match';
|
|
17
19
|
import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
|
|
18
20
|
import type { Predicate, QueryPlan, SortKey } from './tenancy';
|
|
19
21
|
import { assertRowTenant } from './tenancy';
|
|
20
|
-
import type { IdOf } from './types';
|
|
22
|
+
import type { IdOf, RowPatch } from './types';
|
|
21
23
|
|
|
22
24
|
export interface Tx {
|
|
23
25
|
readonly id: string;
|
|
@@ -52,12 +54,26 @@ export interface UpsertArgs<T = unknown> extends RepoOptions {
|
|
|
52
54
|
readonly onMatch?: 'update' | 'nothing';
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
/**
|
|
58
|
+
* `findById`'s options: `RepoOptions`, plus the one knob a point READ has and a write must not.
|
|
59
|
+
*
|
|
60
|
+
* `includeDeleted` lives here and deliberately NOT on `RepoOptions`. It has always been honoured
|
|
61
|
+
* on this path — `idPlan` spreads the options straight into `FindManyArgs`, and both drivers read
|
|
62
|
+
* `args.includeDeleted === true` — but `RepoOptions` never declared it, so the only documented way
|
|
63
|
+
* to read a soft-deleted row by its id did not typecheck. Putting it on `RepoOptions` instead would
|
|
64
|
+
* have offered it to `update(id, patch)` and `delete(id)`, which reach the same `idPlan`: that is
|
|
65
|
+
* the resurrection those two carry `deleted_at is null` to refuse.
|
|
66
|
+
*/
|
|
67
|
+
export interface FindByIdOptions extends RepoOptions {
|
|
68
|
+
/** Soft-deleted rows are hidden unless the caller asks for them. */
|
|
69
|
+
readonly includeDeleted?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FindManyArgs extends FindByIdOptions {
|
|
56
73
|
readonly where?: readonly Predicate[];
|
|
57
74
|
readonly orderBy?: readonly SortKey[];
|
|
58
75
|
readonly limit?: number;
|
|
59
76
|
readonly cursor?: string | null;
|
|
60
|
-
readonly includeDeleted?: boolean;
|
|
61
77
|
readonly select?: readonly string[];
|
|
62
78
|
}
|
|
63
79
|
|
|
@@ -76,7 +92,7 @@ export interface Page<T> {
|
|
|
76
92
|
* both `string`, so a row-agnostic consumer sees the signature it always saw.
|
|
77
93
|
*/
|
|
78
94
|
export interface Repo<T = unknown> {
|
|
79
|
-
findById(id: IdOf<T>, options?:
|
|
95
|
+
findById(id: IdOf<T>, options?: FindByIdOptions): Promise<T | null>;
|
|
80
96
|
findMany(args?: FindManyArgs): Promise<Page<T>>;
|
|
81
97
|
insert(values: T, options?: RepoOptions): Promise<T>;
|
|
82
98
|
/**
|
|
@@ -93,7 +109,7 @@ export interface Repo<T = unknown> {
|
|
|
93
109
|
* which is what `returning *` says on the Postgres side.
|
|
94
110
|
*/
|
|
95
111
|
upsertAll(rows: readonly T[], args: UpsertArgs<T>): Promise<readonly T[]>;
|
|
96
|
-
update(id: IdOf<T>, patch:
|
|
112
|
+
update(id: IdOf<T>, patch: RowPatch<T>, options?: RepoOptions): Promise<T>;
|
|
97
113
|
delete(id: IdOf<T>, options?: RepoOptions): Promise<void>;
|
|
98
114
|
/**
|
|
99
115
|
* Delete by filter, returning how many rows went. The only way to remove a row from an entity
|
|
@@ -101,14 +117,14 @@ export interface Repo<T = unknown> {
|
|
|
101
117
|
* to be able to tell "nothing matched" from "it worked", and an empty filter is
|
|
102
118
|
* `X_WRITE_UNFILTERED` rather than every row.
|
|
103
119
|
*/
|
|
104
|
-
deleteWhere(filter:
|
|
120
|
+
deleteWhere(filter: RowPatch<T>, options?: RepoOptions): Promise<number>;
|
|
105
121
|
/**
|
|
106
122
|
* Update by filter, returning how many rows were written. The `update(id, patch)` a composite
|
|
107
123
|
* primary key cannot express — `participants.lastReadAt` is the reference case. Same two guards
|
|
108
124
|
* as `deleteWhere`, plus `X_PATCH_EMPTY` for a patch that names no columns, and soft-deleted
|
|
109
125
|
* rows are not reachable, exactly as they are not by `update(id, patch)`.
|
|
110
126
|
*/
|
|
111
|
-
updateWhere(filter:
|
|
127
|
+
updateWhere(filter: RowPatch<T>, patch: RowPatch<T>, options?: RepoOptions): Promise<number>;
|
|
112
128
|
count(args?: FindManyArgs): Promise<number>;
|
|
113
129
|
/**
|
|
114
130
|
* The grouped count: one statement, one entry per distinct value of `column`, over exactly the
|
|
@@ -142,79 +158,22 @@ export interface Transactor {
|
|
|
142
158
|
const field = (row: unknown, property: string): unknown =>
|
|
143
159
|
typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
|
|
144
160
|
|
|
145
|
-
/**
|
|
146
|
-
* `===` on two Dates compares identity, so `where({ publishedAt })` would match nothing here
|
|
147
|
-
* and every row in Postgres. Equality has to mean the same thing in both drivers or the
|
|
148
|
-
* in-memory one stops being a preview of production.
|
|
149
|
-
*/
|
|
150
|
-
const sameValue = (left: unknown, right: unknown): boolean =>
|
|
151
|
-
left instanceof Date && right instanceof Date
|
|
152
|
-
? left.getTime() === right.getTime()
|
|
153
|
-
: left === right;
|
|
154
|
-
|
|
155
|
-
const matches = (row: unknown, predicate: Predicate): boolean => {
|
|
156
|
-
const actual = field(row, predicate.column);
|
|
157
|
-
switch (predicate.op) {
|
|
158
|
-
case 'eq':
|
|
159
|
-
return sameValue(actual, predicate.value);
|
|
160
|
-
case 'neq':
|
|
161
|
-
return !sameValue(actual, predicate.value);
|
|
162
|
-
case 'in':
|
|
163
|
-
return (
|
|
164
|
-
Array.isArray(predicate.value) &&
|
|
165
|
-
predicate.value.some((candidate) => sameValue(candidate, actual))
|
|
166
|
-
);
|
|
167
|
-
case 'gt':
|
|
168
|
-
return compare(actual, predicate.value) > 0;
|
|
169
|
-
case 'gte':
|
|
170
|
-
return compare(actual, predicate.value) >= 0;
|
|
171
|
-
case 'lt':
|
|
172
|
-
return compare(actual, predicate.value) < 0;
|
|
173
|
-
case 'lte':
|
|
174
|
-
return compare(actual, predicate.value) <= 0;
|
|
175
|
-
// Real LIKE semantics, so `'draft%'` means "starts with" here exactly as it does in
|
|
176
|
-
// Postgres. Treating the pattern as a substring would make the two drivers disagree.
|
|
177
|
-
case 'like':
|
|
178
|
-
return likePattern(String(predicate.value)).test(String(actual));
|
|
179
|
-
case 'is-null':
|
|
180
|
-
return actual === null || actual === undefined;
|
|
181
|
-
case 'is-not-null':
|
|
182
|
-
return actual !== null && actual !== undefined;
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* `%` and `_` are the wildcards; everything else in the pattern is literal, as in SQL.
|
|
188
|
-
*
|
|
189
|
-
* A RUN of `%` is one `.*`, not one each: `%%%…x` compiled to twenty adjacent `.*` groups, and an
|
|
190
|
-
* anchored regex with twenty of them takes exponential time to fail on a long value — a filter
|
|
191
|
-
* value an app forwards from a search box is then a CPU stall in the process, on the in-memory
|
|
192
|
-
* driver. Postgres reads a run of `%` as one wildcard too, so this is the two drivers agreeing
|
|
193
|
-
* rather than a defensive narrowing.
|
|
194
|
-
*/
|
|
195
|
-
const likePattern = (pattern: string): RegExp =>
|
|
196
|
-
new RegExp(
|
|
197
|
-
`^${pattern
|
|
198
|
-
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
199
|
-
.replaceAll(/%+/g, '.*')
|
|
200
|
-
.replaceAll('_', '.')}$`,
|
|
201
|
-
's',
|
|
202
|
-
);
|
|
203
|
-
|
|
204
|
-
const compare = (left: unknown, right: unknown): number => {
|
|
205
|
-
if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime();
|
|
206
|
-
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
|
207
|
-
if (typeof left === 'bigint' && typeof right === 'bigint') {
|
|
208
|
-
return left < right ? -1 : left > right ? 1 : 0;
|
|
209
|
-
}
|
|
210
|
-
const [a, b] = [String(left), String(right)];
|
|
211
|
-
return a < b ? -1 : a > b ? 1 : 0;
|
|
212
|
-
};
|
|
213
|
-
|
|
214
161
|
/** Lexicographic over the sort keys, direction applied. `> 0` means "after the cursor". */
|
|
215
|
-
const compareToSeek = (
|
|
162
|
+
const compareToSeek = <Row>(
|
|
163
|
+
entity: EntityCore<Row>,
|
|
164
|
+
plan: QueryPlan,
|
|
165
|
+
row: unknown,
|
|
166
|
+
seek: readonly unknown[],
|
|
167
|
+
): number => {
|
|
216
168
|
for (const [index, entry] of plan.orderBy.entries()) {
|
|
217
|
-
|
|
169
|
+
// The COLUMN's kind, not the value's: the seek was revived from the same kind (`cursor.ts`),
|
|
170
|
+
// so a `bigint` column compares its stored decimal string against a revived `BigInt` as one
|
|
171
|
+
// number instead of as two pieces of text.
|
|
172
|
+
const order = compareByKind(
|
|
173
|
+
kindOf(entity, entry.column),
|
|
174
|
+
valueAt(row, entry.column),
|
|
175
|
+
seek[index],
|
|
176
|
+
);
|
|
218
177
|
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
219
178
|
}
|
|
220
179
|
return 0;
|
|
@@ -232,7 +191,7 @@ const afterCursor = <Row>(
|
|
|
232
191
|
): number => {
|
|
233
192
|
const seek = seekFrom(entity, plan);
|
|
234
193
|
if (seek === undefined) return 0;
|
|
235
|
-
const start = found.findIndex((row) => compareToSeek(plan, row, seek) > 0);
|
|
194
|
+
const start = found.findIndex((row) => compareToSeek(entity, plan, row, seek) > 0);
|
|
236
195
|
return start === -1 ? found.length : start;
|
|
237
196
|
};
|
|
238
197
|
|
|
@@ -245,9 +204,20 @@ export const memoryRepo = <Row>(
|
|
|
245
204
|
entity: EntityCore<Row>,
|
|
246
205
|
seed: readonly Row[] = [],
|
|
247
206
|
): MemoryRepo<Row> => {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
207
|
+
/**
|
|
208
|
+
* A stored row's key, spelled the way `batch-read.ts` spells an id — because Postgres compares a
|
|
209
|
+
* `uuid` as a VALUE and prints it lower-cased, so `findById(UPPER)` reads the row there while
|
|
210
|
+
* `String(...)` missed it here: `null` from a read and `X_NOT_FOUND` from a write, against a row
|
|
211
|
+
* that exists, reachable from a path parameter, a client-supplied id or a legacy import.
|
|
212
|
+
*/
|
|
213
|
+
const storeKey = (row: unknown): string =>
|
|
214
|
+
entity.$primaryKey
|
|
215
|
+
.map((property) => keyOf(kindOf(entity, property) ?? '', field(row, property)))
|
|
216
|
+
.join('');
|
|
217
|
+
/** The same key, from the id a caller named rather than from a row it has in hand. */
|
|
218
|
+
const idStoreKey = (id: unknown, operation: string): string =>
|
|
219
|
+
keyOf(kindOf(entity, singleKeyOf(entity, operation)) ?? '', id);
|
|
220
|
+
const rows = new Map<string, Row>(seed.map((row) => [storeKey(row), row]));
|
|
251
221
|
|
|
252
222
|
const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
|
|
253
223
|
const visible = (row: Row): boolean =>
|
|
@@ -256,11 +226,15 @@ export const memoryRepo = <Row>(
|
|
|
256
226
|
field(row, SOFT_DELETE_COLUMN) === null ||
|
|
257
227
|
field(row, SOFT_DELETE_COLUMN) === undefined;
|
|
258
228
|
return [...rows.values()]
|
|
259
|
-
.filter((row) => plan.where.every((predicate) =>
|
|
229
|
+
.filter((row) => plan.where.every((predicate) => matchesPredicate(entity, row, predicate)))
|
|
260
230
|
.filter(visible)
|
|
261
231
|
.sort((left, right) => {
|
|
262
232
|
for (const entry of plan.orderBy) {
|
|
263
|
-
const order =
|
|
233
|
+
const order = compareByKind(
|
|
234
|
+
kindOf(entity, entry.column),
|
|
235
|
+
valueAt(left, entry.column),
|
|
236
|
+
valueAt(right, entry.column),
|
|
237
|
+
);
|
|
264
238
|
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
265
239
|
}
|
|
266
240
|
return 0;
|
|
@@ -283,7 +257,7 @@ export const memoryRepo = <Row>(
|
|
|
283
257
|
// out of this tenant is refused by the same call that refuses an insert into another one.
|
|
284
258
|
assertRowTenant(entity.$name, entity.$tenantColumn, operation, row);
|
|
285
259
|
entity.$assert(row);
|
|
286
|
-
const key =
|
|
260
|
+
const key = storeKey(row);
|
|
287
261
|
const previous = rows.get(key);
|
|
288
262
|
options?.tx?.onRollback(() => {
|
|
289
263
|
if (previous === undefined) rows.delete(key);
|
|
@@ -297,7 +271,7 @@ export const memoryRepo = <Row>(
|
|
|
297
271
|
// to name a row, so `update`/`delete` resolve through a plan rather than through the map.
|
|
298
272
|
const addressed = (id: string, options: RepoOptions | undefined, operation: string): Row => {
|
|
299
273
|
const plan = idPlan(entity, id, options, operation);
|
|
300
|
-
const current = rows.get(id);
|
|
274
|
+
const current = rows.get(idStoreKey(id, operation));
|
|
301
275
|
// A soft-deleted row is hidden from writes too — `delete` on one is `X_NOT_FOUND`, not a
|
|
302
276
|
// second stamp, which is what the Postgres driver's `deleted_at is null` clause already says.
|
|
303
277
|
const hidden =
|
|
@@ -308,7 +282,7 @@ export const memoryRepo = <Row>(
|
|
|
308
282
|
if (
|
|
309
283
|
current === undefined ||
|
|
310
284
|
hidden ||
|
|
311
|
-
!plan.where.every((predicate) =>
|
|
285
|
+
!plan.where.every((predicate) => matchesPredicate(entity, current, predicate))
|
|
312
286
|
) {
|
|
313
287
|
throw notFound(entity.$name, id);
|
|
314
288
|
}
|
|
@@ -334,7 +308,8 @@ export const memoryRepo = <Row>(
|
|
|
334
308
|
const more = start + page.length < found.length;
|
|
335
309
|
return {
|
|
336
310
|
rows: page,
|
|
337
|
-
nextCursor:
|
|
311
|
+
nextCursor:
|
|
312
|
+
more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
|
|
338
313
|
};
|
|
339
314
|
},
|
|
340
315
|
|
|
@@ -407,14 +382,10 @@ export const memoryRepo = <Row>(
|
|
|
407
382
|
const current = addressed(id, options, 'delete');
|
|
408
383
|
// Soft delete hides the row without losing it; the column's presence is the switch.
|
|
409
384
|
if (entity.$softDelete) {
|
|
410
|
-
write(
|
|
411
|
-
Object.assign({}, current, { [SOFT_DELETE_COLUMN]: systemClock.now() }),
|
|
412
|
-
options,
|
|
413
|
-
'delete',
|
|
414
|
-
);
|
|
385
|
+
write(Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }), options, 'delete');
|
|
415
386
|
return;
|
|
416
387
|
}
|
|
417
|
-
const key =
|
|
388
|
+
const key = storeKey(current);
|
|
418
389
|
options?.tx?.onRollback(() => rows.set(key, current));
|
|
419
390
|
rows.delete(key);
|
|
420
391
|
},
|
|
@@ -428,13 +399,13 @@ export const memoryRepo = <Row>(
|
|
|
428
399
|
for (const row of doomed) {
|
|
429
400
|
if (entity.$softDelete) {
|
|
430
401
|
write(
|
|
431
|
-
Object.assign({}, row, { [SOFT_DELETE_COLUMN]:
|
|
402
|
+
Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
|
|
432
403
|
options,
|
|
433
404
|
'deleteWhere',
|
|
434
405
|
);
|
|
435
406
|
continue;
|
|
436
407
|
}
|
|
437
|
-
const key =
|
|
408
|
+
const key = storeKey(row);
|
|
438
409
|
options?.tx?.onRollback(() => rows.set(key, row));
|
|
439
410
|
rows.delete(key);
|
|
440
411
|
}
|
|
@@ -471,7 +442,7 @@ export const memoryRepo = <Row>(
|
|
|
471
442
|
|
|
472
443
|
reset() {
|
|
473
444
|
rows.clear();
|
|
474
|
-
for (const row of seed) rows.set(
|
|
445
|
+
for (const row of seed) rows.set(storeKey(row), row);
|
|
475
446
|
},
|
|
476
447
|
};
|
|
477
448
|
};
|
package/src/seed.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
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
|
|
7
|
+
import { type Environment, resolveEnvironment } from '@ultimat3/core';
|
|
8
|
+
import { entityNow } from './clock';
|
|
8
9
|
import type { Driver } from './database';
|
|
9
10
|
import { memoryDriver } from './database';
|
|
10
11
|
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
@@ -325,7 +326,7 @@ export const defineSeed = (
|
|
|
325
326
|
},
|
|
326
327
|
|
|
327
328
|
id: seedId,
|
|
328
|
-
now:
|
|
329
|
+
now: entityNow(),
|
|
329
330
|
environment: resolveEnvironment({ env: options.env }),
|
|
330
331
|
tier,
|
|
331
332
|
dryRun,
|
package/src/types.ts
CHANGED
|
@@ -15,20 +15,30 @@
|
|
|
15
15
|
* and a `bytea` blob cannot be declared at all without them, and an entity that cannot be declared
|
|
16
16
|
* is a rewrite instead of an adoption.
|
|
17
17
|
*/
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
18
|
+
export const COLUMN_KINDS = [
|
|
19
|
+
'uuid',
|
|
20
|
+
'text',
|
|
21
|
+
'char',
|
|
22
|
+
'boolean',
|
|
23
|
+
'integer',
|
|
24
|
+
'bigint',
|
|
25
|
+
'numeric',
|
|
26
|
+
'timestamptz',
|
|
27
|
+
'date',
|
|
28
|
+
'jsonb',
|
|
29
|
+
'bytea',
|
|
30
|
+
'array',
|
|
31
|
+
'money',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Derived from the array and never written twice — the same shape `PRIMITIVE_KINDS` in
|
|
36
|
+
* `@ultimat3/core`'s `registrar.ts` uses, and for the same reason: a package that has to answer
|
|
37
|
+
* "one case per kind" needs a RUNTIME list, and a list written beside the type is one that drifts
|
|
38
|
+
* from it. `@ultimat3/query`'s `shape-order.test.ts` spelled its own out and counted nine against
|
|
39
|
+
* thirteen — `9 === 9`, a test that could not fail.
|
|
40
|
+
*/
|
|
41
|
+
export type ColumnKind = (typeof COLUMN_KINDS)[number];
|
|
32
42
|
|
|
33
43
|
export type ColumnDefault =
|
|
34
44
|
| { readonly kind: 'value'; readonly value: string | number | boolean | null }
|
|
@@ -219,6 +229,38 @@ export type Insertable<C extends ColumnMap> = {
|
|
|
219
229
|
readonly [K in DefaultedKeys<C> | NullableKeys<C>]?: InputOf<TypeOf<C[K]>>;
|
|
220
230
|
};
|
|
221
231
|
|
|
232
|
+
/**
|
|
233
|
+
* A patch or a filter: every property optional, **and every property allowed to be present and
|
|
234
|
+
* `undefined`**.
|
|
235
|
+
*
|
|
236
|
+
* `Partial<Row>` cannot say the second half. Under `exactOptionalPropertyTypes` — which this repo
|
|
237
|
+
* has on — `{ k?: T }` means "absent, or a `T`", so `{ postId: undefined }` is a type error, and
|
|
238
|
+
* that is the ONE value every filtered write in this package is built to refuse:
|
|
239
|
+
*
|
|
240
|
+
* - `plan.ts`'s `namedColumns` drops `value !== undefined`, so `deleteWhere({ postId: undefined })`
|
|
241
|
+
* yields no predicate and throws `X_WRITE_UNFILTERED` rather than deleting the table. That
|
|
242
|
+
* refusal exists for exactly one caller — a variable that came back `undefined` — and
|
|
243
|
+
* `Partial<Row>` made the caller unable to spell it and the test unable to prove it.
|
|
244
|
+
* - `updateWhere(filter, { createdAt: undefined })` is `X_PATCH_EMPTY`, one argument over.
|
|
245
|
+
* - `bulk-write.ts`'s `owns()` is `Object.hasOwn`, so for a batch a present-`undefined` property
|
|
246
|
+
* IS a named column — the opposite reading, deliberately, and equally unspellable.
|
|
247
|
+
*
|
|
248
|
+
* A type that forbids the value its own runtime is written to catch is a type that documents
|
|
249
|
+
* nothing: `bulk-write.test.ts` carried `as Partial<Item>` with the comment
|
|
250
|
+
* "`exactOptionalPropertyTypes` is why it takes a cast to say", which is the defect written down.
|
|
251
|
+
*
|
|
252
|
+
* Money goes through `InputOf`, for the reason `Insertable` does: a patch is a WRITE, and money's
|
|
253
|
+
* write shape is the wider one. `conflictKeyOf`'s own `cellKey` renders a `bigint` minor unit
|
|
254
|
+
* (`${part}n`), so refusing one here would have been a type refusing a value one function down
|
|
255
|
+
* spells out how to serialise. The row's own type stays in the union rather than being replaced by
|
|
256
|
+
* `InputOf<Row[K]>`: a conditional type over an unresolved `Row` never reduces, so `Row` itself
|
|
257
|
+
* stopped being assignable to its own patch and every internal caller reddened. Both spellings are
|
|
258
|
+
* accepted here, which is the honest statement anyway — a patch may carry either.
|
|
259
|
+
*/
|
|
260
|
+
export type RowPatch<Row> = {
|
|
261
|
+
readonly [K in keyof Row]?: Row[K] | InputOf<Row[K]> | undefined;
|
|
262
|
+
};
|
|
263
|
+
|
|
222
264
|
export interface IndexDef {
|
|
223
265
|
readonly name: string;
|
|
224
266
|
readonly columns: readonly string[];
|