@ultimat3/entity 2.0.0 → 4.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 +142 -7
- package/README.md +83 -5
- package/package.json +5 -4
- package/src/bulk-write.ts +9 -7
- package/src/clock.ts +18 -0
- package/src/coalesce.ts +30 -16
- package/src/column.ts +82 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +47 -5
- package/src/count-by.ts +2 -1
- package/src/cursor.ts +23 -2
- package/src/describe.ts +58 -16
- package/src/entity.ts +27 -11
- package/src/index.ts +26 -3
- package/src/jit-preload.ts +66 -10
- package/src/memory-match.ts +169 -0
- package/src/pg-driver.ts +3 -4
- package/src/pg-row.ts +66 -20
- package/src/pg-sql.ts +58 -11
- 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 +289 -19
- package/src/types.ts +103 -11
package/src/seed.ts
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
|
-
// A seed is the fixture graph, written once and
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// A seed is the fixture graph, written once and REPLAYED anywhere: a second run writes nothing new
|
|
2
|
+
// and raises nothing. Two write verbs, because only the author knows which key identifies a row —
|
|
3
|
+
// `insert` where the seed owns the id (`id('post:tenancy')` is a v5 uuid of the label, the same on
|
|
4
|
+
// every machine), `upsert` where the table owns it and a natural key is all there is.
|
|
5
5
|
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
|
+
import { type Environment, resolveEnvironment } from '@ultimat3/core';
|
|
8
|
+
import { entityNow } from './clock';
|
|
7
9
|
import type { Driver } from './database';
|
|
8
10
|
import { memoryDriver } from './database';
|
|
9
|
-
import type
|
|
11
|
+
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
12
|
+
import { EntityError } from './errors';
|
|
13
|
+
import type { Predicate } from './tenancy';
|
|
10
14
|
import type { ColumnMap, Insertable } from './types';
|
|
11
15
|
|
|
12
16
|
/** Framework namespace for seed labels. Fixed forever: changing it moves every seeded id. */
|
|
13
17
|
const NAMESPACE = 'a3c1f0d6-5c2b-4a3e-9f1b-6d4e7c8a9b02';
|
|
14
18
|
|
|
19
|
+
/**
|
|
20
|
+
* What a replay never overwrites, unless `preserve` says otherwise. Spelled here as a constant for
|
|
21
|
+
* the reason `SOFT_DELETE_COLUMN` is: the timestamp convention is the framework's, so the column a
|
|
22
|
+
* seed must not reset is decided once.
|
|
23
|
+
*/
|
|
24
|
+
const CREATED_AT_COLUMN = 'createdAt';
|
|
25
|
+
|
|
15
26
|
const bytesOf = (uuid: string): Uint8Array =>
|
|
16
27
|
Uint8Array.from((uuid.replaceAll('-', '').match(/../g) ?? []).map((pair) => parseInt(pair, 16)));
|
|
17
28
|
|
|
@@ -35,35 +46,294 @@ export const seedId = (label: string): string => {
|
|
|
35
46
|
].join('-');
|
|
36
47
|
};
|
|
37
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Which deploys a seed belongs to, declared as DATA on the seed and never inferred from its name.
|
|
51
|
+
*
|
|
52
|
+
* `reference` is data the app is wrong without — currencies, plans, service tiers, locations — and
|
|
53
|
+
* it ships to production through this same mechanism. `dev` is fixture data, and production is the
|
|
54
|
+
* one environment it must not reach by accident. The word is the seed's; the refusal is the
|
|
55
|
+
* caller's (`x db seed`), because an app that seeds its own database from its boot code has
|
|
56
|
+
* DECIDED to (axiom 8) and a library that overruled that would break it.
|
|
57
|
+
*/
|
|
58
|
+
export const SEED_TIERS = ['reference', 'dev'] as const;
|
|
59
|
+
|
|
60
|
+
export type SeedTier = (typeof SEED_TIERS)[number];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The tiers a run takes when nothing asked for one: everything, except that production leaves
|
|
64
|
+
* `dev` out. `requested` is both the selection AND the consent, one word doing one job — a cluster
|
|
65
|
+
* that sets `ULTIMATE_ENV=production` on every box (staging included) still loads its dev seeds by
|
|
66
|
+
* naming the tier, instead of by lying about the environment.
|
|
67
|
+
*/
|
|
68
|
+
export const seedTiersFor = (
|
|
69
|
+
environment: Environment,
|
|
70
|
+
requested?: SeedTier | undefined,
|
|
71
|
+
): readonly SeedTier[] => {
|
|
72
|
+
if (requested !== undefined) return [requested];
|
|
73
|
+
return environment === 'production' ? ['reference'] : [...SEED_TIERS];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** What one `upsert` did. `skipped` is a row already stored with these values — no statement. */
|
|
77
|
+
export type SeedWrite = 'inserted' | 'updated' | 'skipped';
|
|
78
|
+
|
|
79
|
+
/** One run's tally, in the three words every seed report is built from. */
|
|
80
|
+
export interface SeedMetrics {
|
|
81
|
+
inserted: number;
|
|
82
|
+
updated: number;
|
|
83
|
+
skipped: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SeedKey<Row> {
|
|
87
|
+
/**
|
|
88
|
+
* The columns of the unique constraint this row is identified by — its NATURAL key, which is the
|
|
89
|
+
* only key a seed writing into an existing table can know. A target no declared constraint
|
|
90
|
+
* matches is refused by `upsertPlan` before a statement is sent (`42P10` otherwise).
|
|
91
|
+
*/
|
|
92
|
+
readonly by: readonly (keyof Row & string)[];
|
|
93
|
+
/**
|
|
94
|
+
* Columns a collision leaves alone. `createdAt` by default, and that default is the point: a
|
|
95
|
+
* replay must not reset when a row first arrived. The conflict target, the primary key and the
|
|
96
|
+
* soft-delete stamp are spared by `upsertPlan` already.
|
|
97
|
+
*/
|
|
98
|
+
readonly preserve?: readonly (keyof Row & string)[];
|
|
99
|
+
}
|
|
100
|
+
|
|
38
101
|
export interface SeedContext {
|
|
102
|
+
/**
|
|
103
|
+
* Rows whose ids the SEED chose, written in one statement and replayable by primary key: a row
|
|
104
|
+
* already stored is left exactly as it is (`on conflict … do nothing`). The bulk verb — one
|
|
105
|
+
* statement per call, not one per row.
|
|
106
|
+
*/
|
|
39
107
|
insert<Row, C extends ColumnMap>(
|
|
40
108
|
entity: EntityCore<Row, C>,
|
|
41
109
|
rows: readonly Insertable<C>[],
|
|
42
110
|
): Promise<void>;
|
|
111
|
+
/**
|
|
112
|
+
* One row whose id the TABLE owns, matched on the natural key `by` names. Reads first so the
|
|
113
|
+
* answer can be `'skipped'`, then writes with a single `on conflict … do update`, which is what
|
|
114
|
+
* settles the race between two containers booting at once — the read is for the report, never
|
|
115
|
+
* for the decision.
|
|
116
|
+
*/
|
|
117
|
+
upsert<Row, C extends ColumnMap>(
|
|
118
|
+
entity: EntityCore<Row, C>,
|
|
119
|
+
key: SeedKey<Row>,
|
|
120
|
+
values: Insertable<C>,
|
|
121
|
+
): Promise<SeedWrite>;
|
|
122
|
+
/**
|
|
123
|
+
* The other unit of idempotency: the FILE. Bulk volume data has no natural key worth upserting
|
|
124
|
+
* ten thousand rows against, so the guard is a sentinel — `if (await exists(reports)) return;`
|
|
125
|
+
* at the top of the seed.
|
|
126
|
+
*/
|
|
127
|
+
exists<Row, C extends ColumnMap>(
|
|
128
|
+
entity: EntityCore<Row, C>,
|
|
129
|
+
where?: Partial<Row>,
|
|
130
|
+
): Promise<boolean>;
|
|
131
|
+
count<Row, C extends ColumnMap>(
|
|
132
|
+
entity: EntityCore<Row, C>,
|
|
133
|
+
where?: Partial<Row>,
|
|
134
|
+
): Promise<number>;
|
|
135
|
+
/** Scoped wipe before a regenerate. Refused on a soft-deleting entity — see `softDeleteWipe`. */
|
|
136
|
+
deleteWhere<Row, C extends ColumnMap>(
|
|
137
|
+
entity: EntityCore<Row, C>,
|
|
138
|
+
where: Partial<Row>,
|
|
139
|
+
): Promise<number>;
|
|
43
140
|
/** Deterministic id for a label. Same label, same uuid, every run. */
|
|
44
141
|
id(label: string): string;
|
|
142
|
+
/** One instant for the whole run, so every row a bulk pass stamps carries the same timestamp. */
|
|
143
|
+
readonly now: Date;
|
|
144
|
+
readonly environment: Environment;
|
|
145
|
+
readonly tier: SeedTier;
|
|
146
|
+
/** Reads still run; every write short-circuits and is counted as what it WOULD have written. */
|
|
147
|
+
readonly dryRun: boolean;
|
|
148
|
+
readonly metrics: SeedMetrics;
|
|
45
149
|
}
|
|
46
150
|
|
|
47
151
|
export interface SeedOptions {
|
|
48
152
|
/** Defaults to a fresh in-memory driver, so a seed runs with no database at all. */
|
|
49
153
|
readonly driver?: Driver;
|
|
154
|
+
readonly dryRun?: boolean;
|
|
155
|
+
/** Injected for a test; `process.env` otherwise. Read once, by `resolveEnvironment`. */
|
|
156
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface SeedRun {
|
|
160
|
+
readonly name: string;
|
|
161
|
+
readonly tier: SeedTier;
|
|
162
|
+
readonly metrics: SeedMetrics;
|
|
50
163
|
}
|
|
51
164
|
|
|
52
165
|
export interface Seed {
|
|
53
166
|
readonly name: string;
|
|
54
|
-
|
|
167
|
+
readonly tier: SeedTier;
|
|
168
|
+
run(options?: SeedOptions): Promise<SeedRun>;
|
|
55
169
|
}
|
|
56
170
|
|
|
57
|
-
export
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
171
|
+
export interface SeedInit {
|
|
172
|
+
/** Defaults to `dev`: fixture data is what a seed is until its author says otherwise. */
|
|
173
|
+
readonly tier?: SeedTier;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** What `x db seed` picks out of a module. Same shape rule as `isRouteConfig`. */
|
|
177
|
+
export const isSeed = (value: unknown): value is Seed =>
|
|
178
|
+
typeof value === 'object' &&
|
|
179
|
+
value !== null &&
|
|
180
|
+
typeof (value as { name?: unknown }).name === 'string' &&
|
|
181
|
+
typeof (value as { run?: unknown }).run === 'function' &&
|
|
182
|
+
(SEED_TIERS as readonly unknown[]).includes((value as { tier?: unknown }).tier);
|
|
183
|
+
|
|
184
|
+
/** Property access on a parsed row without `any`: `$parse` fills every declared column. */
|
|
185
|
+
const cellOf = (row: unknown, property: string): unknown =>
|
|
186
|
+
(row as Readonly<Record<string, unknown>>)[property];
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Two cells, as a replay has to compare them: a `Date` is not `===` a `Date` and money is an
|
|
190
|
+
* object, so both are compared by value and everything else by identity.
|
|
191
|
+
*/
|
|
192
|
+
const sameCell = (left: unknown, right: unknown): boolean => {
|
|
193
|
+
if (left instanceof Date || right instanceof Date) {
|
|
194
|
+
return left instanceof Date && right instanceof Date && left.getTime() === right.getTime();
|
|
195
|
+
}
|
|
196
|
+
if (typeof left === 'object' && left !== null && typeof right === 'object' && right !== null) {
|
|
197
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
198
|
+
}
|
|
199
|
+
return left === right;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const equalityPredicates = <Row>(where: Partial<Row>): readonly Predicate[] =>
|
|
203
|
+
Object.entries(where).map(([column, value]): Predicate => ({ column, op: 'eq', value }));
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The entity's own key as a conflict target. `$primaryKey` is `readonly string[]` because an
|
|
207
|
+
* entity does not know its row type at that field, and `onConflict` is typed by the row.
|
|
208
|
+
*/
|
|
209
|
+
const primaryKeyTarget = <Row>(entity: EntityCore<Row>): readonly (keyof Row & string)[] =>
|
|
210
|
+
entity.$primaryKey as readonly (keyof Row & string)[];
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A primary key the row leaves to a GENERATED default is a different id on every run, so the
|
|
214
|
+
* conflict target finds nothing and each replay inserts one more copy. `$parse` refuses a key with
|
|
215
|
+
* no value at all; this is the half it cannot see, because filling that column is what it does.
|
|
216
|
+
*/
|
|
217
|
+
const generatedKey = (entity: EntityCore, missing: string, position: number): EntityError =>
|
|
218
|
+
new EntityError({
|
|
219
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
220
|
+
cause: `${entity.$name} seed row ${position + 1} leaves "${missing}" to a generated default, and a primary key generated fresh on every run is a row every replay inserts a second copy of`,
|
|
221
|
+
fix: `insert(${entity.$name}, rows.map((row, index) => ({ ...row, ${missing}: id(\`${entity.$name}:\${index}\`) }))) # id() is a uuid v5 of the label: same row, same id, every run`,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Deleting from a soft-deleting entity inside a seed, refused rather than documented. The stamp is
|
|
226
|
+
* what makes it unrecoverable: `upsertPlan` spares the soft-delete column on purpose and the stored
|
|
227
|
+
* row still occupies its unique key, so the replay that was supposed to bring the rows back writes
|
|
228
|
+
* nothing at all and the fixture is gone until the database is.
|
|
229
|
+
*/
|
|
230
|
+
const softDeleteWipe = (entity: EntityCore): EntityError =>
|
|
231
|
+
new EntityError({
|
|
232
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
233
|
+
cause: `${entity.$name} declares ${SOFT_DELETE_COLUMN}, so deleteWhere() would stamp its seeded rows rather than remove them — the stamped row keeps its unique key, and no replay of this seed can clear it`,
|
|
234
|
+
fix: 'x db reset --json # the only wipe a soft-deleting entity has; drop the deleteWhere() call from the seed',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
/** The row an update may write: everything the caller named, less what a match must not move. */
|
|
238
|
+
const withoutPreserved = <Row>(row: Row, preserve: readonly string[]): Row => {
|
|
239
|
+
const copy: Record<string, unknown> = { ...(row as Record<string, unknown>) };
|
|
240
|
+
for (const property of preserve) delete copy[property];
|
|
241
|
+
// `Repo` is typed for whole rows, and a partial one is exactly what keeps a column OUT of the
|
|
242
|
+
// update set — `namedProperties` reads what the row owns. Same assertion the bulk live test takes.
|
|
243
|
+
return copy as Row;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export const defineSeed = (
|
|
247
|
+
name: string,
|
|
248
|
+
build: (context: SeedContext) => Promise<void>,
|
|
249
|
+
init: SeedInit = {},
|
|
250
|
+
): Seed => {
|
|
251
|
+
const tier = init.tier ?? 'dev';
|
|
252
|
+
return {
|
|
253
|
+
name,
|
|
254
|
+
tier,
|
|
255
|
+
run: async (options = {}) => {
|
|
256
|
+
const driver = options.driver ?? memoryDriver();
|
|
257
|
+
const dryRun = options.dryRun ?? false;
|
|
258
|
+
const metrics: SeedMetrics = { inserted: 0, updated: 0, skipped: 0 };
|
|
259
|
+
const context: SeedContext = {
|
|
260
|
+
insert: async (entity, rows) => {
|
|
261
|
+
// Judged on the row as WRITTEN, before `$parse` fills the column that would hide it.
|
|
262
|
+
for (const [position, row] of rows.entries()) {
|
|
263
|
+
const missing = entity.$primaryKey.find(
|
|
264
|
+
(property) =>
|
|
265
|
+
entity.$columns[property]?.$meta.default?.kind === 'generated' &&
|
|
266
|
+
!Object.hasOwn(row, property),
|
|
267
|
+
);
|
|
268
|
+
if (missing !== undefined) throw generatedKey(entity, missing, position);
|
|
269
|
+
}
|
|
270
|
+
const parsed = rows.map((row) => entity.$parse(row));
|
|
271
|
+
if (dryRun) {
|
|
272
|
+
metrics.inserted += parsed.length;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const written = await driver.repo(entity).upsertAll(parsed, {
|
|
276
|
+
onConflict: primaryKeyTarget(entity),
|
|
277
|
+
// Never `'update'`: a do-nothing conflict needs no tenant column in the target, so this
|
|
278
|
+
// is the one form that replays on a tenant-scoped entity whose unique keys are global.
|
|
279
|
+
onMatch: 'nothing',
|
|
280
|
+
});
|
|
281
|
+
metrics.inserted += written.length;
|
|
282
|
+
metrics.skipped += parsed.length - written.length;
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
upsert: async (entity, key, values) => {
|
|
286
|
+
const row = entity.$parse(values);
|
|
287
|
+
const repo = driver.repo(entity);
|
|
288
|
+
const where = Object.fromEntries(
|
|
289
|
+
key.by.map((property) => [property, cellOf(row, property)]),
|
|
290
|
+
);
|
|
291
|
+
const found = await repo.findMany({ where: equalityPredicates(where), limit: 1 });
|
|
292
|
+
const stored = found.rows[0];
|
|
293
|
+
const preserve: readonly string[] = key.preserve ?? [CREATED_AT_COLUMN];
|
|
294
|
+
const compared = Object.keys(row as Record<string, unknown>).filter(
|
|
295
|
+
(property) => !preserve.includes(property),
|
|
296
|
+
);
|
|
297
|
+
if (
|
|
298
|
+
stored !== undefined &&
|
|
299
|
+
compared.every((property) => sameCell(cellOf(stored, property), cellOf(row, property)))
|
|
300
|
+
) {
|
|
301
|
+
metrics.skipped += 1;
|
|
302
|
+
return 'skipped';
|
|
303
|
+
}
|
|
304
|
+
const write: SeedWrite = stored === undefined ? 'inserted' : 'updated';
|
|
305
|
+
if (!dryRun) {
|
|
306
|
+
await repo.upsertAll([stored === undefined ? row : withoutPreserved(row, preserve)], {
|
|
307
|
+
onConflict: key.by,
|
|
308
|
+
onMatch: 'update',
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
metrics[write === 'inserted' ? 'inserted' : 'updated'] += 1;
|
|
312
|
+
return write;
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
count: async (entity, where) =>
|
|
316
|
+
driver
|
|
317
|
+
.repo(entity)
|
|
318
|
+
.count(where === undefined ? {} : { where: equalityPredicates(where) }),
|
|
319
|
+
|
|
320
|
+
exists: async (entity, where) => (await context.count(entity, where)) > 0,
|
|
321
|
+
|
|
322
|
+
deleteWhere: async (entity, where) => {
|
|
323
|
+
if (entity.$softDelete) throw softDeleteWipe(entity);
|
|
324
|
+
if (dryRun) return context.count(entity, where);
|
|
325
|
+
return driver.repo(entity).deleteWhere(where);
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
id: seedId,
|
|
329
|
+
now: entityNow(),
|
|
330
|
+
environment: resolveEnvironment({ env: options.env }),
|
|
331
|
+
tier,
|
|
332
|
+
dryRun,
|
|
333
|
+
metrics,
|
|
334
|
+
};
|
|
335
|
+
await build(context);
|
|
336
|
+
return { name, tier, metrics };
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
};
|
package/src/types.ts
CHANGED
|
@@ -6,17 +6,39 @@
|
|
|
6
6
|
// A column carries its TypeScript type in `$parse`, which is what lets the row type be derived
|
|
7
7
|
// from the column set instead of being written a second time as a hand-maintained schema.
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Postgres types the builders emit. `money` expands to `bigint` + `char(3)` (+ a nullable
|
|
11
|
+
* `integer` scale); `array` expands to its element's type with `[]` after it.
|
|
12
|
+
*
|
|
13
|
+
* The four beyond the blessed set exist for ONE reason: a schema Ultimate did not generate already
|
|
14
|
+
* has them. A table with a `numeric(18,8)` rate, a `date` a rate takes effect on, a `jsonb` payload
|
|
15
|
+
* and a `bytea` blob cannot be declared at all without them, and an entity that cannot be declared
|
|
16
|
+
* is a rewrite instead of an adoption.
|
|
17
|
+
*/
|
|
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];
|
|
20
42
|
|
|
21
43
|
export type ColumnDefault =
|
|
22
44
|
| { readonly kind: 'value'; readonly value: string | number | boolean | null }
|
|
@@ -48,6 +70,22 @@ export type { MoneyValue };
|
|
|
48
70
|
* needs no conversion at the call site. A float throws, and so does a `bigint` past
|
|
49
71
|
* `Number.MAX_SAFE_INTEGER` — see `parseMinor` in `columns.ts`.
|
|
50
72
|
*/
|
|
73
|
+
/**
|
|
74
|
+
* The physical columns behind a money property, when they are not `<name>_minor`,
|
|
75
|
+
* `<name>_currency` and `<name>_scale`. Named per part and merged over those defaults, so a table
|
|
76
|
+
* that renamed one column does not have to restate the other two.
|
|
77
|
+
*/
|
|
78
|
+
export interface MoneyColumnNames {
|
|
79
|
+
readonly minor?: string;
|
|
80
|
+
readonly currency?: string;
|
|
81
|
+
/**
|
|
82
|
+
* `null` says this table has NO scale column — the ordinary shape of a money column written
|
|
83
|
+
* before scale existed. Every amount in it is then at the currency's own minor unit, which is
|
|
84
|
+
* what an absent scale already means, so nothing is lost but the ability to store a sub-cent one.
|
|
85
|
+
*/
|
|
86
|
+
readonly scale?: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
51
89
|
export interface MoneyInput {
|
|
52
90
|
readonly minor: bigint | number;
|
|
53
91
|
readonly currency: string;
|
|
@@ -71,7 +109,20 @@ export interface ColumnMeta {
|
|
|
71
109
|
readonly index: boolean;
|
|
72
110
|
/** Presence of a tenant column is what turns tenancy on. See `tenancy.ts`. */
|
|
73
111
|
readonly tenant: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* The physical column, when it is not `snake(property)`. The whole of what makes an existing
|
|
114
|
+
* table addressable: `githubLogin` on a row, `gh_login` in every statement, decided once here
|
|
115
|
+
* and read through `columnName()` by every projection.
|
|
116
|
+
*/
|
|
117
|
+
readonly name?: string;
|
|
74
118
|
readonly length?: number;
|
|
119
|
+
/** `numeric(precision, scale)` — both, or neither. A bare `numeric` is unbounded on purpose. */
|
|
120
|
+
readonly precision?: number;
|
|
121
|
+
readonly numericScale?: number;
|
|
122
|
+
/** The element column of an `array`, which carries the element's own kind and `$parse`. */
|
|
123
|
+
readonly element?: AnyColumn;
|
|
124
|
+
/** Where money's three physical columns live, when the table already named them. */
|
|
125
|
+
readonly parts?: MoneyColumnNames;
|
|
75
126
|
readonly values?: readonly string[];
|
|
76
127
|
readonly default?: ColumnDefault;
|
|
77
128
|
readonly onUpdate?: ColumnDefault;
|
|
@@ -99,6 +150,13 @@ export interface Column<T, Optional extends boolean = false> {
|
|
|
99
150
|
tenant(): Column<T, Optional>;
|
|
100
151
|
references(target: () => AnyColumn, options?: ReferenceOptions): Column<T, Optional>;
|
|
101
152
|
default(value: T): Column<T, true>;
|
|
153
|
+
/**
|
|
154
|
+
* The physical column name, when the table does not spell it `snake(property)`. Name it LAST in
|
|
155
|
+
* a chain: this link returns the general column, so a builder's own methods (`defaultNow()`,
|
|
156
|
+
* a uuid key's narrowed `primaryKey()`) are declared before it — `uuid()` and `timestamp()`
|
|
157
|
+
* override it to keep theirs, and nothing else has any.
|
|
158
|
+
*/
|
|
159
|
+
column(name: string): Column<T, Optional>;
|
|
102
160
|
}
|
|
103
161
|
|
|
104
162
|
/**
|
|
@@ -111,11 +169,13 @@ export interface Column<T, Optional extends boolean = false> {
|
|
|
111
169
|
export interface UuidColumn<T extends string = string, Optional extends boolean = false>
|
|
112
170
|
extends Column<T, Optional> {
|
|
113
171
|
primaryKey(): Column<T, true>;
|
|
172
|
+
column(name: string): UuidColumn<T, Optional>;
|
|
114
173
|
}
|
|
115
174
|
|
|
116
175
|
export interface TimestampColumn<Optional extends boolean = false> extends Column<Date, Optional> {
|
|
117
176
|
defaultNow(): TimestampColumn<true>;
|
|
118
177
|
onUpdateNow(): TimestampColumn<Optional>;
|
|
178
|
+
column(name: string): TimestampColumn<Optional>;
|
|
119
179
|
}
|
|
120
180
|
|
|
121
181
|
export type AnyColumn = Column<unknown, boolean>;
|
|
@@ -169,6 +229,38 @@ export type Insertable<C extends ColumnMap> = {
|
|
|
169
229
|
readonly [K in DefaultedKeys<C> | NullableKeys<C>]?: InputOf<TypeOf<C[K]>>;
|
|
170
230
|
};
|
|
171
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
|
+
|
|
172
264
|
export interface IndexDef {
|
|
173
265
|
readonly name: string;
|
|
174
266
|
readonly columns: readonly string[];
|