@stonyx/orm 0.3.2-beta.65 → 0.3.2-beta.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/config/environment.js +8 -0
- package/dist/commands.js +34 -0
- package/dist/dynamodb/connection.d.ts +28 -0
- package/dist/dynamodb/connection.js +28 -0
- package/dist/dynamodb/dynamodb-db.d.ts +131 -0
- package/dist/dynamodb/dynamodb-db.js +556 -0
- package/dist/dynamodb/operation-builder.d.ts +76 -0
- package/dist/dynamodb/operation-builder.js +109 -0
- package/dist/dynamodb/type-map.d.ts +31 -0
- package/dist/dynamodb/type-map.js +48 -0
- package/dist/main.js +6 -0
- package/dist/types/orm-types.d.ts +6 -0
- package/package.json +9 -1
- package/src/commands.ts +43 -0
- package/src/dynamodb/connection.ts +49 -0
- package/src/dynamodb/dynamodb-db.ts +768 -0
- package/src/dynamodb/operation-builder.ts +188 -0
- package/src/dynamodb/type-map.ts +54 -0
- package/src/main.ts +5 -0
- package/src/types/orm-types.ts +7 -0
|
@@ -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
|
+
}
|