@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,256 @@
1
+ import { type DeclaredTable, type Entity, type PrimaryKeyOf } from '@zmdb/schema';
2
+ import { type Populated, type RelationKeys, type RelationPath } from '@zmdb/schema/derive';
3
+
4
+ import { type BaseRepository, type ReadOptions } from '../index.js';
5
+
6
+ /** One explicit, request-lifetime loader for a repository's primary key. */
7
+ export interface EntityLoader<T extends DeclaredTable> {
8
+ load(id: PrimaryKeyOf<T>): Promise<Entity<T> | undefined>;
9
+ }
10
+
11
+ /** The populated value attached under one declared relation name. */
12
+ export type RelationValueOf<T extends DeclaredTable, K extends RelationKeys<T>> = Populated<T, K>[K];
13
+
14
+ /** One explicit, request-lifetime loader for a declared relation. */
15
+ export interface RelationLoader<T extends DeclaredTable, K extends RelationKeys<T>> {
16
+ load(parent: Entity<T>): Promise<RelationValueOf<T, K>>;
17
+ }
18
+
19
+ export interface LoaderScope {
20
+ loaderFor<T extends DeclaredTable>(repository: BaseRepository<T>): EntityLoader<T>;
21
+ relationLoader<T extends DeclaredTable, K extends RelationKeys<T> & string>(
22
+ repository: BaseRepository<T>,
23
+ relation: K,
24
+ ): RelationLoader<T, K>;
25
+ populate<T extends DeclaredTable, K extends string>(
26
+ repository: BaseRepository<T>,
27
+ rows: readonly Entity<T>[],
28
+ paths: readonly (K & RelationPath<T, K>)[],
29
+ options?: ReadOptions,
30
+ ): Promise<readonly Populated<T, RelationPath<T, K>>[]>;
31
+ populate<T extends DeclaredTable, K extends string>(
32
+ repository: BaseRepository<T>,
33
+ row: Entity<T>,
34
+ paths: readonly (K & RelationPath<T, K>)[],
35
+ options?: ReadOptions,
36
+ ): Promise<Populated<T, RelationPath<T, K>>>;
37
+ }
38
+
39
+ interface PopulateRequest {
40
+ readonly rows: readonly object[];
41
+ readonly resolve: (rows: readonly object[]) => void;
42
+ readonly reject: (reason: unknown) => void;
43
+ }
44
+
45
+ /**
46
+ * Internal repository hooks. They are symbols so the loader can reuse the
47
+ * repository's schema, decoding, relation resolution and dialect limits without
48
+ * widening the public method surface with a second family of read operations.
49
+ */
50
+ export const LOADER_FOR_SCOPE = Symbol('zmdb.loaderForScope');
51
+ export const RELATION_LOADER_FOR_SCOPE = Symbol('zmdb.relationLoaderForScope');
52
+ export const LOADER_ENTITY_KEY = Symbol('zmdb.loaderEntityKey');
53
+ export const LOADER_ENTITY_BATCH = Symbol('zmdb.loaderEntityBatch');
54
+ export const LOADER_RELATION_KEY = Symbol('zmdb.loaderRelationKey');
55
+ export const LOADER_RELATION_BATCH = Symbol('zmdb.loaderRelationBatch');
56
+
57
+ interface BatchWaiter<Value> {
58
+ readonly resolve: (value: Value) => void;
59
+ readonly reject: (reason: unknown) => void;
60
+ }
61
+
62
+ interface BatchEntry<Input, Value> {
63
+ readonly input: Input;
64
+ readonly waiters: BatchWaiter<Value>[];
65
+ }
66
+
67
+ interface BatchLoader<Input, Value> {
68
+ load(input: Input): Promise<Value>;
69
+ }
70
+
71
+ /**
72
+ * One-microtask batching with a request-lifetime result map.
73
+ *
74
+ * `inFlight` matters for a duplicate that arrives after dispatch but before the
75
+ * driver answers: it joins the dispatched entry instead of opening a second
76
+ * query. Errors are deliberately not cached, so a later call may retry.
77
+ */
78
+ function createBatchLoader<Input, Value>(
79
+ keyOf: (input: Input) => string,
80
+ dispatch: (inputs: readonly Input[]) => Promise<readonly Value[]>,
81
+ copy: (value: Value) => Value,
82
+ ): BatchLoader<Input, Value> {
83
+ const cache = new Map<string, { readonly value: Value }>();
84
+ let pending = new Map<string, BatchEntry<Input, Value>>();
85
+ const inFlight = new Map<string, BatchEntry<Input, Value>>();
86
+ let scheduled = false;
87
+
88
+ const flush = async (): Promise<void> => {
89
+ scheduled = false;
90
+ const batch = pending;
91
+ pending = new Map();
92
+ const entries = [...batch.entries()];
93
+ for (const [key, entry] of entries) inFlight.set(key, entry);
94
+
95
+ try {
96
+ const values = await dispatch(entries.map(([, entry]) => entry.input));
97
+ if (values.length !== entries.length) {
98
+ throw new Error(`loader batch returned ${values.length} result(s) for ${entries.length} key(s)`);
99
+ }
100
+
101
+ const results = values.map(value => ({ value }));
102
+ for (let index = 0; index < entries.length; index++) {
103
+ const keyed = entries[index];
104
+ const result = results[index];
105
+ if (!keyed || !result) throw new Error(`loader batch omitted result ${index}`);
106
+ const [key, entry] = keyed;
107
+ cache.set(key, result);
108
+ for (const waiter of entry.waiters) waiter.resolve(copy(result.value));
109
+ }
110
+ } catch (error) {
111
+ for (const [, entry] of entries) {
112
+ for (const waiter of entry.waiters) waiter.reject(error);
113
+ }
114
+ } finally {
115
+ for (const [key] of entries) inFlight.delete(key);
116
+ }
117
+ };
118
+
119
+ const enqueue = (input: Input): Promise<Value> => {
120
+ const key = keyOf(input);
121
+ const cached = cache.get(key);
122
+ if (cached) return Promise.resolve(copy(cached.value));
123
+
124
+ return new Promise<Value>((resolve, reject) => {
125
+ const active = inFlight.get(key) ?? pending.get(key);
126
+ if (active) {
127
+ active.waiters.push({ resolve, reject });
128
+ return;
129
+ }
130
+
131
+ pending.set(key, { input, waiters: [{ resolve, reject }] });
132
+ if (!scheduled) {
133
+ scheduled = true;
134
+ queueMicrotask(() => void flush());
135
+ }
136
+ });
137
+ };
138
+
139
+ return {
140
+ async load(input) {
141
+ return enqueue(input);
142
+ },
143
+ };
144
+ }
145
+
146
+ function copyEntity<Row extends object>(row: Row | undefined): Row | undefined {
147
+ return row === undefined ? undefined : { ...row };
148
+ }
149
+
150
+ function copyRelation<Value extends object | readonly object[] | null>(value: Value): Value;
151
+ function copyRelation(value: object | readonly object[] | null): object | readonly object[] | null {
152
+ if (value === null) return null;
153
+ if (Array.isArray(value)) return value.map(item => ({ ...item }));
154
+ return { ...value };
155
+ }
156
+
157
+ export function createEntityLoader<T extends DeclaredTable>(repository: BaseRepository<T>): EntityLoader<T> {
158
+ return createBatchLoader(
159
+ id => repository[LOADER_ENTITY_KEY](id),
160
+ ids => repository[LOADER_ENTITY_BATCH](ids),
161
+ copyEntity,
162
+ );
163
+ }
164
+
165
+ export function createRelationLoader<T extends DeclaredTable, K extends RelationKeys<T> & string>(
166
+ repository: BaseRepository<T>,
167
+ relation: K,
168
+ ): RelationLoader<T, K> {
169
+ return createBatchLoader(
170
+ parent => repository[LOADER_RELATION_KEY](parent, relation),
171
+ parents => repository[LOADER_RELATION_BATCH](parents, relation),
172
+ copyRelation,
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Construct this at the request boundary and pass it explicitly. There is no
178
+ * default, module-global or ambient scope.
179
+ */
180
+ export function createLoaderScope(): LoaderScope {
181
+ const token = {};
182
+ const pending = new WeakMap<object, Map<string, Map<ReadOptions | undefined, PopulateRequest[]>>>();
183
+
184
+ function populate<T extends DeclaredTable, K extends string>(
185
+ repository: BaseRepository<T>,
186
+ rows: readonly Entity<T>[],
187
+ paths: readonly (K & RelationPath<T, K>)[],
188
+ options?: ReadOptions,
189
+ ): Promise<readonly Populated<T, RelationPath<T, K>>[]>;
190
+ function populate<T extends DeclaredTable, K extends string>(
191
+ repository: BaseRepository<T>,
192
+ row: Entity<T>,
193
+ paths: readonly (K & RelationPath<T, K>)[],
194
+ options?: ReadOptions,
195
+ ): Promise<Populated<T, RelationPath<T, K>>>;
196
+ function populate<T extends DeclaredTable, K extends string>(
197
+ repository: BaseRepository<T>,
198
+ rowOrRows: Entity<T> | readonly Entity<T>[],
199
+ paths: readonly (K & RelationPath<T, K>)[],
200
+ options?: ReadOptions,
201
+ ): Promise<Populated<T, RelationPath<T, K>> | readonly Populated<T, RelationPath<T, K>>[]> {
202
+ const allPaths = [...new Set(paths)].toSorted();
203
+ const canonical = allPaths.filter(path => !allPaths.some(other => other.startsWith(`${path}.`)));
204
+ const key = JSON.stringify(canonical);
205
+ let byPath = pending.get(repository);
206
+ if (byPath === undefined) {
207
+ byPath = new Map();
208
+ pending.set(repository, byPath);
209
+ }
210
+ let byOptions = byPath.get(key);
211
+ if (byOptions === undefined) {
212
+ byOptions = new Map();
213
+ byPath.set(key, byOptions);
214
+ }
215
+ const isMany = Array.isArray(rowOrRows);
216
+ return new Promise((resolve, reject) => {
217
+ const request: PopulateRequest = {
218
+ rows: isMany ? rowOrRows : [rowOrRows],
219
+ // The grouping key preserves repository identity, so every request has this T.
220
+ resolve: rows =>
221
+ resolve(
222
+ (isMany ? rows : rows[0]) as Populated<T, RelationPath<T, K>> | readonly Populated<T, RelationPath<T, K>>[],
223
+ ),
224
+ reject,
225
+ };
226
+ const existing = byOptions.get(options);
227
+ if (existing !== undefined) {
228
+ existing.push(request);
229
+ return;
230
+ }
231
+ const requests = [request];
232
+ byOptions.set(options, requests);
233
+ queueMicrotask(async () => {
234
+ byOptions.delete(options);
235
+ if (byOptions.size === 0) byPath.delete(key);
236
+ try {
237
+ const roots = requests.flatMap(entry => entry.rows) as readonly Entity<T>[];
238
+ const rows = await repository.populate<K>(roots, canonical, options);
239
+ let offset = 0;
240
+ for (const entry of requests) {
241
+ entry.resolve(rows.slice(offset, offset + entry.rows.length));
242
+ offset += entry.rows.length;
243
+ }
244
+ } catch (error) {
245
+ for (const entry of requests) entry.reject(error);
246
+ }
247
+ });
248
+ });
249
+ }
250
+
251
+ return {
252
+ loaderFor: repository => repository[LOADER_FOR_SCOPE](token),
253
+ relationLoader: (repository, relation) => repository[RELATION_LOADER_FOR_SCOPE](token, relation),
254
+ populate,
255
+ };
256
+ }
@@ -0,0 +1,409 @@
1
+ import { type CoreSchema } from '@zmdb/schema';
2
+ import { schemaFromIR } from '@zmdb/schema/ir';
3
+ import { type HasDefault, type PrimaryKey, type Sql, type Table } from '@zmdb/schema/tags';
4
+ import { createQueryCompiler, type DialectTarget } from '@zmdb/sql';
5
+
6
+ import { type Driver } from '../index.js';
7
+ import { type TransactionContext } from '../transactions/index.js';
8
+ import {
9
+ outboxCandidatesQuery,
10
+ outboxClaimQuery,
11
+ outboxMarkDeadQuery,
12
+ outboxMarkDeliveredQuery,
13
+ outboxMarkRetryQuery,
14
+ outboxReadBackQuery,
15
+ } from './sql.js';
16
+
17
+ function requiredDialect(dialect: DialectTarget | undefined, owner: string) {
18
+ if (dialect === undefined) throw new TypeError(`${owner} requires an explicit database dialect object`);
19
+ return dialect;
20
+ }
21
+
22
+ import type { OutboxStatus } from './sql.js';
23
+ export * from './sql.js';
24
+
25
+ export interface OutboxRow extends Table<'zmdb_outbox'> {
26
+ id: string & Sql<'text'> & PrimaryKey;
27
+ topic: string & Sql<'text'>;
28
+ payload: string & Sql<'text'>;
29
+ status: OutboxStatus & Sql<'jsonEnum'> & HasDefault;
30
+ attempts: number & Sql<'integer'> & HasDefault;
31
+ createdAt: Date & Sql<'timestamp'> & HasDefault;
32
+ leaseOwner: string & Sql<'text'> & HasDefault;
33
+ leaseUntil: Date & Sql<'timestamp'> & HasDefault;
34
+ deliveredAt: (Date & Sql<'timestamp'>) | null;
35
+ lastError: (string & Sql<'text'>) | null;
36
+ }
37
+
38
+ const EMPTY_COLUMN = {
39
+ primaryKey: false,
40
+ serial: false,
41
+ unique: false,
42
+ sensitive: false,
43
+ constraints: {},
44
+ rules: [],
45
+ } as const;
46
+
47
+ /**
48
+ * The built-in physical declaration applications include in schema snapshots.
49
+ *
50
+ * The public row type uses app-facing camelCase, while this value names the
51
+ * snake_case columns emitted by `outboxMigration` and used by the dispatcher.
52
+ */
53
+ export const OutboxSchema: CoreSchema<string> = schemaFromIR({
54
+ table: 'zmdb_outbox',
55
+ physicalTable: 'zmdb_outbox',
56
+ primaryKey: ['id'],
57
+ relations: [],
58
+ foreignKeys: [],
59
+ columns: [
60
+ {
61
+ ...EMPTY_COLUMN,
62
+ name: 'id',
63
+ physicalName: 'id',
64
+ sql: 'text',
65
+ nullable: false,
66
+ primaryKey: true,
67
+ hasDefault: false,
68
+ },
69
+ {
70
+ ...EMPTY_COLUMN,
71
+ name: 'topic',
72
+ physicalName: 'topic',
73
+ sql: 'text',
74
+ nullable: false,
75
+ hasDefault: false,
76
+ },
77
+ {
78
+ ...EMPTY_COLUMN,
79
+ name: 'payload',
80
+ physicalName: 'payload',
81
+ sql: 'text',
82
+ nullable: false,
83
+ hasDefault: false,
84
+ },
85
+ {
86
+ ...EMPTY_COLUMN,
87
+ name: 'status',
88
+ physicalName: 'status',
89
+ sql: 'jsonEnum',
90
+ nullable: false,
91
+ hasDefault: true,
92
+ enum: ['dead', 'delivered', 'pending'],
93
+ },
94
+ {
95
+ ...EMPTY_COLUMN,
96
+ name: 'attempts',
97
+ physicalName: 'attempts',
98
+ sql: 'integer',
99
+ nullable: false,
100
+ hasDefault: true,
101
+ },
102
+ {
103
+ ...EMPTY_COLUMN,
104
+ name: 'created_at',
105
+ physicalName: 'created_at',
106
+ sql: 'timestamp',
107
+ nullable: false,
108
+ hasDefault: true,
109
+ },
110
+ {
111
+ ...EMPTY_COLUMN,
112
+ name: 'lease_owner',
113
+ physicalName: 'lease_owner',
114
+ sql: 'text',
115
+ nullable: false,
116
+ hasDefault: true,
117
+ },
118
+ {
119
+ ...EMPTY_COLUMN,
120
+ name: 'lease_until',
121
+ physicalName: 'lease_until',
122
+ sql: 'timestamp',
123
+ nullable: false,
124
+ hasDefault: true,
125
+ },
126
+ {
127
+ ...EMPTY_COLUMN,
128
+ name: 'delivered_at',
129
+ physicalName: 'delivered_at',
130
+ sql: 'timestamp',
131
+ nullable: true,
132
+ hasDefault: false,
133
+ },
134
+ {
135
+ ...EMPTY_COLUMN,
136
+ name: 'last_error',
137
+ physicalName: 'last_error',
138
+ sql: 'text',
139
+ nullable: true,
140
+ hasDefault: false,
141
+ },
142
+ ],
143
+ });
144
+
145
+ export interface OutboxWriter {
146
+ write(topic: string, payload: string): Promise<string>;
147
+ }
148
+
149
+ export function outboxWriter(tx: TransactionContext): OutboxWriter {
150
+ const dialect = requiredDialect(tx.dialect, 'outboxWriter');
151
+ return {
152
+ async write(topic, payload) {
153
+ const id = globalThis.crypto.randomUUID();
154
+ await tx.execute(
155
+ createQueryCompiler(dialect)
156
+ .insertInto('zmdb_outbox')
157
+ .values({
158
+ id,
159
+ topic,
160
+ payload,
161
+ status: 'pending',
162
+ attempts: 0,
163
+ created_at: new Date(),
164
+ lease_owner: '',
165
+ lease_until: new Date(0),
166
+ delivered_at: null,
167
+ last_error: null,
168
+ })
169
+ .compile(),
170
+ );
171
+ return id;
172
+ },
173
+ };
174
+ }
175
+
176
+ export interface DeadOutboxRow {
177
+ readonly id: string;
178
+ readonly topic: string;
179
+ readonly payload: string;
180
+ readonly attempts: number;
181
+ readonly lastError: string | null;
182
+ }
183
+
184
+ export interface OutboxDispatcherOptions {
185
+ readonly driver: Driver;
186
+ readonly publish: (topic: string, payload: string) => Promise<void>;
187
+ readonly batch?: number;
188
+ readonly leaseMs?: number;
189
+ readonly idleMs?: number;
190
+ readonly maxIdleMs?: number;
191
+ readonly maxAttempts?: number;
192
+ readonly backoffMs?: (attempts: number) => number;
193
+ readonly onDead?: (row: DeadOutboxRow) => void | Promise<void>;
194
+ }
195
+
196
+ export interface OutboxDispatcher {
197
+ runOnce(): Promise<{ readonly claimed: number; readonly delivered: number; readonly failed: number }>;
198
+ start(): void;
199
+ onModuleInit(): void;
200
+ onShutdown(): Promise<void>;
201
+ }
202
+
203
+ interface ClaimedRow {
204
+ readonly id: string;
205
+ readonly topic: string;
206
+ readonly payload: string;
207
+ readonly attempts: number;
208
+ }
209
+
210
+ function claimedRow(row: Readonly<Record<string, unknown>>): ClaimedRow {
211
+ const { id, topic, payload, attempts } = row;
212
+ if (
213
+ typeof id !== 'string' ||
214
+ typeof topic !== 'string' ||
215
+ typeof payload !== 'string' ||
216
+ typeof attempts !== 'number' ||
217
+ !Number.isInteger(attempts) ||
218
+ attempts < 0
219
+ ) {
220
+ throw new Error('@zmdb/orm: invalid outbox row returned by the driver');
221
+ }
222
+ return { id, topic, payload, attempts };
223
+ }
224
+
225
+ function errorMessage(error: unknown): string {
226
+ return error instanceof Error ? error.message : String(error);
227
+ }
228
+
229
+ function deadRowFromInvalid(
230
+ raw: Readonly<Record<string, unknown>>,
231
+ attempts: number,
232
+ lastError: string,
233
+ ): DeadOutboxRow {
234
+ return {
235
+ id: typeof raw['id'] === 'string' ? raw['id'] : String(raw['id']),
236
+ topic: typeof raw['topic'] === 'string' ? raw['topic'] : String(raw['topic']),
237
+ payload: typeof raw['payload'] === 'string' ? raw['payload'] : String(raw['payload']),
238
+ attempts,
239
+ lastError,
240
+ };
241
+ }
242
+
243
+ function positiveInteger(name: string, value: number): number {
244
+ if (!Number.isSafeInteger(value) || value <= 0) {
245
+ throw new Error(`@zmdb/orm: ${name} must be a positive safe integer`);
246
+ }
247
+ return value;
248
+ }
249
+
250
+ const MAX_TIMER_MS = 2_147_483_647;
251
+
252
+ function timerDelay(name: string, value: number): number {
253
+ const delay = positiveInteger(name, value);
254
+ if (delay > MAX_TIMER_MS) {
255
+ throw new Error(`@zmdb/orm: ${name} must not exceed ${MAX_TIMER_MS}`);
256
+ }
257
+ return delay;
258
+ }
259
+
260
+ export function createOutboxDispatcher(options: OutboxDispatcherOptions): OutboxDispatcher {
261
+ const dialect = requiredDialect(options.driver.dialect, 'createOutboxDispatcher');
262
+ const batch = positiveInteger('batch', options.batch ?? 100);
263
+ const leaseMs = positiveInteger('leaseMs', options.leaseMs ?? 30_000);
264
+ const idleMs = timerDelay('idleMs', options.idleMs ?? 1_000);
265
+ const maxIdleMs = timerDelay('maxIdleMs', options.maxIdleMs ?? 30_000);
266
+ const maxAttempts = positiveInteger('maxAttempts', options.maxAttempts ?? 10);
267
+ if (maxIdleMs < idleMs) {
268
+ throw new Error('@zmdb/orm: maxIdleMs must be greater than or equal to idleMs');
269
+ }
270
+ const backoffMs = options.backoffMs ?? (attempts => Math.min(2 ** attempts * 1_000, 300_000));
271
+
272
+ let stopped = false;
273
+ let started = false;
274
+ let timer: ReturnType<typeof setTimeout> | undefined;
275
+ const activePasses = new Set<
276
+ Promise<{ readonly claimed: number; readonly delivered: number; readonly failed: number }>
277
+ >();
278
+ let nextIdleMs = idleMs;
279
+
280
+ const runPass = async () => {
281
+ const now = new Date();
282
+ const candidates = await options.driver.execute(outboxCandidatesQuery(dialect, { now, batch }));
283
+ const ids = candidates.flatMap(row => (typeof row['id'] === 'string' ? [row['id']] : []));
284
+ if (ids.length === 0) return { claimed: 0, delivered: 0, failed: 0 } as const;
285
+
286
+ const token = globalThis.crypto.randomUUID();
287
+ const leaseUntil = new Date(now.getTime() + leaseMs);
288
+ await options.driver.execute(outboxClaimQuery(dialect, { now, token, leaseUntil, ids }));
289
+ const candidateOrder = new Map(ids.map((id, index) => [id, index]));
290
+ const rows = (await options.driver.execute(outboxReadBackQuery(dialect, { token }))).toSorted((left, right) => {
291
+ const leftIndex = typeof left['id'] === 'string' ? candidateOrder.get(left['id']) : undefined;
292
+ const rightIndex = typeof right['id'] === 'string' ? candidateOrder.get(right['id']) : undefined;
293
+ return (leftIndex ?? ids.length) - (rightIndex ?? ids.length);
294
+ });
295
+
296
+ let delivered = 0;
297
+ let failed = 0;
298
+ for (const raw of rows) {
299
+ let row: ClaimedRow;
300
+ try {
301
+ row = claimedRow(raw);
302
+ } catch (error) {
303
+ failed += 1;
304
+ const id = raw['id'];
305
+ const previousAttempts = raw['attempts'];
306
+ if (typeof id !== 'string' || typeof previousAttempts !== 'number' || !Number.isInteger(previousAttempts)) {
307
+ throw error;
308
+ }
309
+ const attempts = previousAttempts + 1;
310
+ const lastError = errorMessage(error);
311
+ await options.driver.execute(outboxMarkDeadQuery(dialect, { id, token, attempts, lastError }));
312
+ await options.onDead?.(deadRowFromInvalid(raw, attempts, lastError));
313
+ continue;
314
+ }
315
+ const attempts = row.attempts + 1;
316
+ let publishError: unknown;
317
+ try {
318
+ await options.publish(row.topic, row.payload);
319
+ } catch (error) {
320
+ publishError = error;
321
+ }
322
+ if (publishError === undefined) {
323
+ await options.driver.execute(
324
+ outboxMarkDeliveredQuery(dialect, {
325
+ id: row.id,
326
+ token,
327
+ deliveredAt: new Date(),
328
+ attempts,
329
+ }),
330
+ );
331
+ delivered += 1;
332
+ } else {
333
+ failed += 1;
334
+ const lastError = errorMessage(publishError);
335
+ if (attempts >= maxAttempts) {
336
+ await options.driver.execute(outboxMarkDeadQuery(dialect, { id: row.id, token, attempts, lastError }));
337
+ await options.onDead?.({ ...row, attempts, lastError });
338
+ } else {
339
+ const delay = backoffMs(attempts);
340
+ if (!Number.isFinite(delay) || delay < 0) {
341
+ throw new Error('@zmdb/orm: backoffMs must return a finite non-negative number');
342
+ }
343
+ await options.driver.execute(
344
+ outboxMarkRetryQuery(dialect, {
345
+ id: row.id,
346
+ token,
347
+ attempts,
348
+ lastError,
349
+ leaseUntil: new Date(Date.now() + delay),
350
+ }),
351
+ );
352
+ }
353
+ }
354
+ }
355
+ return { claimed: rows.length, delivered, failed };
356
+ };
357
+
358
+ const runOnce = (): Promise<{ readonly claimed: number; readonly delivered: number; readonly failed: number }> => {
359
+ if (stopped) return Promise.resolve({ claimed: 0, delivered: 0, failed: 0 });
360
+ const pass = runPass();
361
+ activePasses.add(pass);
362
+ void pass.then(
363
+ () => activePasses.delete(pass),
364
+ () => activePasses.delete(pass),
365
+ );
366
+ return pass;
367
+ };
368
+
369
+ const schedule = (delay: number): void => {
370
+ timer = setTimeout(() => {
371
+ if (stopped) return;
372
+ const pass = runOnce();
373
+ void pass.then(
374
+ report => {
375
+ if (stopped) return;
376
+ if (report.claimed > 0) {
377
+ nextIdleMs = idleMs;
378
+ schedule(report.claimed >= batch ? 0 : nextIdleMs);
379
+ } else {
380
+ nextIdleMs = Math.min(nextIdleMs * 2, maxIdleMs);
381
+ schedule(nextIdleMs);
382
+ }
383
+ },
384
+ () => {
385
+ if (!stopped) schedule(nextIdleMs);
386
+ },
387
+ );
388
+ }, delay);
389
+ };
390
+
391
+ const start = (): void => {
392
+ if (started || stopped) return;
393
+ started = true;
394
+ schedule(idleMs);
395
+ };
396
+
397
+ return {
398
+ runOnce,
399
+ start,
400
+ onModuleInit: start,
401
+ async onShutdown() {
402
+ stopped = true;
403
+ if (timer !== undefined) clearTimeout(timer);
404
+ while (activePasses.size > 0) {
405
+ await Promise.allSettled(activePasses);
406
+ }
407
+ },
408
+ };
409
+ }