@stonyx/orm 0.3.2-beta.64 → 0.3.2-beta.66

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