@stonyx/orm 0.3.2-beta.9 → 0.3.2-beta.90

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 (43) hide show
  1. package/README.md +35 -2
  2. package/config/environment.js +99 -12
  3. package/dist/commands.js +34 -0
  4. package/dist/dynamodb/connection.d.ts +31 -0
  5. package/dist/dynamodb/connection.js +28 -0
  6. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  7. package/dist/dynamodb/dynamodb-db.js +596 -0
  8. package/dist/dynamodb/operation-builder.d.ts +76 -0
  9. package/dist/dynamodb/operation-builder.js +116 -0
  10. package/dist/dynamodb/type-map.d.ts +31 -0
  11. package/dist/dynamodb/type-map.js +48 -0
  12. package/dist/main.js +10 -0
  13. package/dist/manage-record.js +34 -3
  14. package/dist/mysql/mysql-db.d.ts +8 -0
  15. package/dist/mysql/mysql-db.js +22 -8
  16. package/dist/orm-request.js +7 -6
  17. package/dist/postgres/connection.d.ts +1 -0
  18. package/dist/postgres/connection.js +8 -6
  19. package/dist/postgres/postgres-db.d.ts +8 -0
  20. package/dist/postgres/postgres-db.js +22 -8
  21. package/dist/relationships.js +1 -1
  22. package/dist/serializer.js +38 -2
  23. package/dist/store.d.ts +13 -1
  24. package/dist/store.js +64 -4
  25. package/dist/types/orm-types.d.ts +9 -0
  26. package/package.json +16 -7
  27. package/src/commands.ts +43 -0
  28. package/src/dynamodb/connection.ts +50 -0
  29. package/src/dynamodb/dynamodb-db.ts +811 -0
  30. package/src/dynamodb/operation-builder.ts +202 -0
  31. package/src/dynamodb/type-map.ts +54 -0
  32. package/src/main.ts +10 -0
  33. package/src/manage-record.ts +41 -9
  34. package/src/mysql/mysql-db.ts +24 -8
  35. package/src/orm-request.ts +8 -5
  36. package/src/postgres/connection.ts +10 -6
  37. package/src/postgres/postgres-db.ts +24 -8
  38. package/src/relationships.ts +1 -1
  39. package/src/serializer.ts +39 -2
  40. package/src/store.ts +67 -4
  41. package/src/types/orm-types.ts +10 -0
  42. package/src/types/stonyx.d.ts +7 -1
  43. package/config/environment.ts +0 -91
@@ -0,0 +1,811 @@
1
+ /**
2
+ * DynamoDB driver implementing the SqlDb PAL contract.
3
+ *
4
+ * Drop-in replacement for PostgresDB / MysqlDB — zero ORM core changes.
5
+ * Selected via config.orm.dynamodb.
6
+ */
7
+
8
+ import { createDocumentClient, destroyDocumentClient } from './connection.js';
9
+ import type { DocumentClient, DynamoDBConfig } from './connection.js';
10
+ import {
11
+ buildPutItem,
12
+ buildGetItem,
13
+ buildUpdateItem,
14
+ buildDeleteItem,
15
+ buildScan,
16
+ buildQuery,
17
+ } from './operation-builder.js';
18
+ import { introspectModels, getTopologicalOrder } from '../postgres/schema-introspector.js';
19
+ import { getDynamoKeyType } from './type-map.js';
20
+ import { store } from '@stonyx/orm';
21
+ import { createRecord } from '../manage-record.js';
22
+ import { getPluralName } from '../plural-registry.js';
23
+ import { sanitizeTableName } from '../schema-helpers.js';
24
+ import config from 'stonyx/config';
25
+ import log from 'stonyx/log';
26
+ import type { ModelSchema, OrmRecord } from '../types/orm-types.js';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // ULID — monotonic, inline implementation (avoids heavy dep)
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
33
+
34
+ function generateUlid(): string {
35
+ const now = Date.now();
36
+ let id = '';
37
+
38
+ // 10-char timestamp (48-bit millisecond precision)
39
+ let t = now;
40
+ for (let i = 9; i >= 0; i--) {
41
+ id = CROCKFORD[t % 32] + id;
42
+ t = Math.floor(t / 32);
43
+ }
44
+
45
+ // 16-char random
46
+ for (let i = 0; i < 16; i++) {
47
+ id += CROCKFORD[Math.floor(Math.random() * 32)];
48
+ }
49
+
50
+ return id;
51
+ }
52
+
53
+ /**
54
+ * Generates a monotonically unique numeric ID for DynamoDB tables with numeric keys.
55
+ * Uses timestamp-based generation with a sub-millisecond counter to ensure uniqueness.
56
+ */
57
+ let _numericIdCounter = 0;
58
+ let _numericIdLastMs = 0;
59
+
60
+ function generateNumericId(): number {
61
+ const now = Date.now();
62
+ if (now === _numericIdLastMs) {
63
+ _numericIdCounter++;
64
+ } else {
65
+ _numericIdLastMs = now;
66
+ _numericIdCounter = 0;
67
+ }
68
+ return now * 1000 + _numericIdCounter;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // SDK Command factories (injectable for testing without real AWS SDK)
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Load the DynamoDB DocumentClient command constructors via dynamic import.
77
+ * Returns a frozen object so it can be cached in deps.
78
+ */
79
+ export async function loadDocClientCommands(): Promise<{
80
+ PutCommand: new (params: unknown) => unknown;
81
+ GetCommand: new (params: unknown) => unknown;
82
+ UpdateCommand: new (params: unknown) => unknown;
83
+ DeleteCommand: new (params: unknown) => unknown;
84
+ ScanCommand: new (params: unknown) => unknown;
85
+ QueryCommand: new (params: unknown) => unknown;
86
+ }> {
87
+ return import('@aws-sdk/lib-dynamodb' as string) as Promise<{
88
+ PutCommand: new (params: unknown) => unknown;
89
+ GetCommand: new (params: unknown) => unknown;
90
+ UpdateCommand: new (params: unknown) => unknown;
91
+ DeleteCommand: new (params: unknown) => unknown;
92
+ ScanCommand: new (params: unknown) => unknown;
93
+ QueryCommand: new (params: unknown) => unknown;
94
+ }>;
95
+ }
96
+
97
+ export async function loadTableCommands(): Promise<{
98
+ DynamoDBClient: new (opts: unknown) => { send(cmd: unknown): Promise<unknown> };
99
+ DescribeTableCommand: new (params: unknown) => unknown;
100
+ CreateTableCommand: new (params: unknown) => unknown;
101
+ UpdateTableCommand: new (params: unknown) => unknown;
102
+ }> {
103
+ return import('@aws-sdk/client-dynamodb' as string) as Promise<{
104
+ DynamoDBClient: new (opts: unknown) => { send(cmd: unknown): Promise<unknown> };
105
+ DescribeTableCommand: new (params: unknown) => unknown;
106
+ CreateTableCommand: new (params: unknown) => unknown;
107
+ UpdateTableCommand: new (params: unknown) => unknown;
108
+ }>;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Types
113
+ // ---------------------------------------------------------------------------
114
+
115
+ interface PersistContext {
116
+ record?: OrmRecord;
117
+ recordId?: unknown;
118
+ oldState?: Record<string, unknown>;
119
+ rawData?: Record<string, unknown>;
120
+ }
121
+
122
+ interface PersistResponse {
123
+ data?: { id?: unknown };
124
+ }
125
+
126
+ /** Minimal Orm module shape needed at runtime — avoids circular import at top-level. */
127
+ interface OrmModule {
128
+ default: {
129
+ instance: {
130
+ getRecordClasses(name: string): { modelClass: { memory?: boolean } };
131
+ isView?(name: string): boolean;
132
+ };
133
+ };
134
+ }
135
+
136
+ export interface DynamoDBDeps {
137
+ createDocumentClient: typeof createDocumentClient;
138
+ destroyDocumentClient: typeof destroyDocumentClient;
139
+ loadDocClientCommands: typeof loadDocClientCommands;
140
+ loadTableCommands: typeof loadTableCommands;
141
+ buildPutItem: typeof buildPutItem;
142
+ buildGetItem: typeof buildGetItem;
143
+ buildUpdateItem: typeof buildUpdateItem;
144
+ buildDeleteItem: typeof buildDeleteItem;
145
+ buildScan: typeof buildScan;
146
+ buildQuery: typeof buildQuery;
147
+ introspectModels: typeof introspectModels;
148
+ getTopologicalOrder: typeof getTopologicalOrder;
149
+ getDynamoKeyType: typeof getDynamoKeyType;
150
+ createRecord: typeof createRecord;
151
+ store: typeof store;
152
+ getPluralName: typeof getPluralName;
153
+ config: typeof config;
154
+ log: typeof log;
155
+ /** Injected for testing — import('@stonyx/orm') replacement */
156
+ _importOrm?: () => Promise<OrmModule>;
157
+ [key: string]: unknown;
158
+ }
159
+
160
+ const defaultDeps: DynamoDBDeps = {
161
+ createDocumentClient,
162
+ destroyDocumentClient,
163
+ loadDocClientCommands,
164
+ loadTableCommands,
165
+ buildPutItem,
166
+ buildGetItem,
167
+ buildUpdateItem,
168
+ buildDeleteItem,
169
+ buildScan,
170
+ buildQuery,
171
+ introspectModels,
172
+ getTopologicalOrder,
173
+ getDynamoKeyType,
174
+ createRecord,
175
+ store,
176
+ getPluralName,
177
+ config,
178
+ log,
179
+ };
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // GSI registry type
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /** modelName → attrName → gsiName */
186
+ type GsiRegistry = Map<string, Map<string, string>>;
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // DynamoDB driver
190
+ // ---------------------------------------------------------------------------
191
+
192
+ export default class DynamoDBDB {
193
+ static instance: DynamoDBDB | undefined;
194
+
195
+ deps!: DynamoDBDeps;
196
+ client!: DocumentClient | null;
197
+ dbConfig!: DynamoDBConfig;
198
+
199
+ /** GSI registry built during init from model introspection. */
200
+ private _gsiRegistry: GsiRegistry = new Map();
201
+
202
+ constructor(deps: Partial<DynamoDBDeps> = {}) {
203
+ const Ctor = this.constructor as typeof DynamoDBDB;
204
+ if (Ctor.instance) return Ctor.instance;
205
+ Ctor.instance = this;
206
+
207
+ this.deps = { ...defaultDeps, ...deps } as DynamoDBDeps;
208
+ this.client = null;
209
+
210
+ const dynamoConfig = this.deps.config.orm.dynamodb;
211
+ if (!dynamoConfig) throw new Error('DynamoDB configuration (config.orm.dynamodb) is required');
212
+ this.dbConfig = dynamoConfig as DynamoDBConfig;
213
+ }
214
+
215
+ private requireClient(): DocumentClient {
216
+ if (!this.client) throw new Error('DynamoDBDB client not initialized — call init() first');
217
+ return this.client;
218
+ }
219
+
220
+ private _resolveTableName(modelName: string): string {
221
+ return (this.dbConfig.tablePrefix ?? '') + sanitizeTableName(this.deps.getPluralName(modelName));
222
+ }
223
+
224
+ /** Resolve Orm singleton — falls back to real import in production. */
225
+ private async _getOrm(): Promise<OrmModule> {
226
+ if (this.deps._importOrm) return this.deps._importOrm();
227
+ return import('@stonyx/orm') as unknown as Promise<OrmModule>;
228
+ }
229
+
230
+ // -------------------------------------------------------------------------
231
+ // SqlDb contract — init
232
+ // -------------------------------------------------------------------------
233
+
234
+ async init(): Promise<void> {
235
+ this.client = await this.deps.createDocumentClient(this.dbConfig);
236
+ this._buildGsiRegistry();
237
+ await this.loadMemoryRecords();
238
+ }
239
+
240
+ // -------------------------------------------------------------------------
241
+ // SqlDb contract — startup (table provisioning)
242
+ // -------------------------------------------------------------------------
243
+
244
+ /**
245
+ * For each model, DescribeTable — CreateTable if missing (with GSIs, PAY_PER_REQUEST).
246
+ * For existing tables, check for missing GSIs and UpdateTable + poll for ACTIVE.
247
+ */
248
+ async startup(): Promise<void> {
249
+ const schemas = this.deps.introspectModels();
250
+ const { DynamoDBClient, DescribeTableCommand, CreateTableCommand, UpdateTableCommand } =
251
+ await this.deps.loadTableCommands();
252
+
253
+ const clientOptions: Record<string, unknown> = {};
254
+ if (this.dbConfig.region) clientOptions.region = this.dbConfig.region;
255
+ if (this.dbConfig.endpoint) clientOptions.endpoint = this.dbConfig.endpoint;
256
+
257
+ const rawClient = new DynamoDBClient(clientOptions);
258
+
259
+ for (const [modelName, schema] of Object.entries(schemas)) {
260
+ const tableName = this._resolveTableName(modelName);
261
+ const gsis = this._buildGsiDefinitions(modelName, schema);
262
+
263
+ try {
264
+ const desc = await rawClient.send(new DescribeTableCommand({ TableName: tableName })) as {
265
+ Table?: { TableStatus?: string; GlobalSecondaryIndexes?: Array<{ IndexName?: string }> };
266
+ };
267
+
268
+ // Table exists — check for missing GSIs
269
+ const existingGsiNames = new Set(
270
+ (desc.Table?.GlobalSecondaryIndexes ?? []).map((g: { IndexName?: string }) => g.IndexName ?? '')
271
+ );
272
+
273
+ for (const gsi of gsis) {
274
+ const gsiName = (gsi as { IndexName?: string }).IndexName;
275
+ if (gsiName && !existingGsiNames.has(gsiName)) {
276
+ this.deps.log.db?.(`Adding missing GSI '${gsiName}' to table '${tableName}'`);
277
+
278
+ await rawClient.send(new UpdateTableCommand({
279
+ TableName: tableName,
280
+ GlobalSecondaryIndexUpdates: [{ Create: gsi }],
281
+ AttributeDefinitions: this._buildAttributeDefinitions(schema),
282
+ }));
283
+
284
+ await this._waitForTableActive(rawClient, tableName, DescribeTableCommand);
285
+ }
286
+ }
287
+
288
+ this.deps.log.db?.(`DynamoDB table '${tableName}' is ready`);
289
+ } catch (err: unknown) {
290
+ const code = (err as { name?: string }).name;
291
+ if (code !== 'ResourceNotFoundException') throw err;
292
+
293
+ // Table does not exist — create it
294
+ this.deps.log.db?.(`Creating DynamoDB table '${tableName}'`);
295
+
296
+ await rawClient.send(new CreateTableCommand({
297
+ TableName: tableName,
298
+ BillingMode: 'PAY_PER_REQUEST',
299
+ AttributeDefinitions: this._buildAttributeDefinitions(schema),
300
+ KeySchema: [{ AttributeName: 'id', KeyType: 'HASH' }],
301
+ ...(gsis.length > 0 ? { GlobalSecondaryIndexes: gsis } : {}),
302
+ }));
303
+
304
+ await this._waitForTableActive(rawClient, tableName, DescribeTableCommand);
305
+ this.deps.log.db?.(`Created DynamoDB table '${tableName}'`);
306
+ }
307
+ }
308
+ }
309
+
310
+ // -------------------------------------------------------------------------
311
+ // SqlDb contract — shutdown
312
+ // -------------------------------------------------------------------------
313
+
314
+ async shutdown(): Promise<void> {
315
+ this.client = this.deps.destroyDocumentClient(this.client);
316
+ }
317
+
318
+ // -------------------------------------------------------------------------
319
+ // SqlDb contract — persist
320
+ // -------------------------------------------------------------------------
321
+
322
+ /**
323
+ * DynamoDB does NOT use write serialization (#156).
324
+ *
325
+ * Unlike MySQL/PostgreSQL, DynamoDB has no server-side foreign key
326
+ * constraints and no multi-row transactions in standard single-item
327
+ * operations (PutItem, UpdateItem, DeleteItem). Each operation is
328
+ * atomic at the item level and cannot deadlock against other items.
329
+ * Concurrent fire-and-forget writes therefore cannot produce the
330
+ * cross-row lock contention that causes InnoDB/PG deadlocks.
331
+ */
332
+ async persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
333
+ const OrmModule = await this._getOrm();
334
+ if (OrmModule.default?.instance?.isView?.(modelName)) return;
335
+
336
+ switch (operation) {
337
+ case 'create':
338
+ return this._persistCreate(modelName, context, response);
339
+ case 'update':
340
+ return this._persistUpdate(modelName, context);
341
+ case 'delete':
342
+ return this._persistDelete(modelName, context);
343
+ }
344
+ }
345
+
346
+ // -------------------------------------------------------------------------
347
+ // SqlDb contract — findRecord
348
+ // -------------------------------------------------------------------------
349
+
350
+ async findRecord(modelName: string, id: unknown): Promise<OrmRecord | undefined> {
351
+ const schemas = this.deps.introspectModels();
352
+ const schema = schemas[modelName];
353
+ if (!schema) return undefined;
354
+
355
+ const tableName = this._resolveTableName(modelName);
356
+ const { GetCommand } = await this.deps.loadDocClientCommands();
357
+
358
+ const params = this.deps.buildGetItem(tableName, { id });
359
+
360
+ try {
361
+ const result = await this.requireClient().send(new GetCommand(params)) as {
362
+ Item?: Record<string, unknown>;
363
+ };
364
+
365
+ if (!result.Item) return undefined;
366
+
367
+ const rawData = this._itemToRawData(result.Item, schema);
368
+ const record = this.deps.createRecord(modelName, rawData, {
369
+ isDbRecord: true, serialize: false, transform: false,
370
+ }) as unknown as OrmRecord;
371
+
372
+ this._evictIfNotMemory(modelName, record);
373
+ return record;
374
+ } catch (err: unknown) {
375
+ if ((err as { name?: string }).name === 'ResourceNotFoundException') return undefined;
376
+ throw err;
377
+ }
378
+ }
379
+
380
+ // -------------------------------------------------------------------------
381
+ // SqlDb contract — findAll
382
+ // -------------------------------------------------------------------------
383
+
384
+ async findAll(modelName: string, conditions?: Record<string, unknown>): Promise<OrmRecord[]> {
385
+ const schemas = this.deps.introspectModels();
386
+ const schema = schemas[modelName];
387
+ if (!schema) return [];
388
+
389
+ const tableName = this._resolveTableName(modelName);
390
+
391
+ try {
392
+ let items: Record<string, unknown>[];
393
+
394
+ if (!conditions || Object.keys(conditions).length === 0) {
395
+ items = await this._paginatedScan(tableName);
396
+ } else {
397
+ // Try to route through a GSI if one matches a condition key
398
+ const gsiMatch = this._findGsiMatch(modelName, conditions);
399
+
400
+ if (gsiMatch) {
401
+ const { indexName, keyConditions, remainingConditions } = gsiMatch;
402
+ items = await this._paginatedQuery(tableName, indexName, keyConditions);
403
+
404
+ // Filter remaining non-key conditions in memory
405
+ if (Object.keys(remainingConditions).length > 0) {
406
+ items = items.filter(item =>
407
+ Object.entries(remainingConditions).every(([k, v]) => item[k] === v)
408
+ );
409
+ }
410
+ } else {
411
+ // No GSI — fall back to Scan + FilterExpression (warn)
412
+ this.deps.log.warn?.(
413
+ `[DynamoDB] findAll('${modelName}') using Scan+FilterExpression — no GSI for conditions: ${JSON.stringify(conditions)}. Add a GSI index for better performance.`
414
+ );
415
+ items = await this._paginatedScan(tableName, conditions);
416
+ }
417
+ }
418
+
419
+ const records = items.map(item => {
420
+ const rawData = this._itemToRawData(item, schema);
421
+ return this.deps.createRecord(modelName, rawData, {
422
+ isDbRecord: true, serialize: false, transform: false,
423
+ }) as unknown as OrmRecord;
424
+ });
425
+
426
+ for (const record of records) {
427
+ this._evictIfNotMemory(modelName, record);
428
+ }
429
+
430
+ return records;
431
+ } catch (err: unknown) {
432
+ if ((err as { name?: string }).name === 'ResourceNotFoundException') return [];
433
+ throw err;
434
+ }
435
+ }
436
+
437
+ // -------------------------------------------------------------------------
438
+ // loadMemoryRecords
439
+ // -------------------------------------------------------------------------
440
+
441
+ async loadMemoryRecords(): Promise<void> {
442
+ const schemas = this.deps.introspectModels();
443
+ const order = this.deps.getTopologicalOrder(schemas);
444
+ const OrmModule = await this._getOrm();
445
+ const Orm = OrmModule.default;
446
+
447
+ for (const modelName of order) {
448
+ const { modelClass } = Orm.instance.getRecordClasses(modelName);
449
+
450
+ if (modelClass?.memory === false) {
451
+ this.deps.log.db?.(`Skipping memory load for '${modelName}' (memory: false)`);
452
+ continue;
453
+ }
454
+
455
+ const schema = schemas[modelName];
456
+ const tableName = this._resolveTableName(modelName);
457
+
458
+ try {
459
+ const items = await this._paginatedScan(tableName);
460
+
461
+ for (const item of items) {
462
+ const rawData = this._itemToRawData(item, schema);
463
+ this.deps.createRecord(modelName, rawData, {
464
+ isDbRecord: true, serialize: false, transform: false,
465
+ });
466
+ }
467
+ } catch (err: unknown) {
468
+ if ((err as { name?: string }).name === 'ResourceNotFoundException') {
469
+ this.deps.log.db?.(`Table '${tableName}' does not exist yet. Skipping load for '${modelName}'.`);
470
+ continue;
471
+ }
472
+ throw err;
473
+ }
474
+ }
475
+ }
476
+
477
+ // -------------------------------------------------------------------------
478
+ // Private — persist helpers
479
+ // -------------------------------------------------------------------------
480
+
481
+ private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
482
+ const schemas = this.deps.introspectModels();
483
+ const schema = schemas[modelName];
484
+ if (!schema) return;
485
+
486
+ const recordId = response?.data?.id;
487
+ const storeRef = this.deps.store as unknown as { get(name: string, id: unknown): OrmRecord | null };
488
+ const record = recordId != null ? storeRef.get(modelName, recordId) : null;
489
+ if (!record) return;
490
+
491
+ const isPendingId = context.rawData?.__pendingSqlId === true;
492
+ const tableName = this._resolveTableName(modelName);
493
+
494
+ // For models with a pending ID, generate a unique replacement ID
495
+ let finalId: unknown = record.id;
496
+ if (isPendingId) {
497
+ const keyType = this.deps.getDynamoKeyType(schema.idType);
498
+ finalId = keyType === 'N' ? generateNumericId() : generateUlid();
499
+ }
500
+
501
+ const item = this._recordToItem(record, schema, context.rawData);
502
+ item.id = finalId;
503
+
504
+ const { PutCommand } = await this.deps.loadDocClientCommands();
505
+ const params = this.deps.buildPutItem(tableName, item, 'attribute_not_exists(id)');
506
+ await this.requireClient().send(new PutCommand(params));
507
+
508
+ // Re-key the store record if we generated a new ID
509
+ if (isPendingId) {
510
+ const pendingId = record.id;
511
+ const modelStoreMap = (this.deps.store as unknown as { get(name: string): Map<unknown, unknown> }).get(modelName);
512
+ modelStoreMap.delete(pendingId);
513
+ record.__data.id = finalId as string | number;
514
+ record.id = finalId as string | number;
515
+ modelStoreMap.set(finalId as string | number, record);
516
+
517
+ if (response?.data) response.data.id = finalId;
518
+ delete record.__data.__pendingSqlId;
519
+ }
520
+ }
521
+
522
+ private async _persistUpdate(modelName: string, context: PersistContext): Promise<void> {
523
+ const schemas = this.deps.introspectModels();
524
+ const schema = schemas[modelName];
525
+ if (!schema) return;
526
+
527
+ const record = context.record;
528
+ if (!record) return;
529
+
530
+ const tableName = this._resolveTableName(modelName);
531
+ const id = record.id;
532
+ const oldState = context.oldState || {};
533
+ const currentData = record.__data;
534
+
535
+ // Build diff of changed columns
536
+ const changedData: Record<string, unknown> = {};
537
+
538
+ for (const col of Object.keys(schema.columns)) {
539
+ if (currentData[col] !== oldState[col]) {
540
+ const value = currentData[col] ?? null;
541
+ // Date objects must be serialized to ISO-8601 strings for DynamoDB 'S' storage
542
+ changedData[col] = (value instanceof Date)
543
+ ? value.toISOString()
544
+ : value;
545
+ }
546
+ }
547
+
548
+ // FK changes
549
+ for (const fkCol of Object.keys(schema.foreignKeys)) {
550
+ const relName = fkCol.replace(/_id$/, '');
551
+ const relValue = record.__relationships[relName];
552
+ const currentFkValue = relValue && typeof relValue === 'object' && relValue !== null
553
+ ? (relValue as { id: unknown }).id ?? null
554
+ : relValue ?? record.__data[relName] ?? null;
555
+ const oldFkValue = oldState[relName] ?? null;
556
+
557
+ if (currentFkValue !== oldFkValue) changedData[fkCol] = currentFkValue;
558
+ }
559
+
560
+ if (Object.keys(changedData).length === 0) return;
561
+
562
+ const { UpdateCommand } = await this.deps.loadDocClientCommands();
563
+ const params = this.deps.buildUpdateItem(tableName, { id }, changedData);
564
+ await this.requireClient().send(new UpdateCommand(params));
565
+ }
566
+
567
+ private async _persistDelete(modelName: string, context: PersistContext): Promise<void> {
568
+ const schemas = this.deps.introspectModels();
569
+ const schema = schemas[modelName];
570
+ if (!schema) return;
571
+
572
+ const id = context.recordId;
573
+ if (id == null) return;
574
+
575
+ const tableName = this._resolveTableName(modelName);
576
+ const { DeleteCommand } = await this.deps.loadDocClientCommands();
577
+ const params = this.deps.buildDeleteItem(tableName, { id });
578
+ await this.requireClient().send(new DeleteCommand(params));
579
+ }
580
+
581
+ // -------------------------------------------------------------------------
582
+ // Private — pagination helpers
583
+ // -------------------------------------------------------------------------
584
+
585
+ private async _paginatedScan(tableName: string, conditions?: Record<string, unknown>): Promise<Record<string, unknown>[]> {
586
+ const { ScanCommand } = await this.deps.loadDocClientCommands();
587
+ const items: Record<string, unknown>[] = [];
588
+ let lastKey: Record<string, unknown> | undefined;
589
+
590
+ do {
591
+ const params = this.deps.buildScan(tableName, conditions, lastKey);
592
+ const result = await this.requireClient().send(new ScanCommand(params)) as {
593
+ Items?: Record<string, unknown>[];
594
+ LastEvaluatedKey?: Record<string, unknown>;
595
+ };
596
+
597
+ if (result.Items) items.push(...result.Items);
598
+ lastKey = result.LastEvaluatedKey;
599
+ } while (lastKey);
600
+
601
+ return items;
602
+ }
603
+
604
+ private async _paginatedQuery(tableName: string, indexName: string, keyConditions: Record<string, unknown>): Promise<Record<string, unknown>[]> {
605
+ const { QueryCommand } = await this.deps.loadDocClientCommands();
606
+ const items: Record<string, unknown>[] = [];
607
+ let lastKey: Record<string, unknown> | undefined;
608
+
609
+ do {
610
+ const params = this.deps.buildQuery(tableName, indexName, keyConditions, lastKey);
611
+ const result = await this.requireClient().send(new QueryCommand(params)) as {
612
+ Items?: Record<string, unknown>[];
613
+ LastEvaluatedKey?: Record<string, unknown>;
614
+ };
615
+
616
+ if (result.Items) items.push(...result.Items);
617
+ lastKey = result.LastEvaluatedKey;
618
+ } while (lastKey);
619
+
620
+ return items;
621
+ }
622
+
623
+ // -------------------------------------------------------------------------
624
+ // Private — GSI registry
625
+ // -------------------------------------------------------------------------
626
+
627
+ /**
628
+ * Build the GSI registry from model introspection.
629
+ * Registry: modelName → attrName → gsiName
630
+ *
631
+ * FK columns (belonging to belongsTo relationships) get a GSI automatically.
632
+ */
633
+ private _buildGsiRegistry(): void {
634
+ const schemas = this.deps.introspectModels();
635
+
636
+ for (const [modelName, schema] of Object.entries(schemas)) {
637
+ const tableName = this._resolveTableName(modelName);
638
+ const modelGsis = new Map<string, string>();
639
+
640
+ for (const fkCol of Object.keys(schema.foreignKeys)) {
641
+ const gsiName = `${tableName}-${fkCol}-index`;
642
+ modelGsis.set(fkCol, gsiName);
643
+ }
644
+
645
+ this._gsiRegistry.set(modelName, modelGsis);
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Find a GSI that can serve the given conditions.
651
+ */
652
+ private _findGsiMatch(modelName: string, conditions: Record<string, unknown>): {
653
+ indexName: string;
654
+ keyConditions: Record<string, unknown>;
655
+ remainingConditions: Record<string, unknown>;
656
+ } | null {
657
+ const modelGsis = this._gsiRegistry.get(modelName);
658
+ if (!modelGsis) return null;
659
+
660
+ for (const [attrName, indexName] of modelGsis) {
661
+ if (conditions[attrName] !== undefined) {
662
+ const keyConditions: Record<string, unknown> = { [attrName]: conditions[attrName] };
663
+ const remainingConditions: Record<string, unknown> = {};
664
+
665
+ for (const [k, v] of Object.entries(conditions)) {
666
+ if (k !== attrName) remainingConditions[k] = v;
667
+ }
668
+
669
+ return { indexName, keyConditions, remainingConditions };
670
+ }
671
+ }
672
+
673
+ return null;
674
+ }
675
+
676
+ // -------------------------------------------------------------------------
677
+ // Private — table provisioning helpers
678
+ // -------------------------------------------------------------------------
679
+
680
+ private _buildAttributeDefinitions(schema: ModelSchema): Array<{ AttributeName: string; AttributeType: string }> {
681
+ const defs: Array<{ AttributeName: string; AttributeType: string }> = [
682
+ { AttributeName: 'id', AttributeType: this.deps.getDynamoKeyType(schema.idType) },
683
+ ];
684
+
685
+ for (const fkCol of Object.keys(schema.foreignKeys)) {
686
+ defs.push({ AttributeName: fkCol, AttributeType: 'S' });
687
+ }
688
+
689
+ // Deduplicate
690
+ const seen = new Set<string>();
691
+ return defs.filter(d => {
692
+ if (seen.has(d.AttributeName)) return false;
693
+ seen.add(d.AttributeName);
694
+ return true;
695
+ });
696
+ }
697
+
698
+ private _buildGsiDefinitions(modelName: string, schema: ModelSchema): unknown[] {
699
+ const tableName = this._resolveTableName(modelName);
700
+ const gsis: unknown[] = [];
701
+
702
+ for (const fkCol of Object.keys(schema.foreignKeys)) {
703
+ const gsiName = `${tableName}-${fkCol}-index`;
704
+ gsis.push({
705
+ IndexName: gsiName,
706
+ KeySchema: [{ AttributeName: fkCol, KeyType: 'HASH' }],
707
+ Projection: { ProjectionType: 'ALL' },
708
+ });
709
+ }
710
+
711
+ return gsis;
712
+ }
713
+
714
+ private async _waitForTableActive(
715
+ rawClient: { send(cmd: unknown): Promise<unknown> },
716
+ tableName: string,
717
+ DescribeTableCommand: new (p: unknown) => unknown,
718
+ ): Promise<void> {
719
+ const MAX_POLLS = 60;
720
+ const POLL_INTERVAL = 2000;
721
+
722
+ for (let i = 0; i < MAX_POLLS; i++) {
723
+ const desc = await rawClient.send(new DescribeTableCommand({ TableName: tableName })) as {
724
+ Table?: { TableStatus?: string };
725
+ };
726
+
727
+ if (desc.Table?.TableStatus === 'ACTIVE') return;
728
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
729
+ }
730
+
731
+ throw new Error(`DynamoDB table '${tableName}' did not become ACTIVE within ${MAX_POLLS * POLL_INTERVAL / 1000}s`);
732
+ }
733
+
734
+ // -------------------------------------------------------------------------
735
+ // Private — data conversion
736
+ // -------------------------------------------------------------------------
737
+
738
+ private _itemToRawData(item: Record<string, unknown>, schema: ModelSchema): Record<string, unknown> {
739
+ const rawData: Record<string, unknown> = { ...item };
740
+
741
+ // Map FK columns back to relationship keys (matching PostgresDB pattern)
742
+ for (const [fkCol] of Object.entries(schema.foreignKeys)) {
743
+ const relName = fkCol.replace(/_id$/, '');
744
+ if (rawData[fkCol] !== undefined) {
745
+ rawData[relName] = rawData[fkCol];
746
+ delete rawData[fkCol];
747
+ }
748
+ }
749
+
750
+ return rawData;
751
+ }
752
+
753
+ private _recordToItem(record: OrmRecord, schema: ModelSchema, rawData?: Record<string, unknown>): Record<string, unknown> {
754
+ const item: Record<string, unknown> = {};
755
+ const data = record.__data;
756
+
757
+ if (data.id !== undefined) item.id = data.id;
758
+
759
+ for (const col of Object.keys(schema.columns)) {
760
+ if (data[col] !== undefined) {
761
+ const value = data[col];
762
+ // Date objects must be serialized to ISO-8601 strings for DynamoDB 'S' storage
763
+ item[col] = (value instanceof Date)
764
+ ? value.toISOString()
765
+ : value;
766
+ }
767
+ }
768
+
769
+ for (const fkCol of Object.keys(schema.foreignKeys)) {
770
+ const relName = fkCol.replace(/_id$/, '');
771
+ const related = record.__relationships[relName];
772
+
773
+ if (related && typeof related === 'object' && related !== null) {
774
+ item[fkCol] = (related as { id: unknown }).id;
775
+ } else if (related != null) {
776
+ item[fkCol] = related;
777
+ } else if (data[relName] !== undefined) {
778
+ item[fkCol] = data[relName];
779
+ } else if (rawData?.[relName] !== undefined) {
780
+ item[fkCol] = rawData[relName];
781
+ }
782
+ }
783
+
784
+ return item;
785
+ }
786
+
787
+ // -------------------------------------------------------------------------
788
+ // Private — store eviction
789
+ // -------------------------------------------------------------------------
790
+
791
+ private _evictIfNotMemory(modelName: string, record: OrmRecord): void {
792
+ const storeRef = this.deps.store as {
793
+ _memoryResolver?: (name: string) => boolean;
794
+ get?: (name: string) => Map<unknown, unknown> | undefined;
795
+ data?: { get(name: string): Map<unknown, unknown> | undefined };
796
+ };
797
+
798
+ if (storeRef._memoryResolver && !storeRef._memoryResolver(modelName)) {
799
+ const modelStore = storeRef.get?.(modelName) ?? storeRef.data?.get(modelName);
800
+ if (modelStore) modelStore.delete(record.id);
801
+ }
802
+ }
803
+
804
+ // -------------------------------------------------------------------------
805
+ // Deprecated alias
806
+ // -------------------------------------------------------------------------
807
+
808
+ async loadAllRecords(): Promise<void> {
809
+ return this.loadMemoryRecords();
810
+ }
811
+ }