@ultimat3/entity 0.0.1 → 1.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/src/pg-row.ts ADDED
@@ -0,0 +1,110 @@
1
+ // Single responsibility: the two-way map between a physical Postgres row and an entity row.
2
+ // Three things are not one-to-one and all three live here: the property key is camelCase while
3
+ // the column is snake_case, money is one property over two columns, and a value that came back
4
+ // from the driver is re-parsed by the column that declared it rather than trusted — int8 arrives
5
+ // as a string, timestamptz may arrive as one, and a silent `NaN` is worse than a loud throw.
6
+
7
+ import { snake } from './column';
8
+ import type { EntityCore } from './entity';
9
+ import { invariantViolated } from './errors';
10
+ import type { AnyColumn, MoneyValue } from './types';
11
+
12
+ export type PhysicalRow = Readonly<Record<string, unknown>>;
13
+
14
+ const MONEY_PARTS = new Set(['minor', 'currency']);
15
+
16
+ /** `price` -> `price_minor`, `price_currency`. Everything else is one snake_case column. */
17
+ export const columnsOf = (property: string, column: AnyColumn): readonly string[] =>
18
+ column.$meta.kind === 'money'
19
+ ? [`${snake(property)}_minor`, `${snake(property)}_currency`]
20
+ : [snake(property)];
21
+
22
+ /**
23
+ * A predicate or sort key names a property, never a physical column — so `orgId` becomes
24
+ * `org_id` in exactly one place, and a name the entity never declared cannot reach the SQL.
25
+ */
26
+ export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string => {
27
+ const [property = path, part] = path.split('.');
28
+ const column = entity.$columns[property];
29
+ if (column === undefined) {
30
+ throw invariantViolated(
31
+ entity.$name,
32
+ 'column',
33
+ `no column "${path}" — pick from: ${Object.keys(entity.$columns).join(', ')}`,
34
+ );
35
+ }
36
+ const isMoney = column.$meta.kind === 'money';
37
+ if (part === undefined) {
38
+ if (!isMoney) return snake(property);
39
+ throw invariantViolated(
40
+ entity.$name,
41
+ property,
42
+ `${property} is money: name ${property}.minor or ${property}.currency`,
43
+ );
44
+ }
45
+ if (!isMoney || !MONEY_PARTS.has(part)) {
46
+ throw invariantViolated(entity.$name, property, `${property} has no part "${part}"`);
47
+ }
48
+ return `${snake(property)}_${part}`;
49
+ };
50
+
51
+ /** Every physical column of the entity, in declaration order. */
52
+ export const allColumns = <Row>(entity: EntityCore<Row>): readonly string[] =>
53
+ Object.entries(entity.$columns).flatMap(([property, column]) => columnsOf(property, column));
54
+
55
+ /**
56
+ * Row (or patch) -> the columns to write. Absent properties are skipped rather than nulled,
57
+ * which is what makes the same function serve `insert` and a partial `update`.
58
+ */
59
+ export const bindValues = <Row>(
60
+ entity: EntityCore<Row>,
61
+ values: Partial<Row>,
62
+ ): ReadonlyMap<string, unknown> => {
63
+ const bound = new Map<string, unknown>();
64
+ const record = values as Readonly<Record<string, unknown>>;
65
+ for (const [property, column] of Object.entries(entity.$columns)) {
66
+ if (!Object.hasOwn(record, property)) continue;
67
+ const value = record[property];
68
+ if (column.$meta.kind !== 'money') {
69
+ bound.set(snake(property), value ?? null);
70
+ continue;
71
+ }
72
+ const money = value as MoneyValue | null | undefined;
73
+ bound.set(`${snake(property)}_minor`, money?.minor ?? null);
74
+ bound.set(`${snake(property)}_currency`, money?.currency ?? null);
75
+ }
76
+ return bound;
77
+ };
78
+
79
+ const moneyOf = (source: PhysicalRow, minor: string, currency: string): unknown => {
80
+ const amount = source[minor];
81
+ if (amount === null || amount === undefined) return null;
82
+ return { minor: amount, currency: String(source[currency] ?? '').trim() };
83
+ };
84
+
85
+ /**
86
+ * Physical row -> entity row. A column the projection left out is left out here too, so a
87
+ * `select` narrows the object as well as the statement.
88
+ */
89
+ export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Row => {
90
+ const row: Record<string, unknown> = {};
91
+ for (const [property, column] of Object.entries(entity.$columns)) {
92
+ const [head, tail] = columnsOf(property, column);
93
+ if (head === undefined || !(head in source)) continue;
94
+ const value = tail === undefined ? source[head] : moneyOf(source, head, tail);
95
+ if (value !== null && value !== undefined) {
96
+ row[property] = column.$parse(value);
97
+ continue;
98
+ }
99
+ if (column.$meta.notNull) {
100
+ throw invariantViolated(
101
+ entity.$name,
102
+ property,
103
+ 'the database returned null for a not-null column — the table no longer matches the entity',
104
+ );
105
+ }
106
+ row[property] = null;
107
+ }
108
+ // Every property present was validated by the column that declared it, so this is the row.
109
+ return row as Row;
110
+ };
package/src/pg-sql.ts ADDED
@@ -0,0 +1,162 @@
1
+ // Single responsibility: compile a `QueryPlan` into parameterised SQL. Nothing here builds a
2
+ // string from a value — `sql` binds every scalar to `$n` and refuses anything else — and every
3
+ // identifier is resolved through the entity, so a column name can only ever be one the entity
4
+ // declared. That is the whole reason this file exists instead of a template literal per method.
5
+
6
+ import { identifier, join, raw, type SqlFragment, sql } from '@ultimat3/db';
7
+ import { snake } from './column';
8
+ import type { EntityCore } from './entity';
9
+ import { SOFT_DELETE_COLUMN } from './entity';
10
+ import { allColumns, columnsOf, physicalName } from './pg-row';
11
+ import type { Predicate, QueryPlan, SortKey } from './tenancy';
12
+
13
+ /** Nothing matches. `in ()` is a syntax error in Postgres, so an empty set needs a constant. */
14
+ const NEVER = sql`1 = 0`;
15
+
16
+ export interface ReadShape {
17
+ /** Soft-deleted rows are hidden unless the caller asked for them. */
18
+ readonly includeDeleted: boolean;
19
+ /** The keyset position, already revived to typed values. */
20
+ readonly seek?: readonly unknown[] | undefined;
21
+ }
22
+
23
+ const columnRef = <Row>(entity: EntityCore<Row>, path: string): SqlFragment =>
24
+ identifier(physicalName(entity, path));
25
+
26
+ const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragment => {
27
+ const column = columnRef(entity, predicate.column);
28
+ const value = predicate.value;
29
+ switch (predicate.op) {
30
+ case 'eq':
31
+ return value === null ? sql`${column} is null` : sql`${column} = ${value}`;
32
+ case 'neq':
33
+ // `is distinct from` so a null on either side compares as a value, not as unknown.
34
+ return sql`${column} is distinct from ${value}`;
35
+ case 'in': {
36
+ const values = Array.isArray(value) ? value : [value];
37
+ return values.length === 0
38
+ ? NEVER
39
+ : sql`${column} in (${join(values.map((each) => sql`${each}`))})`;
40
+ }
41
+ case 'gt':
42
+ return sql`${column} > ${value}`;
43
+ case 'gte':
44
+ return sql`${column} >= ${value}`;
45
+ case 'lt':
46
+ return sql`${column} < ${value}`;
47
+ case 'lte':
48
+ return sql`${column} <= ${value}`;
49
+ case 'like':
50
+ return sql`${column} like ${value}`;
51
+ case 'is-null':
52
+ return sql`${column} is null`;
53
+ case 'is-not-null':
54
+ return sql`${column} is not null`;
55
+ }
56
+ };
57
+
58
+ /**
59
+ * The keyset seek, spelled out rather than as a row comparison: `(a, b) > (x, y)` requires every
60
+ * key to sort the same way, and a listing that is `published_at desc, id asc` does not.
61
+ */
62
+ const seekSql = <Row>(
63
+ entity: EntityCore<Row>,
64
+ orderBy: readonly SortKey[],
65
+ seek: readonly unknown[],
66
+ ): SqlFragment => {
67
+ const terms = orderBy.map((entry, index) => {
68
+ const equal = orderBy
69
+ .slice(0, index)
70
+ .map((earlier, position) => sql`${columnRef(entity, earlier.column)} = ${seek[position]}`);
71
+ const after = raw(entry.direction === 'desc' ? '<' : '>');
72
+ return sql`(${join(
73
+ [...equal, sql`${columnRef(entity, entry.column)} ${after} ${seek[index]}`],
74
+ ' and ',
75
+ )})`;
76
+ });
77
+ return sql`(${join(terms, ' or ')})`;
78
+ };
79
+
80
+ const conditions = <Row>(
81
+ entity: EntityCore<Row>,
82
+ plan: QueryPlan,
83
+ shape: ReadShape,
84
+ ): SqlFragment => {
85
+ const parts = plan.where.map((predicate) => predicateSql(entity, predicate));
86
+ if (entity.$softDelete && !shape.includeDeleted) {
87
+ parts.push(sql`${identifier(snake(SOFT_DELETE_COLUMN))} is null`);
88
+ }
89
+ if (shape.seek !== undefined) parts.push(seekSql(entity, plan.orderBy, shape.seek));
90
+ return parts.length === 0 ? sql`true` : join(parts, ' and ');
91
+ };
92
+
93
+ const orderSql = <Row>(entity: EntityCore<Row>, orderBy: readonly SortKey[]): SqlFragment =>
94
+ join(
95
+ orderBy.map(
96
+ (entry) =>
97
+ sql`${columnRef(entity, entry.column)} ${raw(entry.direction === 'desc' ? 'desc' : 'asc')}`,
98
+ ),
99
+ );
100
+
101
+ /**
102
+ * A projection always carries the primary key and the sort keys even when the caller did not
103
+ * ask for them: without those values the page cannot produce the cursor that continues it.
104
+ */
105
+ const projection = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment => {
106
+ if (plan.select === undefined) return join(allColumns(entity).map(identifier));
107
+ const wanted = new Set([
108
+ ...plan.select,
109
+ ...entity.$primaryKey,
110
+ ...plan.orderBy.map((entry) => entry.column.split('.')[0] ?? entry.column),
111
+ ]);
112
+ const names = [...wanted].flatMap((property) => {
113
+ const column = entity.$columns[property];
114
+ return column === undefined ? [] : columnsOf(property, column);
115
+ });
116
+ return join(names.map(identifier));
117
+ };
118
+
119
+ export const selectStatement = <Row>(
120
+ entity: EntityCore<Row>,
121
+ plan: QueryPlan,
122
+ shape: ReadShape,
123
+ limit: number,
124
+ ): SqlFragment =>
125
+ sql`select ${projection(entity, plan)} from ${identifier(entity.$table)} where ${conditions(
126
+ entity,
127
+ plan,
128
+ shape,
129
+ )} order by ${orderSql(entity, plan.orderBy)} limit ${limit}`;
130
+
131
+ export const countStatement = <Row>(
132
+ entity: EntityCore<Row>,
133
+ plan: QueryPlan,
134
+ shape: ReadShape,
135
+ ): SqlFragment =>
136
+ sql`select count(*) as count from ${identifier(entity.$table)} where ${conditions(entity, plan, shape)}`;
137
+
138
+ export const insertStatement = <Row>(
139
+ entity: EntityCore<Row>,
140
+ values: ReadonlyMap<string, unknown>,
141
+ ): SqlFragment => {
142
+ const entries = [...values];
143
+ return sql`insert into ${identifier(entity.$table)} (${join(
144
+ entries.map(([column]) => identifier(column)),
145
+ )}) values (${join(entries.map(([, value]) => sql`${value}`))}) returning *`;
146
+ };
147
+
148
+ export const updateStatement = <Row>(
149
+ entity: EntityCore<Row>,
150
+ plan: QueryPlan,
151
+ values: ReadonlyMap<string, unknown>,
152
+ shape: ReadShape,
153
+ ): SqlFragment =>
154
+ sql`update ${identifier(entity.$table)} set ${join(
155
+ [...values].map(([column, value]) => sql`${identifier(column)} = ${value}`),
156
+ )} where ${conditions(entity, plan, shape)} returning *`;
157
+
158
+ /** Only reached when the entity has no soft-delete column, so there is no filter to apply. */
159
+ export const deleteStatement = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment =>
160
+ sql`delete from ${identifier(entity.$table)} where ${conditions(entity, plan, {
161
+ includeDeleted: true,
162
+ })}`;
package/src/plan.ts ADDED
@@ -0,0 +1,82 @@
1
+ // Single responsibility: turn repository arguments into the `QueryPlan` a driver executes.
2
+ // It sits outside both drivers because memory and Postgres must agree on what a call means —
3
+ // which rows are in scope, what the total sort order is, how big a page is. A guard only one
4
+ // driver applies is worse than none: the test passes and production leaks another tenant's rows.
5
+
6
+ import type { EntityCore } from './entity';
7
+ import { invariantViolated } from './errors';
8
+ import type { FindManyArgs, RepoOptions } from './repo';
9
+ import type { Predicate, QueryPlan } from './tenancy';
10
+ import { assertScoped } from './tenancy';
11
+
12
+ /** A page is bounded by default; an unbounded read is a production incident waiting for traffic. */
13
+ export const DEFAULT_PAGE_SIZE = 50;
14
+
15
+ /** Id-addressed operations need exactly one key. A composite key is a `findMany({ where })`. */
16
+ export const singleKeyOf = <Row>(entity: EntityCore<Row>, operation: string): string => {
17
+ const [only] = entity.$primaryKey;
18
+ if (entity.$primaryKey.length !== 1 || only === undefined) {
19
+ throw invariantViolated(
20
+ entity.$name,
21
+ operation,
22
+ `${entity.$name} has a composite primary key (${entity.$primaryKey.join(', ')}) — ` +
23
+ 'use findMany({ where }) instead of an id',
24
+ );
25
+ }
26
+ return only;
27
+ };
28
+
29
+ export const planFor = <Row>(entity: EntityCore<Row>, args: FindManyArgs): QueryPlan => {
30
+ const scoped =
31
+ args.orgId === undefined || entity.$tenantColumn === null
32
+ ? []
33
+ : [{ column: entity.$tenantColumn, op: 'eq', value: args.orgId } satisfies Predicate];
34
+ const ordered = args.orderBy ?? [];
35
+ return {
36
+ entity: entity.$name,
37
+ where: [...(args.where ?? []), ...scoped],
38
+ // The primary key is always the final sort key: a cursor needs a total order, or two
39
+ // rows with the same sort value straddle a page boundary.
40
+ orderBy: [
41
+ ...ordered,
42
+ ...entity.$primaryKey
43
+ .filter((property) => !ordered.some((entry) => entry.column === property))
44
+ .map((property) => ({ column: property, direction: 'asc' as const })),
45
+ ],
46
+ limit: args.limit ?? DEFAULT_PAGE_SIZE,
47
+ ...(args.cursor === undefined || args.cursor === null ? {} : { cursor: args.cursor }),
48
+ ...(args.select === undefined ? {} : { select: args.select }),
49
+ };
50
+ };
51
+
52
+ /** The plan for a read. Throws `X_TENANCY_UNSCOPED` before a single row is considered. */
53
+ export const readPlan = <Row>(
54
+ entity: EntityCore<Row>,
55
+ args: FindManyArgs,
56
+ operation: string,
57
+ ): QueryPlan => {
58
+ const plan = planFor(entity, args);
59
+ assertScoped(entity.$name, entity.$tenantColumn, operation, plan);
60
+ return plan;
61
+ };
62
+
63
+ /**
64
+ * The plan for an id-addressed write. A write is a query too: without the same guard,
65
+ * `update(id, patch)` on a tenant-scoped entity is a cross-tenant write that no read path
66
+ * would ever have allowed.
67
+ */
68
+ export const idPlan = <Row>(
69
+ entity: EntityCore<Row>,
70
+ id: string,
71
+ options: RepoOptions | undefined,
72
+ operation: string,
73
+ ): QueryPlan =>
74
+ readPlan(
75
+ entity,
76
+ {
77
+ ...options,
78
+ where: [{ column: singleKeyOf(entity, operation), op: 'eq', value: id }],
79
+ limit: 1,
80
+ },
81
+ operation,
82
+ );
package/src/query.ts ADDED
@@ -0,0 +1,144 @@
1
+ // The chainable read. Every chain terminates in a cursor page — `page()` returns rows plus the
2
+ // cursor for the next one, and `all()`/`one()` are that page's rows. There is no `offset()` and
3
+ // there will not be one: under concurrent writes an insert before the offset shifts every later
4
+ // page, so a client silently skips and repeats rows.
5
+
6
+ import type { EntityCore } from './entity';
7
+ import type { Page, Repo, RepoOptions } from './repo';
8
+ import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
9
+ import type { ColumnMap, Insertable } from './types';
10
+
11
+ export interface ReadBuilder<Row> {
12
+ /** Equality on the columns given. `where({ orgId })` is what satisfies the tenancy guard. */
13
+ where(filter: Partial<Row>): ReadBuilder<Row>;
14
+ andWhere(column: keyof Row & string, op: Operator, value: unknown): ReadBuilder<Row>;
15
+ orderBy(column: keyof Row & string, direction?: SortDirection): ReadBuilder<Row>;
16
+ limit(rows: number): ReadBuilder<Row>;
17
+ /** The cursor from the previous page. */
18
+ after(cursor: string | null): ReadBuilder<Row>;
19
+ select<K extends keyof Row & string>(
20
+ fields: { readonly [P in K]: true },
21
+ ): ReadBuilder<Pick<Row, K>>;
22
+ /** The terminal: one bounded page and the cursor that continues it. */
23
+ page(): Promise<Page<Row>>;
24
+ all(): Promise<readonly Row[]>;
25
+ one(): Promise<Row | null>;
26
+ count(): Promise<number>;
27
+ /** The plan this chain describes. Safe to log — `describePlan()` elides values. */
28
+ plan(): QueryPlan;
29
+ }
30
+
31
+ export interface Table<Row, C extends ColumnMap = ColumnMap> extends ReadBuilder<Row> {
32
+ insert(values: Insertable<C>, options?: RepoOptions): Promise<Row>;
33
+ update(id: string, patch: Partial<Row>, options?: RepoOptions): Promise<Row>;
34
+ delete(id: string, options?: RepoOptions): Promise<void>;
35
+ }
36
+
37
+ interface State {
38
+ readonly where: readonly Predicate[];
39
+ readonly orderBy: readonly SortKey[];
40
+ readonly limit: number;
41
+ readonly cursor: string | null;
42
+ readonly select: readonly string[] | undefined;
43
+ }
44
+
45
+ const EMPTY: State = { where: [], orderBy: [], limit: 50, cursor: null, select: undefined };
46
+
47
+ const asRecord = (value: unknown): Readonly<Record<string, unknown>> =>
48
+ typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
49
+
50
+ const builder = <Source, Row>(
51
+ entity: EntityCore<Source>,
52
+ repo: Repo<Source>,
53
+ state: State,
54
+ pick: (row: Source) => Row,
55
+ ): ReadBuilder<Row> => {
56
+ const next = (patch: Partial<State>): ReadBuilder<Row> =>
57
+ builder(entity, repo, { ...state, ...patch }, pick);
58
+
59
+ const args = () => ({
60
+ where: state.where,
61
+ orderBy: state.orderBy,
62
+ limit: state.limit,
63
+ cursor: state.cursor,
64
+ ...(state.select === undefined ? {} : { select: state.select }),
65
+ });
66
+
67
+ return {
68
+ where: (filter) =>
69
+ next({
70
+ where: [
71
+ ...state.where,
72
+ ...Object.entries(asRecord(filter)).map(
73
+ ([column, value]): Predicate => ({ column, op: 'eq', value }),
74
+ ),
75
+ ],
76
+ }),
77
+
78
+ andWhere: (column, op, value) => next({ where: [...state.where, { column, op, value }] }),
79
+
80
+ orderBy: (column, direction = 'asc') =>
81
+ next({ orderBy: [...state.orderBy, { column, direction }] }),
82
+
83
+ limit: (rows) => next({ limit: rows }),
84
+
85
+ after: (cursor) => next({ cursor }),
86
+
87
+ select<K extends keyof Row & string>(fields: { readonly [P in K]: true }) {
88
+ // The predicate is what carries the literal key type through `Object.keys`.
89
+ const keys = Object.keys(fields).filter((key): key is K => Object.hasOwn(fields, key));
90
+ return builder<Source, Pick<Row, K>>(entity, repo, { ...state, select: keys }, (row) => {
91
+ const source = pick(row);
92
+ const picked = {} as Pick<Row, K>;
93
+ for (const key of keys) picked[key] = source[key];
94
+ return picked;
95
+ });
96
+ },
97
+
98
+ page: async () => {
99
+ const result = await repo.findMany(args());
100
+ return { rows: result.rows.map(pick), nextCursor: result.nextCursor };
101
+ },
102
+
103
+ all: async () => (await repo.findMany(args())).rows.map(pick),
104
+
105
+ one: async () => {
106
+ const { rows } = await repo.findMany({ ...args(), limit: 1 });
107
+ const row = rows[0];
108
+ return row === undefined ? null : pick(row);
109
+ },
110
+
111
+ count: () => repo.count(args()),
112
+
113
+ plan: (): QueryPlan => ({
114
+ entity: entity.$name,
115
+ where: state.where,
116
+ orderBy: state.orderBy,
117
+ limit: state.limit,
118
+ ...(state.cursor === null ? {} : { cursor: state.cursor }),
119
+ ...(state.select === undefined ? {} : { select: state.select }),
120
+ }),
121
+ };
122
+ };
123
+
124
+ /** Columns declared `onUpdateNow()` are written by the framework, never by the caller. */
125
+ const touch = <Row>(entity: EntityCore<Row>, patch: Partial<Row>): Partial<Row> => {
126
+ const stamped: Record<string, unknown> = {};
127
+ for (const [property, column] of Object.entries(entity.$columns)) {
128
+ if (column.$meta.onUpdate !== undefined) stamped[property] = new Date();
129
+ }
130
+ return Object.assign({}, patch, stamped);
131
+ };
132
+
133
+ // Every write is `async`, matching the repository contract: a failing call rejects and never
134
+ // throws synchronously. `$parse` throws — without the wrapper, a bad row escapes at call time
135
+ // while a bad id rejects, and every call site would need two error paths for one mistake.
136
+ export const tableFor = <Row, C extends ColumnMap>(
137
+ entity: EntityCore<Row, C>,
138
+ repo: Repo<Row>,
139
+ ): Table<Row, C> => ({
140
+ ...builder<Row, Row>(entity, repo, EMPTY, (row) => row),
141
+ insert: async (values, options) => repo.insert(entity.$parse(values), options),
142
+ update: async (id, patch, options) => repo.update(id, touch(entity, patch), options),
143
+ delete: async (id, options) => repo.delete(id, options),
144
+ });
package/src/registry.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  // The entity registry. Every `entity()` call registers here, which is what makes
2
- // `x.manifest.json`, the admin dashboard generator and the migration emitter able to
3
- // see the whole domain without importing it — and what makes a duplicate name a
4
- // build error rather than a silent last-one-wins.
2
+ // `x.manifest.json`, the admin dashboard generator and the migration emitter able to see the
3
+ // whole domain without importing it — and what makes a duplicate name a build error rather
4
+ // than a silent last-one-wins.
5
+
5
6
  import { entityDuplicate } from './errors';
7
+ import type { InvariantKind } from './invariants';
6
8
 
7
9
  export interface ColumnDescription {
8
10
  readonly property: string;
@@ -18,9 +20,10 @@ export interface ColumnDescription {
18
20
 
19
21
  export interface InvariantDescription {
20
22
  readonly name: string;
21
- readonly kind: 'check' | 'unique';
23
+ readonly kind: InvariantKind;
22
24
  readonly message: string;
23
- readonly sql: string;
25
+ /** `null` for an `assert`: a JS predicate the database was never told about. */
26
+ readonly sql: string | null;
24
27
  readonly where: string | null;
25
28
  }
26
29
 
package/src/repo.ts CHANGED
Binary file
package/src/seed.ts ADDED
@@ -0,0 +1,69 @@
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.
5
+
6
+ import { createHash } from 'node:crypto';
7
+ import type { Driver } from './database';
8
+ import { memoryDriver } from './database';
9
+ import type { EntityCore } from './entity';
10
+ import type { ColumnMap, Insertable } from './types';
11
+
12
+ /** Framework namespace for seed labels. Fixed forever: changing it moves every seeded id. */
13
+ const NAMESPACE = 'a3c1f0d6-5c2b-4a3e-9f1b-6d4e7c8a9b02';
14
+
15
+ const bytesOf = (uuid: string): Uint8Array =>
16
+ Uint8Array.from((uuid.replaceAll('-', '').match(/../g) ?? []).map((pair) => parseInt(pair, 16)));
17
+
18
+ /** RFC 4122 v5: SHA-1 of namespace + name, with the version and variant bits pinned. */
19
+ export const seedId = (label: string): string => {
20
+ const name = new TextEncoder().encode(label);
21
+ const input = new Uint8Array(16 + name.length);
22
+ input.set(bytesOf(NAMESPACE));
23
+ input.set(name, 16);
24
+ const digest = new Uint8Array(createHash('sha1').update(input).digest());
25
+ const bytes = digest.slice(0, 16);
26
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x50;
27
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
28
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
29
+ return [
30
+ hex.slice(0, 8),
31
+ hex.slice(8, 12),
32
+ hex.slice(12, 16),
33
+ hex.slice(16, 20),
34
+ hex.slice(20, 32),
35
+ ].join('-');
36
+ };
37
+
38
+ export interface SeedContext {
39
+ insert<Row, C extends ColumnMap>(
40
+ entity: EntityCore<Row, C>,
41
+ rows: readonly Insertable<C>[],
42
+ ): Promise<void>;
43
+ /** Deterministic id for a label. Same label, same uuid, every run. */
44
+ id(label: string): string;
45
+ }
46
+
47
+ export interface SeedOptions {
48
+ /** Defaults to a fresh in-memory driver, so a seed runs with no database at all. */
49
+ readonly driver?: Driver;
50
+ }
51
+
52
+ export interface Seed {
53
+ readonly name: string;
54
+ run(options?: SeedOptions): Promise<void>;
55
+ }
56
+
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
+ });