@zmdb/orm 1.0.0-beta.1

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.
Files changed (87) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +30 -0
  3. package/dist/cache/index.d.ts +34 -0
  4. package/dist/cache/index.d.ts.map +1 -0
  5. package/dist/cache/index.js +149 -0
  6. package/dist/cache/index.js.map +1 -0
  7. package/dist/drivers/transactional.d.ts +6 -0
  8. package/dist/drivers/transactional.d.ts.map +1 -0
  9. package/dist/drivers/transactional.js +2 -0
  10. package/dist/drivers/transactional.js.map +1 -0
  11. package/dist/dto/index.d.ts +41 -0
  12. package/dist/dto/index.d.ts.map +1 -0
  13. package/dist/dto/index.js +334 -0
  14. package/dist/dto/index.js.map +1 -0
  15. package/dist/entity-modeling/index.d.ts +18 -0
  16. package/dist/entity-modeling/index.d.ts.map +1 -0
  17. package/dist/entity-modeling/index.js +26 -0
  18. package/dist/entity-modeling/index.js.map +1 -0
  19. package/dist/filters/index.d.ts +62 -0
  20. package/dist/filters/index.d.ts.map +1 -0
  21. package/dist/filters/index.js +163 -0
  22. package/dist/filters/index.js.map +1 -0
  23. package/dist/index.d.ts +413 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +2003 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/loaders/index.d.ts +38 -0
  28. package/dist/loaders/index.d.ts.map +1 -0
  29. package/dist/loaders/index.js +164 -0
  30. package/dist/loaders/index.js.map +1 -0
  31. package/dist/outbox/index.d.ts +59 -0
  32. package/dist/outbox/index.d.ts.map +1 -0
  33. package/dist/outbox/index.js +323 -0
  34. package/dist/outbox/index.js.map +1 -0
  35. package/dist/outbox/sql.d.ts +52 -0
  36. package/dist/outbox/sql.d.ts.map +1 -0
  37. package/dist/outbox/sql.js +115 -0
  38. package/dist/outbox/sql.js.map +1 -0
  39. package/dist/relations/index.d.ts +38 -0
  40. package/dist/relations/index.d.ts.map +1 -0
  41. package/dist/relations/index.js +164 -0
  42. package/dist/relations/index.js.map +1 -0
  43. package/dist/replicas/index.d.ts +10 -0
  44. package/dist/replicas/index.d.ts.map +1 -0
  45. package/dist/replicas/index.js +42 -0
  46. package/dist/replicas/index.js.map +1 -0
  47. package/dist/seeding/index.d.ts +18 -0
  48. package/dist/seeding/index.d.ts.map +1 -0
  49. package/dist/seeding/index.js +66 -0
  50. package/dist/seeding/index.js.map +1 -0
  51. package/dist/streaming/index.d.ts +7 -0
  52. package/dist/streaming/index.d.ts.map +1 -0
  53. package/dist/streaming/index.js +67 -0
  54. package/dist/streaming/index.js.map +1 -0
  55. package/dist/testing/official-dialects.fixture.d.ts +16 -0
  56. package/dist/testing/official-dialects.fixture.d.ts.map +1 -0
  57. package/dist/testing/official-dialects.fixture.js +21 -0
  58. package/dist/testing/official-dialects.fixture.js.map +1 -0
  59. package/dist/testing/repository.fixture.d.ts +11 -0
  60. package/dist/testing/repository.fixture.d.ts.map +1 -0
  61. package/dist/testing/repository.fixture.js +33 -0
  62. package/dist/testing/repository.fixture.js.map +1 -0
  63. package/dist/transactions/index.d.ts +40 -0
  64. package/dist/transactions/index.d.ts.map +1 -0
  65. package/dist/transactions/index.js +249 -0
  66. package/dist/transactions/index.js.map +1 -0
  67. package/dist/typed-populate/nested.fixtures.d.ts +34 -0
  68. package/dist/typed-populate/nested.fixtures.d.ts.map +1 -0
  69. package/dist/typed-populate/nested.fixtures.js +4 -0
  70. package/dist/typed-populate/nested.fixtures.js.map +1 -0
  71. package/package.json +78 -0
  72. package/src/cache/index.ts +188 -0
  73. package/src/drivers/transactional.ts +6 -0
  74. package/src/dto/index.ts +379 -0
  75. package/src/entity-modeling/index.ts +38 -0
  76. package/src/filters/index.ts +267 -0
  77. package/src/index.ts +2757 -0
  78. package/src/loaders/index.ts +256 -0
  79. package/src/outbox/index.ts +409 -0
  80. package/src/outbox/sql.ts +179 -0
  81. package/src/relations/index.ts +221 -0
  82. package/src/replicas/index.ts +51 -0
  83. package/src/seeding/index.ts +72 -0
  84. package/src/streaming/index.ts +67 -0
  85. package/src/testing/repository.fixture.ts +44 -0
  86. package/src/transactions/index.ts +315 -0
  87. package/src/typed-populate/nested.fixtures.ts +59 -0
@@ -0,0 +1,179 @@
1
+ import {
2
+ createQueryCompiler,
3
+ dialectTraits,
4
+ quoteIdentifier,
5
+ type CompiledQuery,
6
+ type DialectOutbox,
7
+ type DialectTarget,
8
+ } from '@zmdb/sql';
9
+
10
+ export const OUTBOX_TABLE = 'zmdb_outbox';
11
+ export type OutboxStatus = 'pending' | 'delivered' | 'dead';
12
+
13
+ const PENDING: OutboxStatus = 'pending';
14
+
15
+ export interface OutboxMigration {
16
+ readonly version: number;
17
+ readonly name: string;
18
+ readonly up: string;
19
+ readonly down: string;
20
+ }
21
+
22
+ /**
23
+ * Most dialects index only pending rows. Dialects that cannot represent the
24
+ * predicate use the full composite index with `status` first to preserve a
25
+ * useful pending-row prefix instead of degrading to an unindexed scan.
26
+ */
27
+ export function outboxPendingIndexDdl(dialect: DialectTarget): string {
28
+ const definition = {
29
+ name: 'zmdb_outbox_pending',
30
+ table: OUTBOX_TABLE,
31
+ columns: ['status', 'lease_until', 'created_at'],
32
+ ...(outboxDialect(dialect).pendingIndex === 'full' ? {} : { where: "status = 'pending'" }),
33
+ };
34
+ const statements = dialect.migrations.emitSchemaObject({
35
+ kind: 'create_index',
36
+ definition,
37
+ });
38
+ if (statements.length !== 1 || statements[0] === undefined) {
39
+ throw new TypeError(`${dialect.name} outbox index emission must return exactly one statement`);
40
+ }
41
+ return statements[0];
42
+ }
43
+
44
+ function outboxDialect(dialect: DialectTarget): DialectOutbox {
45
+ if (dialect.outbox === undefined) {
46
+ throw new TypeError(`${dialect.name} does not provide an outbox DDL strategy`);
47
+ }
48
+ return dialect.outbox;
49
+ }
50
+
51
+ /** The declared outbox table's migration DDL, including the defaults its type tags cannot carry. */
52
+ export function outboxTableDdl(dialect: DialectTarget): string {
53
+ const q = (name: string) => quoteIdentifier(dialect, name);
54
+ const traits = dialectTraits(dialect);
55
+ const outbox = outboxDialect(dialect);
56
+ const timestamp = traits.types.timestamp;
57
+ // MySQL refuses TEXT primary keys and TEXT columns in an index without a prefix
58
+ // length. These three values are bounded by construction, so the migration uses
59
+ // bounded storage there while the application type remains string.
60
+ const text = traits.types.text;
61
+ const idType = outbox.boundedTextType(36);
62
+ const statusType = outbox.boundedTextType(16);
63
+ const leaseOwnerType = outbox.boundedTextType(36);
64
+ return (
65
+ `${outbox.createTable} ${q(OUTBOX_TABLE)} (` +
66
+ `${q('id')} ${idType} PRIMARY KEY, ` +
67
+ `${q('topic')} ${text} NOT NULL, ` +
68
+ `${q('payload')} ${text} NOT NULL, ` +
69
+ `${q('status')} ${statusType} NOT NULL DEFAULT 'pending', ` +
70
+ `${q('attempts')} ${traits.types.integer} NOT NULL DEFAULT 0, ` +
71
+ `${q('created_at')} ${timestamp} NOT NULL DEFAULT ${outbox.createdAtDefault}, ` +
72
+ `${q('lease_owner')} ${leaseOwnerType} NOT NULL DEFAULT '', ` +
73
+ `${q('lease_until')} ${timestamp} NOT NULL DEFAULT ${outbox.epochLiteral}, ` +
74
+ `${q('delivered_at')} ${timestamp}, ` +
75
+ `${q('last_error')} ${text})`
76
+ );
77
+ }
78
+
79
+ /** A normal migration value applications can place in their ordered migration list. */
80
+ export function outboxMigration(version: number, dialect: DialectTarget): OutboxMigration {
81
+ return {
82
+ version,
83
+ name: 'create_zmdb_outbox',
84
+ up: `${outboxTableDdl(dialect)}; ${outboxPendingIndexDdl(dialect)}`,
85
+ down: `DROP TABLE ${quoteIdentifier(dialect, OUTBOX_TABLE)}`,
86
+ };
87
+ }
88
+
89
+ export function outboxCandidatesQuery(
90
+ dialect: DialectTarget,
91
+ args: { readonly now: Date; readonly batch: number },
92
+ ): CompiledQuery {
93
+ return createQueryCompiler(dialect)
94
+ .selectFrom(OUTBOX_TABLE)
95
+ .select(['id'])
96
+ .where('status', '=', PENDING)
97
+ .where('lease_until', '<', args.now)
98
+ .orderBy('created_at', 'asc')
99
+ .limit(args.batch)
100
+ .compile();
101
+ }
102
+
103
+ export function outboxClaimQuery(
104
+ dialect: DialectTarget,
105
+ args: {
106
+ readonly now: Date;
107
+ readonly token: string;
108
+ readonly leaseUntil: Date;
109
+ readonly ids: readonly string[];
110
+ },
111
+ ): CompiledQuery {
112
+ return createQueryCompiler(dialect)
113
+ .updateTable(OUTBOX_TABLE)
114
+ .set({ lease_owner: args.token, lease_until: args.leaseUntil })
115
+ .where('status', '=', PENDING)
116
+ .where('lease_until', '<', args.now)
117
+ .whereIn('id', args.ids)
118
+ .compile();
119
+ }
120
+
121
+ export function outboxReadBackQuery(dialect: DialectTarget, args: { readonly token: string }): CompiledQuery {
122
+ return createQueryCompiler(dialect)
123
+ .selectFrom(OUTBOX_TABLE)
124
+ .select(['id', 'topic', 'payload', 'attempts'])
125
+ .where('lease_owner', '=', args.token)
126
+ .compile();
127
+ }
128
+
129
+ export function outboxMarkDeliveredQuery(
130
+ dialect: DialectTarget,
131
+ args: {
132
+ readonly id: string;
133
+ readonly token: string;
134
+ readonly deliveredAt: Date;
135
+ readonly attempts: number;
136
+ },
137
+ ): CompiledQuery {
138
+ return createQueryCompiler(dialect)
139
+ .updateTable(OUTBOX_TABLE)
140
+ .set({ status: 'delivered', delivered_at: args.deliveredAt, attempts: args.attempts })
141
+ .where('id', '=', args.id)
142
+ .where('lease_owner', '=', args.token)
143
+ .compile();
144
+ }
145
+
146
+ export function outboxMarkRetryQuery(
147
+ dialect: DialectTarget,
148
+ args: {
149
+ readonly id: string;
150
+ readonly token: string;
151
+ readonly attempts: number;
152
+ readonly lastError: string;
153
+ readonly leaseUntil: Date;
154
+ },
155
+ ): CompiledQuery {
156
+ return createQueryCompiler(dialect)
157
+ .updateTable(OUTBOX_TABLE)
158
+ .set({ attempts: args.attempts, last_error: args.lastError, lease_until: args.leaseUntil })
159
+ .where('id', '=', args.id)
160
+ .where('lease_owner', '=', args.token)
161
+ .compile();
162
+ }
163
+
164
+ export function outboxMarkDeadQuery(
165
+ dialect: DialectTarget,
166
+ args: {
167
+ readonly id: string;
168
+ readonly token: string;
169
+ readonly attempts: number;
170
+ readonly lastError: string;
171
+ },
172
+ ): CompiledQuery {
173
+ return createQueryCompiler(dialect)
174
+ .updateTable(OUTBOX_TABLE)
175
+ .set({ status: 'dead', attempts: args.attempts, last_error: args.lastError })
176
+ .where('id', '=', args.id)
177
+ .where('lease_owner', '=', args.token)
178
+ .compile();
179
+ }
@@ -0,0 +1,221 @@
1
+ import type { SchemaIR } from '@zmdb/schema/ir';
2
+ import { resolveRelation } from '@zmdb/schema/relations';
3
+ import {
4
+ dialectName,
5
+ dialectTraits,
6
+ formatPlaceholder,
7
+ quoteIdentifier,
8
+ renderPredicate,
9
+ UnsupportedFeatureError,
10
+ type ComparisonPredicate,
11
+ type DialectTarget,
12
+ } from '@zmdb/sql';
13
+
14
+ export type PopulateDialect = DialectTarget;
15
+
16
+ export interface PopulateQuery {
17
+ readonly kind: 'join' | 'batched';
18
+ readonly sql: string;
19
+ readonly parameters: readonly unknown[];
20
+ }
21
+
22
+ function sanitizeKeys<T>(keys: readonly T[]): T[] {
23
+ const result: T[] = [];
24
+ const seen = new Set<T>();
25
+ for (const k of keys) {
26
+ if (k !== null && k !== undefined && !seen.has(k)) {
27
+ seen.add(k);
28
+ result.push(k);
29
+ }
30
+ }
31
+ return result;
32
+ }
33
+
34
+ function sameKeyValue(left: unknown, right: unknown): boolean {
35
+ if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
36
+ return Object.is(left, right);
37
+ }
38
+
39
+ function sanitizeCompositeKeys(
40
+ ir: SchemaIR,
41
+ relationName: string,
42
+ keys: readonly unknown[],
43
+ arity: number,
44
+ ): readonly (readonly unknown[])[] {
45
+ const result: unknown[][] = [];
46
+ for (const key of keys) {
47
+ if (key === null || key === undefined) continue;
48
+ if (!Array.isArray(key)) {
49
+ throw new Error(
50
+ `${ir.table}.${relationName}: composite-key populate expects a ${String(arity)}-column tuple for every parent`,
51
+ );
52
+ }
53
+ if (key.length !== arity) {
54
+ throw new Error(
55
+ `${ir.table}.${relationName}: composite-key populate expected ${String(arity)} values, received ${String(key.length)}`,
56
+ );
57
+ }
58
+ if (key.some(value => value === null || value === undefined)) continue;
59
+ if (result.some(existing => existing.every((value, index) => sameKeyValue(value, key[index])))) continue;
60
+ result.push([...key]);
61
+ }
62
+ return result;
63
+ }
64
+
65
+ /**
66
+ * Compile a populate hint into SQL: a to-one becomes an `INNER JOIN`, a to-many a batched
67
+ * `IN (…)` select over the parent keys.
68
+ *
69
+ * Takes the declaring table's IR and a relation name rather than a relation object, which is
70
+ * what makes the two spellings one: the columns on either side of the `ON` come out of
71
+ * `resolveRelation`, the same call `@zmdb/orm`'s `populate` makes.
72
+ */
73
+ export function compilePopulate(
74
+ ir: SchemaIR,
75
+ relationName: string,
76
+ dialect: PopulateDialect,
77
+ parentIds: readonly unknown[] = [],
78
+ targetFilters: readonly ComparisonPredicate[] = [],
79
+ schemas: readonly SchemaIR[] = [],
80
+ ): PopulateQuery {
81
+ const rel = resolveRelation(ir, relationName);
82
+ const q = (name: string): string => quoteIdentifier(dialect, name);
83
+ const targetIr = schemas.find(schema => schema.table === rel.targetTable);
84
+ const sourceTable = ir.physicalTable;
85
+ const targetTable = targetIr?.physicalTable ?? rel.targetTable;
86
+ const physicalColumn = (schema: SchemaIR | undefined, declared: string): string => {
87
+ if (schema === undefined) return declared;
88
+ const separator = declared.lastIndexOf('.');
89
+ const property = separator === -1 ? declared : declared.slice(separator + 1);
90
+ const physical = schema.columns.find(column => column.name === property)?.physicalName;
91
+ if (physical === undefined) return declared;
92
+ if (separator === -1) return physical;
93
+ const qualifier = declared.slice(0, separator);
94
+ return `${qualifier === schema.table ? schema.physicalTable : qualifier}.${physical}`;
95
+ };
96
+ const parentKeys = rel.parentKey.map(column => physicalColumn(ir, column));
97
+ const targetKeys = rel.targetKey.map(column => physicalColumn(targetIr, column));
98
+ const physicalFilters = targetFilters.map(predicate => ({
99
+ ...predicate,
100
+ col: physicalColumn(targetIr, predicate.col),
101
+ }));
102
+ const renderFilters = (parameters: unknown[]): string => {
103
+ if (physicalFilters.length === 0) return '';
104
+ const body = physicalFilters
105
+ .map((predicate, index) => {
106
+ const rendered = renderPredicate(dialect, predicate, parameters);
107
+ return index === 0 ? rendered : `${predicate.connector ?? 'AND'} ${rendered}`;
108
+ })
109
+ .join(' ');
110
+ const grouped = targetFilters.some((predicate, index) => index > 0 && predicate.connector === 'OR');
111
+ return `AND ${grouped ? `(${body})` : body}`;
112
+ };
113
+ if (!rel.toMany) {
114
+ const parameters: unknown[] = [];
115
+ const filtered = physicalFilters.length > 0;
116
+ const onFilters = renderFilters(parameters);
117
+ const conditions = parentKeys.map((parentKey, index) => {
118
+ const targetKey = targetKeys[index];
119
+ if (targetKey === undefined) {
120
+ throw new Error(`${ir.table}.${relationName}: resolved relation keys have different lengths`);
121
+ }
122
+ return `${q(sourceTable)}.${q(parentKey)} = ${q(targetTable)}.${q(targetKey)}`;
123
+ });
124
+ const sql =
125
+ `SELECT * FROM ${q(sourceTable)} ${filtered ? 'LEFT' : 'INNER'} JOIN ${q(targetTable)} ` +
126
+ `ON ${conditions.join(' AND ')}` +
127
+ (onFilters.length === 0 ? '' : ` ${onFilters}`);
128
+ return { kind: 'join', sql, parameters };
129
+ }
130
+ if (parentIds.length === 0) {
131
+ return { kind: 'batched', sql: `SELECT * FROM ${q(targetTable)} WHERE 1 = 0`, parameters: [] };
132
+ }
133
+ if (targetKeys.length === 1) {
134
+ const sanitized = sanitizeKeys(parentIds);
135
+ if (sanitized.length === 0) {
136
+ return { kind: 'batched', sql: `SELECT * FROM ${q(targetTable)} WHERE 1 = 0`, parameters: [] };
137
+ }
138
+ const [targetKey] = targetKeys;
139
+ if (targetKey === undefined) {
140
+ throw new Error(`${ir.table}.${relationName}: resolved relation has no target key`);
141
+ }
142
+ const inList = sanitized.map((_, i) => formatPlaceholder(dialect, i + 1)).join(', ');
143
+ const parameters: unknown[] = [...sanitized];
144
+ const filters = renderFilters(parameters);
145
+ const sql =
146
+ `SELECT * FROM ${q(targetTable)} WHERE ${q(targetKey)} IN (${inList})` +
147
+ (filters.length === 0 ? '' : ` ${filters}`);
148
+ return { kind: 'batched', sql, parameters };
149
+ }
150
+ if (!dialectTraits(dialect).rowValueIn) {
151
+ const name = dialectName(dialect);
152
+ throw new UnsupportedFeatureError(
153
+ `composite-key populate for relation "${relationName}"`,
154
+ name,
155
+ `${ir.table}.${relationName}: dialect "${name}" does not support row-value IN for a composite-key populate`,
156
+ );
157
+ }
158
+ const sanitized = sanitizeCompositeKeys(ir, relationName, parentIds, targetKeys.length);
159
+ if (sanitized.length === 0) {
160
+ return { kind: 'batched', sql: `SELECT * FROM ${q(targetTable)} WHERE 1 = 0`, parameters: [] };
161
+ }
162
+ const parameters: unknown[] = [];
163
+ const inList = sanitized
164
+ .map(tuple => {
165
+ const placeholders = tuple.map(value => {
166
+ parameters.push(value);
167
+ return formatPlaceholder(dialect, parameters.length);
168
+ });
169
+ return `(${placeholders.join(', ')})`;
170
+ })
171
+ .join(', ');
172
+ const columns = targetKeys.map(targetKey => q(targetKey)).join(', ');
173
+ const filters = renderFilters(parameters);
174
+ const sql =
175
+ `SELECT * FROM ${q(targetTable)} WHERE (${columns}) IN (${inList})` + (filters.length === 0 ? '' : ` ${filters}`);
176
+ return { kind: 'batched', sql, parameters };
177
+ }
178
+
179
+ /**
180
+ * Attach a populated relation to a parent row, without mutating it.
181
+ *
182
+ * The type widening is `Populated<T, K>` in `../derive/query.ts`, which reads the declared
183
+ * relation property; this only puts the value there.
184
+ */
185
+ export function attachPopulated<P extends Record<string, unknown>, N extends string, V>(
186
+ parent: P,
187
+ name: N,
188
+ value: V,
189
+ ): P & { [K in N]: V } {
190
+ // boundary: a computed key in an object literal widens to `string`, so TS types
191
+ // this spread as `P & { [x: string]: V }`. `name` is the literal `N` at the call
192
+ // site, which is what the return type states.
193
+ return { ...parent, [name]: value } as P & { [K in N]: V };
194
+ }
195
+
196
+ /**
197
+ * A row from a join written against two tables directly. LEFT: the joined columns may be
198
+ * absent, so they come back optional.
199
+ *
200
+ * `../derive/query.ts` has a `JoinRow<T, K, Kind>` that names the joined side by relation
201
+ * key instead; this is the form for a join whose target is not a declared relation of the
202
+ * base table.
203
+ */
204
+ export type JoinRow<Base, Joined, Kind extends 'inner' | 'left' = 'left'> = Kind extends 'inner'
205
+ ? Base & Joined
206
+ : Base & Partial<Joined>;
207
+
208
+ /** Rename aliased columns per a { alias: outKey } map (stable, non-mutating). */
209
+ export function aliasRow<Row extends Record<string, unknown>>(
210
+ row: Row,
211
+ map: Readonly<Record<string, string>>,
212
+ ): Record<string, unknown> {
213
+ const out: Record<string, unknown> = { ...row };
214
+ for (const [alias, outKey] of Object.entries(map)) {
215
+ if (alias in out) {
216
+ out[outKey] = out[alias];
217
+ delete out[alias]; // rename: drop the original aliased key
218
+ }
219
+ }
220
+ return out;
221
+ }
@@ -0,0 +1,51 @@
1
+ import { type CompiledQuery } from '@zmdb/sql';
2
+
3
+ // Read-replica routing — see ./SPEC.md.
4
+ import { type Driver } from '../index.js';
5
+
6
+ export interface ReplicaOptions {
7
+ primary: Driver;
8
+ replicas: readonly Driver[];
9
+ pick?: (replicas: readonly Driver[], nextIndex: number) => Driver;
10
+ }
11
+
12
+ export function isWrite(sql: string): boolean {
13
+ const s = sql.trimStart().toUpperCase();
14
+ return s.startsWith('INSERT') || s.startsWith('UPDATE') || s.startsWith('DELETE');
15
+ }
16
+
17
+ /** Wrap primary+replicas into a single Driver that routes reads to replicas. */
18
+ export function withReplicas(opts: ReplicaOptions): Driver {
19
+ const { primary, replicas } = opts;
20
+ let rr = 0;
21
+ const pick = (query: CompiledQuery): Driver => {
22
+ if (isWrite(query.text) || replicas.length === 0) return primary;
23
+ const driver = opts.pick ? opts.pick(replicas, rr) : replicas[rr % replicas.length];
24
+ rr = (rr + 1) % replicas.length;
25
+ // `replicas` is non-empty here (checked above), so the modulo index always
26
+ // hits — but a custom `pick` is caller code, so fall back to the primary
27
+ // rather than crashing on a bad index.
28
+ return driver ?? primary;
29
+ };
30
+
31
+ const canStream =
32
+ typeof primary.stream === 'function' && replicas.every(driver => typeof driver.stream === 'function');
33
+ return {
34
+ dialect: primary.dialect,
35
+ execute(query, executeOpts) {
36
+ return pick(query).execute(query, executeOpts);
37
+ },
38
+ ...(canStream
39
+ ? {
40
+ stream(query: CompiledQuery, executeOpts?: Parameters<NonNullable<Driver['stream']>>[1]) {
41
+ const driver = pick(query);
42
+ const stream = driver.stream;
43
+ if (typeof stream !== 'function') {
44
+ throw new Error('replica routing selected a driver without stream support');
45
+ }
46
+ return stream.call(driver, query, executeOpts);
47
+ },
48
+ }
49
+ : {}),
50
+ };
51
+ }
@@ -0,0 +1,72 @@
1
+ import { type CreateDTO, type DeclaredTable, type TaggedSchema } from '@zmdb/schema';
2
+ import { objectTypeFromIR, type ObjectIR } from '@zmdb/schema/ir';
3
+ // Deterministic seeding — see ./SPEC.md.
4
+ //
5
+ // This module used to live in `@zmdb/schema/seeding` and generate its values from a
6
+ // walk of its own over `schema.columns`: a `switch` on `col.type` returning a random number
7
+ // for an integer, a random `s…` for anything string-ish, and a member of `flags.enum` for an
8
+ // enum. It was the fifth walker over column metadata, and it had the failure mode all five
9
+ // shared — it read part of what a column says. `Min<18>`, `Max<120>`, `Pattern<…>`,
10
+ // `MinLength`, a `json` payload shape and nullability were all invisible to it, so seeding a
11
+ // table with any of them produced rows the table's own validator rejects.
12
+ //
13
+ // It generates from the IR now, through the same sampler `random<T>()` uses, which reads the
14
+ // whole vocabulary and *refuses* what it cannot satisfy rather than guessing. Two
15
+ // consequences worth stating, because both are visible to a caller:
16
+ //
17
+ // - The rows honour the constraints. A `Min<18>` column is seeded within its bounds.
18
+ // - A `Pattern<…>` column is refused, with the column named. Nothing inverts a regular
19
+ // expression, so the alternative is a value that violates the pattern — which is what
20
+ // used to happen, silently, and surfaced later as a validation failure in a test whose
21
+ // subject was something else.
22
+ //
23
+ // Seeding lives in ORM because its sampler ships in `@zmdb/validator`, which
24
+ // depends on `@zmdb/schema/ir`. The dependencies point inward from ORM. That is also the honest home: a seeded row exists to be handed to
25
+ // `repository.create`, and now it type-checks as one.
26
+ import { random } from '@zmdb/validator';
27
+
28
+ /** Deterministic PRNG (mulberry32). Same seed ⇒ same sequence. */
29
+ export function makeRng(seed: number): () => number {
30
+ let a = seed >>> 0;
31
+ return () => {
32
+ a |= 0;
33
+ a = (a + 0x6d2b79f5) | 0;
34
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
35
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
36
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
37
+ };
38
+ }
39
+
40
+ export interface SeedOptions {
41
+ seed?: number;
42
+ count: number;
43
+ }
44
+
45
+ /**
46
+ * The create shape with its optional properties dropped — the columns a caller *must* supply.
47
+ *
48
+ * The sampler fills every property of an object it is given, including the optional ones, and
49
+ * that is right for `random<T>()`: a sample of a type is a value of that type. It is wrong
50
+ * here. A column is optional in `CreateDTO` because the database has a default for it, and
51
+ * seeding a value over the default is how a seeded row stops resembling an inserted one.
52
+ */
53
+ function requiredColumns(shape: ObjectIR): ObjectIR {
54
+ return { ...shape, properties: shape.properties.filter(property => !property.optional) };
55
+ }
56
+
57
+ /**
58
+ * `count` rows shaped like `T`'s create DTO, reproducible from `seed`.
59
+ *
60
+ * Typed as `CreateDTO<T>[]`, which the column-map version could not be: it built its rows
61
+ * from the lossy projection and had no way to tell the compiler what it had made. `random`'s
62
+ * type parameter is inferred from the return type here, so there is no assertion — and no
63
+ * type argument either, which is what keeps the transformer out of a call whose shape is only
64
+ * known at runtime.
65
+ */
66
+ export function seedRows<T extends DeclaredTable>(schema: TaggedSchema<T>, opts: SeedOptions): CreateDTO<T>[] {
67
+ const rng = makeRng(opts.seed ?? 1);
68
+ const shape = requiredColumns(objectTypeFromIR(schema.ir, 'create'));
69
+ const rows: CreateDTO<T>[] = [];
70
+ for (let i = 0; i < opts.count; i++) rows.push(random(shape, rng));
71
+ return rows;
72
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Add repository ergonomics around a driver's deliberately small stream
3
+ * surface. The driver owns its cursor; this wrapper owns single-shot use,
4
+ * per-row mapping, and exactly one cleanup path.
5
+ */
6
+ export function createRepositoryStream<Raw, Row>(
7
+ open: () => AsyncIterable<Raw>,
8
+ map: (row: Raw) => Row,
9
+ signal?: AbortSignal,
10
+ ): AsyncIterable<Row> & AsyncDisposable {
11
+ let started = false;
12
+ let disposed = false;
13
+ let active: AsyncIterator<Raw> | undefined;
14
+ let closing: Promise<void> | undefined;
15
+
16
+ const close = (): Promise<void> => {
17
+ if (closing !== undefined) return closing;
18
+ disposed = true;
19
+ const iterator = active;
20
+ active = undefined;
21
+ closing = (async () => {
22
+ if (iterator?.return !== undefined) await iterator.return();
23
+ })();
24
+ return closing;
25
+ };
26
+
27
+ return {
28
+ [Symbol.asyncIterator](): AsyncIterator<Row> {
29
+ if (started) throw new Error('repository stream is single-shot');
30
+ if (disposed) throw new Error('repository stream has been disposed');
31
+ started = true;
32
+
33
+ const iterate = async function* (): AsyncGenerator<Row, void, unknown> {
34
+ let completed = false;
35
+ try {
36
+ if (disposed) throw new Error('repository stream has been disposed');
37
+ signal?.throwIfAborted();
38
+ active = open()[Symbol.asyncIterator]();
39
+
40
+ for (;;) {
41
+ signal?.throwIfAborted();
42
+ const iterator = active;
43
+ if (iterator === undefined) return;
44
+ const next = await iterator.next();
45
+ if (disposed) return;
46
+ signal?.throwIfAborted();
47
+ if (next.done) {
48
+ completed = true;
49
+ active = undefined;
50
+ disposed = true;
51
+ return;
52
+ }
53
+ yield map(next.value);
54
+ }
55
+ } finally {
56
+ if (!completed) await close();
57
+ }
58
+ };
59
+
60
+ return iterate();
61
+ },
62
+
63
+ [Symbol.asyncDispose](): Promise<void> {
64
+ return close();
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,44 @@
1
+ import { type ColumnIR } from '@zmdb/schema/ir';
2
+ import { type CompiledQuery } from '@zmdb/sql';
3
+
4
+ import { type Driver } from '../index.js';
5
+ import { postgresDialect } from './official-dialects.fixture.js';
6
+
7
+ export function column(name: string, sql: ColumnIR['sql'], overrides: Partial<ColumnIR> = {}): ColumnIR {
8
+ return {
9
+ name,
10
+ physicalName: name,
11
+ sql,
12
+ nullable: false,
13
+ primaryKey: false,
14
+ serial: false,
15
+ unique: false,
16
+ hasDefault: false,
17
+ sensitive: false,
18
+ constraints: {},
19
+ rules: [],
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ interface RecordingDriver extends Driver {
25
+ readonly calls: CompiledQuery[];
26
+ }
27
+
28
+ type DriverAnswer = (
29
+ query: CompiledQuery,
30
+ call: number,
31
+ ) => readonly Record<string, unknown>[] | Promise<readonly Record<string, unknown>[]>;
32
+
33
+ export function recordingDriver(answer: DriverAnswer): RecordingDriver {
34
+ const calls: CompiledQuery[] = [];
35
+ return {
36
+ dialect: postgresDialect,
37
+ calls,
38
+ async execute(query) {
39
+ const call = calls.length;
40
+ calls.push(query);
41
+ return answer(query, call);
42
+ },
43
+ };
44
+ }