@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,315 @@
1
+ // Transactions — implementation (#36 transaction context primitive).
2
+ // createTransactionalDb.transaction() issues BEGIN/COMMIT/ROLLBACK and
3
+ // tx.savepoint() issues SAVEPOINT / RELEASE / ROLLBACK TO SAVEPOINT.
4
+ import { dialectTraits, type CompiledQuery, type DialectTarget } from '@zmdb/sql';
5
+
6
+ import { type ExecuteOptions } from '../index.js';
7
+
8
+ export type TransactionState = 'active' | 'closed' | 'committed' | 'rolled_back' | string;
9
+
10
+ export interface TransactionContext<State extends string = 'active'> {
11
+ readonly _state?: State | undefined;
12
+ readonly dialect?: DialectTarget;
13
+ execute(query: CompiledQuery, opts?: ExecuteOptions): Promise<readonly Record<string, unknown>[]>;
14
+ stream?(query: CompiledQuery, opts?: ExecuteOptions): AsyncIterable<Record<string, unknown>>;
15
+ savepoint<R>(fn: (tx: TransactionContext<State>) => Promise<R>): Promise<R>;
16
+ }
17
+
18
+ export type ActiveTransactionContext = TransactionContext<'active'>;
19
+ export type ClosedTransactionContext = TransactionContext<'closed'>;
20
+
21
+ export function markTransactionClosed<State extends string = 'active'>(
22
+ tx: TransactionContext<State>,
23
+ ): ClosedTransactionContext {
24
+ const stream = typeof tx.stream === 'function' ? tx.stream : undefined;
25
+ return {
26
+ _state: 'closed',
27
+ ...(tx.dialect === undefined ? {} : { dialect: tx.dialect }),
28
+ execute: (query, opts) => tx.execute(query, opts),
29
+ ...(stream === undefined
30
+ ? {}
31
+ : {
32
+ stream: (query: CompiledQuery, opts?: ExecuteOptions) => stream.call(tx, query, opts),
33
+ }),
34
+ savepoint: <R>(fn: (ctx: ClosedTransactionContext) => Promise<R>): Promise<R> =>
35
+ tx.savepoint(innerTx => fn(markTransactionClosed(innerTx))),
36
+ };
37
+ }
38
+
39
+ export interface TxConnection {
40
+ readonly dialect?: DialectTarget;
41
+ raw(sql: string): Promise<void>;
42
+ execute(query: CompiledQuery, opts?: ExecuteOptions): Promise<readonly Record<string, unknown>[]>;
43
+ stream?(query: CompiledQuery, opts?: ExecuteOptions): AsyncIterable<Record<string, unknown>>;
44
+ }
45
+
46
+ export interface TransactionalDb {
47
+ transaction<R, State extends string = 'active'>(
48
+ fn: (tx: TransactionContext<State>) => Promise<R>,
49
+ options?: TransactionOptions,
50
+ ): Promise<R>;
51
+ }
52
+
53
+ export interface TransactionRetryPolicy {
54
+ /** Retries after the first failed attempt. The transaction body may run `maxRetries + 1` times. */
55
+ readonly maxRetries: number;
56
+ /** Exponential-backoff starting delay. Defaults to 10 ms. */
57
+ readonly baseDelayMs?: number;
58
+ /** Backoff ceiling. Defaults to 1,000 ms. */
59
+ readonly maxDelayMs?: number;
60
+ }
61
+
62
+ export interface TransactionOptions {
63
+ /**
64
+ * Explicit opt-in only: retrying re-runs the callback, including any side effects
65
+ * outside the database. Keep those effects out of a retrying transaction body.
66
+ */
67
+ readonly retry?: TransactionRetryPolicy;
68
+ }
69
+
70
+ function nonNegativeInteger(name: string, value: number): void {
71
+ if (!Number.isSafeInteger(value) || value < 0) {
72
+ throw new TypeError(`${name} must be a non-negative safe integer`);
73
+ }
74
+ }
75
+
76
+ function retryPolicy(options: TransactionOptions | undefined): Required<TransactionRetryPolicy> | undefined {
77
+ const policy = options?.retry;
78
+ if (policy === undefined) return undefined;
79
+ const baseDelayMs = policy.baseDelayMs ?? 10;
80
+ const maxDelayMs = policy.maxDelayMs ?? 1000;
81
+ nonNegativeInteger('retry.maxRetries', policy.maxRetries);
82
+ nonNegativeInteger('retry.baseDelayMs', baseDelayMs);
83
+ nonNegativeInteger('retry.maxDelayMs', maxDelayMs);
84
+ return { maxRetries: policy.maxRetries, baseDelayMs, maxDelayMs };
85
+ }
86
+
87
+ function errorCode(error: unknown): string | undefined {
88
+ if (error === null || typeof error !== 'object') return undefined;
89
+ const code = Reflect.get(error, 'code');
90
+ return typeof code === 'string' ? code : undefined;
91
+ }
92
+
93
+ function canRetry(
94
+ retryableCodes: readonly string[] | undefined,
95
+ error: unknown,
96
+ retries: number,
97
+ policy: Required<TransactionRetryPolicy> | undefined,
98
+ ): policy is Required<TransactionRetryPolicy> {
99
+ if (retryableCodes === undefined || policy === undefined || retries >= policy.maxRetries) return false;
100
+ const code = errorCode(error);
101
+ return code !== undefined && retryableCodes.includes(code);
102
+ }
103
+
104
+ function backoff(policy: Required<TransactionRetryPolicy>, retry: number): number {
105
+ return Math.min(policy.baseDelayMs * 2 ** (retry - 1), policy.maxDelayMs);
106
+ }
107
+
108
+ async function wait(milliseconds: number): Promise<void> {
109
+ if (milliseconds === 0) return;
110
+ await new Promise<void>(resolve => {
111
+ setTimeout(resolve, milliseconds);
112
+ });
113
+ }
114
+
115
+ interface ActiveTransactionStream {
116
+ close(): Promise<void>;
117
+ }
118
+
119
+ function transactionClosedError(): Error {
120
+ return new Error('cannot continue a stream after its transaction scope has closed');
121
+ }
122
+
123
+ function trackedTransactionStream(
124
+ source: AsyncIterable<Record<string, unknown>>,
125
+ active: Set<ActiveTransactionStream>,
126
+ isOpen: () => boolean,
127
+ ): AsyncIterable<Record<string, unknown>> {
128
+ let started = false;
129
+ let closed = false;
130
+ let iterator: AsyncIterator<Record<string, unknown>> | undefined;
131
+ let closing: Promise<void> | undefined;
132
+
133
+ const stream: ActiveTransactionStream = {
134
+ close(): Promise<void> {
135
+ if (closing !== undefined) return closing;
136
+ if (closed) return Promise.resolve();
137
+ closed = true;
138
+ active.delete(stream);
139
+ const current = iterator;
140
+ iterator = undefined;
141
+ closing = (async () => {
142
+ if (current?.return !== undefined) await current.return();
143
+ })();
144
+ return closing;
145
+ },
146
+ };
147
+
148
+ return {
149
+ [Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
150
+ if (started) throw new Error('transaction stream is single-shot');
151
+ if (!isOpen()) throw transactionClosedError();
152
+ started = true;
153
+ iterator = source[Symbol.asyncIterator]();
154
+ active.add(stream);
155
+
156
+ return {
157
+ async next(): Promise<IteratorResult<Record<string, unknown>>> {
158
+ if (!isOpen()) throw transactionClosedError();
159
+ const current = iterator;
160
+ if (current === undefined) return { done: true, value: undefined };
161
+ try {
162
+ const result = await current.next();
163
+ if (!isOpen()) {
164
+ await stream.close();
165
+ throw transactionClosedError();
166
+ }
167
+ if (result.done) {
168
+ closed = true;
169
+ iterator = undefined;
170
+ active.delete(stream);
171
+ }
172
+ return result;
173
+ } catch (error) {
174
+ await stream.close();
175
+ throw error;
176
+ }
177
+ },
178
+
179
+ async return(): Promise<IteratorResult<Record<string, unknown>>> {
180
+ await stream.close();
181
+ return { done: true, value: undefined };
182
+ },
183
+
184
+ async throw(error?: unknown): Promise<IteratorResult<Record<string, unknown>>> {
185
+ const current = iterator;
186
+ try {
187
+ if (current?.throw !== undefined) return await current.throw(error);
188
+ throw error;
189
+ } finally {
190
+ await stream.close();
191
+ }
192
+ },
193
+ };
194
+ },
195
+ };
196
+ }
197
+
198
+ export function createTransactionalDb(conn: TxConnection): TransactionalDb {
199
+ let savepointSeq = 0;
200
+ const retryableCodes = conn.dialect === undefined ? undefined : dialectTraits(conn.dialect).retryableCodes;
201
+
202
+ const makeContext = <State extends string = 'active'>(): {
203
+ readonly context: TransactionContext<State>;
204
+ readonly close: () => Promise<void>;
205
+ } => {
206
+ let open = true;
207
+ const activeStreams = new Set<ActiveTransactionStream>();
208
+ const connectionStream = typeof conn.stream === 'function' ? conn.stream : undefined;
209
+
210
+ const assertOpen = (): void => {
211
+ if (!open) throw transactionClosedError();
212
+ };
213
+
214
+ const close = async (): Promise<void> => {
215
+ if (!open) return;
216
+ open = false;
217
+ let firstFailure: unknown;
218
+ let failed = false;
219
+ for (const stream of activeStreams) {
220
+ try {
221
+ await stream.close();
222
+ } catch (error) {
223
+ if (!failed) firstFailure = error;
224
+ failed = true;
225
+ }
226
+ }
227
+ if (failed) throw firstFailure;
228
+ };
229
+
230
+ const context: TransactionContext<State> = {
231
+ ...(conn.dialect === undefined ? {} : { dialect: conn.dialect }),
232
+ execute: (query, opts) => {
233
+ assertOpen();
234
+ return conn.execute(query, opts);
235
+ },
236
+ ...(connectionStream === undefined
237
+ ? {}
238
+ : {
239
+ stream: (query: CompiledQuery, opts?: ExecuteOptions) => {
240
+ assertOpen();
241
+ return trackedTransactionStream(connectionStream.call(conn, query, opts), activeStreams, () => open);
242
+ },
243
+ }),
244
+ savepoint: async <R>(fn: (tx: TransactionContext<State>) => Promise<R>): Promise<R> => {
245
+ assertOpen();
246
+ const name = `s${++savepointSeq}`;
247
+ await conn.raw(`SAVEPOINT ${name}`);
248
+ const nested = makeContext<State>();
249
+ try {
250
+ const result = await fn(nested.context);
251
+ await nested.close();
252
+ await conn.raw(`RELEASE SAVEPOINT ${name}`);
253
+ return result;
254
+ } catch (error) {
255
+ let failure = error;
256
+ try {
257
+ await nested.close();
258
+ } catch (closeError) {
259
+ failure = closeError;
260
+ }
261
+ await conn.raw(`ROLLBACK TO SAVEPOINT ${name}`);
262
+ throw failure;
263
+ }
264
+ },
265
+ };
266
+
267
+ return { context, close };
268
+ };
269
+
270
+ return {
271
+ async transaction<R, State extends string = 'active'>(
272
+ fn: (tx: TransactionContext<State>) => Promise<R>,
273
+ options?: TransactionOptions,
274
+ ): Promise<R> {
275
+ const policy = retryPolicy(options);
276
+ let retries = 0;
277
+ while (true) {
278
+ savepointSeq = 0;
279
+ await conn.raw('BEGIN');
280
+ const scope = makeContext<State>();
281
+ try {
282
+ const result = await fn(scope.context);
283
+ await scope.close();
284
+ await conn.raw('COMMIT');
285
+ return result;
286
+ } catch (error) {
287
+ let failure = error;
288
+ try {
289
+ await scope.close();
290
+ } catch (closeError) {
291
+ failure = closeError;
292
+ }
293
+ await conn.raw('ROLLBACK');
294
+ if (!canRetry(retryableCodes, failure, retries, policy)) throw failure;
295
+ retries++;
296
+ await wait(backoff(policy, retries));
297
+ }
298
+ }
299
+ },
300
+ };
301
+ }
302
+
303
+ // #39 — explicit write-batching helper. Runs the given operations inside a
304
+ // single transaction / one flush: all-or-nothing. Each op receives the tx
305
+ // context and performs its own execute(s).
306
+ export function batch<R, State extends string = 'active'>(
307
+ db: TransactionalDb,
308
+ ops: readonly ((tx: TransactionContext<State>) => Promise<R>)[],
309
+ ): Promise<R[]> {
310
+ return db.transaction<R[], State>(async tx => {
311
+ const results: R[] = [];
312
+ for (const op of ops) results.push(await op(tx));
313
+ return results;
314
+ });
315
+ }
@@ -0,0 +1,59 @@
1
+ import { schemasFrom } from '@zmdb/compiler/testing';
2
+ import {
3
+ type ManyToMany,
4
+ type ManyToOne,
5
+ type OneToMany,
6
+ type OneToOne,
7
+ type Physical,
8
+ type PrimaryKey,
9
+ type References,
10
+ type Sql,
11
+ type Table,
12
+ } from '@zmdb/schema/tags';
13
+
14
+ export interface NestedUser extends Table<'nested_users'>, Physical<'app_users'> {
15
+ tenantId: number & Sql<'integer'> & PrimaryKey & Physical<'tenant_id'>;
16
+ id: number & Sql<'integer'> & PrimaryKey;
17
+ name: string & Sql<'text'>;
18
+ posts?: NestedPost[] & OneToMany<'nested_posts', 'tenantId,userId'>;
19
+ profile?: NestedProfile & OneToOne<'nested_profiles', 'tenantId,userId'>;
20
+ groups?: NestedUser[] & ManyToMany<'nested_users', 'user_groups'>;
21
+ }
22
+
23
+ export interface NestedPost extends Table<'nested_posts'>, Physical<'blog_posts'> {
24
+ tenantId: number & Sql<'integer'> & PrimaryKey & References<'nested_users.tenantId'> & Physical<'tenant_id'>;
25
+ id: number & Sql<'integer'> & PrimaryKey;
26
+ userId: number & Sql<'integer'> & References<'nested_users.id'> & Physical<'user_id'>;
27
+ title: string & Sql<'text'>;
28
+ comments?: NestedComment[] & OneToMany<'nested_comments', 'tenantId,postId'>;
29
+ }
30
+
31
+ export interface NestedComment extends Table<'nested_comments'>, Physical<'post_comments'> {
32
+ tenantId: number & Sql<'integer'> & PrimaryKey & References<'nested_users.tenantId'> & Physical<'tenant_id'>;
33
+ id: number & Sql<'integer'> & PrimaryKey;
34
+ postId: number & Sql<'integer'> & Physical<'post_id'>;
35
+ authorId: (number & Sql<'integer'> & References<'nested_users.id'> & Physical<'author_id'>) | null;
36
+ body: string & Sql<'text'>;
37
+ hidden: number & Sql<'integer'>;
38
+ author?: NestedUser & ManyToOne<'nested_users', 'tenantId,authorId'>;
39
+ }
40
+
41
+ export interface NestedProfile extends Table<'nested_profiles'>, Physical<'user_profiles'> {
42
+ tenantId: number & Sql<'integer'> & PrimaryKey & References<'nested_users.tenantId'> & Physical<'tenant_id'>;
43
+ id: number & Sql<'integer'> & PrimaryKey;
44
+ userId: number & Sql<'integer'> & References<'nested_users.id'> & Physical<'user_id'>;
45
+ bio: string & Sql<'text'>;
46
+ user?: NestedUser & ManyToOne<'nested_users', 'tenantId,userId'>;
47
+ }
48
+
49
+ export const {
50
+ NestedUser: NestedUserSchema,
51
+ NestedPost: NestedPostSchema,
52
+ NestedComment: NestedCommentSchema,
53
+ NestedProfile: NestedProfileSchema,
54
+ } = schemasFrom<{
55
+ NestedUser: NestedUser;
56
+ NestedPost: NestedPost;
57
+ NestedComment: NestedComment;
58
+ NestedProfile: NestedProfile;
59
+ }>(import.meta.url, ['NestedUser', 'NestedPost', 'NestedComment', 'NestedProfile']);