@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.
- package/LICENSE +674 -0
- package/README.md +30 -0
- package/dist/cache/index.d.ts +34 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +149 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/drivers/transactional.d.ts +6 -0
- package/dist/drivers/transactional.d.ts.map +1 -0
- package/dist/drivers/transactional.js +2 -0
- package/dist/drivers/transactional.js.map +1 -0
- package/dist/dto/index.d.ts +41 -0
- package/dist/dto/index.d.ts.map +1 -0
- package/dist/dto/index.js +334 -0
- package/dist/dto/index.js.map +1 -0
- package/dist/entity-modeling/index.d.ts +18 -0
- package/dist/entity-modeling/index.d.ts.map +1 -0
- package/dist/entity-modeling/index.js +26 -0
- package/dist/entity-modeling/index.js.map +1 -0
- package/dist/filters/index.d.ts +62 -0
- package/dist/filters/index.d.ts.map +1 -0
- package/dist/filters/index.js +163 -0
- package/dist/filters/index.js.map +1 -0
- package/dist/index.d.ts +413 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2003 -0
- package/dist/index.js.map +1 -0
- package/dist/loaders/index.d.ts +38 -0
- package/dist/loaders/index.d.ts.map +1 -0
- package/dist/loaders/index.js +164 -0
- package/dist/loaders/index.js.map +1 -0
- package/dist/outbox/index.d.ts +59 -0
- package/dist/outbox/index.d.ts.map +1 -0
- package/dist/outbox/index.js +323 -0
- package/dist/outbox/index.js.map +1 -0
- package/dist/outbox/sql.d.ts +52 -0
- package/dist/outbox/sql.d.ts.map +1 -0
- package/dist/outbox/sql.js +115 -0
- package/dist/outbox/sql.js.map +1 -0
- package/dist/relations/index.d.ts +38 -0
- package/dist/relations/index.d.ts.map +1 -0
- package/dist/relations/index.js +164 -0
- package/dist/relations/index.js.map +1 -0
- package/dist/replicas/index.d.ts +10 -0
- package/dist/replicas/index.d.ts.map +1 -0
- package/dist/replicas/index.js +42 -0
- package/dist/replicas/index.js.map +1 -0
- package/dist/seeding/index.d.ts +18 -0
- package/dist/seeding/index.d.ts.map +1 -0
- package/dist/seeding/index.js +66 -0
- package/dist/seeding/index.js.map +1 -0
- package/dist/streaming/index.d.ts +7 -0
- package/dist/streaming/index.d.ts.map +1 -0
- package/dist/streaming/index.js +67 -0
- package/dist/streaming/index.js.map +1 -0
- package/dist/testing/official-dialects.fixture.d.ts +16 -0
- package/dist/testing/official-dialects.fixture.d.ts.map +1 -0
- package/dist/testing/official-dialects.fixture.js +21 -0
- package/dist/testing/official-dialects.fixture.js.map +1 -0
- package/dist/testing/repository.fixture.d.ts +11 -0
- package/dist/testing/repository.fixture.d.ts.map +1 -0
- package/dist/testing/repository.fixture.js +33 -0
- package/dist/testing/repository.fixture.js.map +1 -0
- package/dist/transactions/index.d.ts +40 -0
- package/dist/transactions/index.d.ts.map +1 -0
- package/dist/transactions/index.js +249 -0
- package/dist/transactions/index.js.map +1 -0
- package/dist/typed-populate/nested.fixtures.d.ts +34 -0
- package/dist/typed-populate/nested.fixtures.d.ts.map +1 -0
- package/dist/typed-populate/nested.fixtures.js +4 -0
- package/dist/typed-populate/nested.fixtures.js.map +1 -0
- package/package.json +78 -0
- package/src/cache/index.ts +188 -0
- package/src/drivers/transactional.ts +6 -0
- package/src/dto/index.ts +379 -0
- package/src/entity-modeling/index.ts +38 -0
- package/src/filters/index.ts +267 -0
- package/src/index.ts +2757 -0
- package/src/loaders/index.ts +256 -0
- package/src/outbox/index.ts +409 -0
- package/src/outbox/sql.ts +179 -0
- package/src/relations/index.ts +221 -0
- package/src/replicas/index.ts +51 -0
- package/src/seeding/index.ts +72 -0
- package/src/streaming/index.ts +67 -0
- package/src/testing/repository.fixture.ts +44 -0
- package/src/transactions/index.ts +315 -0
- 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
|
+
}
|