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