@stonyx/orm 0.3.2-beta.7 → 0.3.2-beta.70
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 +30 -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/index.d.ts +1 -0
- package/dist/main.d.ts +16 -0
- package/dist/main.js +36 -0
- package/dist/manage-record.js +35 -5
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/serializer.js +27 -2
- package/dist/store.js +6 -1
- package/dist/types/orm-types.d.ts +8 -0
- package/package.json +17 -7
- 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/index.ts +1 -0
- package/src/main.ts +45 -0
- package/src/manage-record.ts +36 -5
- package/src/postgres/connection.ts +10 -6
- package/src/serializer.ts +27 -2
- package/src/store.ts +6 -1
- package/src/types/orm-types.ts +9 -0
- package/src/types/stonyx.d.ts +7 -1
|
@@ -0,0 +1,556 @@
|
|
|
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
|
+
import { createDocumentClient, destroyDocumentClient } from './connection.js';
|
|
8
|
+
import { buildPutItem, buildGetItem, buildUpdateItem, buildDeleteItem, buildScan, buildQuery, } from './operation-builder.js';
|
|
9
|
+
import { introspectModels, getTopologicalOrder } from '../postgres/schema-introspector.js';
|
|
10
|
+
import { getDynamoKeyType } from './type-map.js';
|
|
11
|
+
import { store } from '@stonyx/orm';
|
|
12
|
+
import { createRecord } from '../manage-record.js';
|
|
13
|
+
import { getPluralName } from '../plural-registry.js';
|
|
14
|
+
import { sanitizeTableName } from '../schema-helpers.js';
|
|
15
|
+
import config from 'stonyx/config';
|
|
16
|
+
import log from 'stonyx/log';
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// ULID — monotonic, inline implementation (avoids heavy dep)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
21
|
+
function generateUlid() {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
let id = '';
|
|
24
|
+
// 10-char timestamp (48-bit millisecond precision)
|
|
25
|
+
let t = now;
|
|
26
|
+
for (let i = 9; i >= 0; i--) {
|
|
27
|
+
id = CROCKFORD[t % 32] + id;
|
|
28
|
+
t = Math.floor(t / 32);
|
|
29
|
+
}
|
|
30
|
+
// 16-char random
|
|
31
|
+
for (let i = 0; i < 16; i++) {
|
|
32
|
+
id += CROCKFORD[Math.floor(Math.random() * 32)];
|
|
33
|
+
}
|
|
34
|
+
return id;
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// SDK Command factories (injectable for testing without real AWS SDK)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
/**
|
|
40
|
+
* Load the DynamoDB DocumentClient command constructors via dynamic import.
|
|
41
|
+
* Returns a frozen object so it can be cached in deps.
|
|
42
|
+
*/
|
|
43
|
+
export async function loadDocClientCommands() {
|
|
44
|
+
return import('@aws-sdk/lib-dynamodb');
|
|
45
|
+
}
|
|
46
|
+
export async function loadTableCommands() {
|
|
47
|
+
return import('@aws-sdk/client-dynamodb');
|
|
48
|
+
}
|
|
49
|
+
const defaultDeps = {
|
|
50
|
+
createDocumentClient,
|
|
51
|
+
destroyDocumentClient,
|
|
52
|
+
loadDocClientCommands,
|
|
53
|
+
loadTableCommands,
|
|
54
|
+
buildPutItem,
|
|
55
|
+
buildGetItem,
|
|
56
|
+
buildUpdateItem,
|
|
57
|
+
buildDeleteItem,
|
|
58
|
+
buildScan,
|
|
59
|
+
buildQuery,
|
|
60
|
+
introspectModels,
|
|
61
|
+
getTopologicalOrder,
|
|
62
|
+
getDynamoKeyType,
|
|
63
|
+
createRecord,
|
|
64
|
+
store,
|
|
65
|
+
getPluralName,
|
|
66
|
+
config,
|
|
67
|
+
log,
|
|
68
|
+
};
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// DynamoDB driver
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
export default class DynamoDBDB {
|
|
73
|
+
static instance;
|
|
74
|
+
deps;
|
|
75
|
+
client;
|
|
76
|
+
dbConfig;
|
|
77
|
+
/** GSI registry built during init from model introspection. */
|
|
78
|
+
_gsiRegistry = new Map();
|
|
79
|
+
constructor(deps = {}) {
|
|
80
|
+
const Ctor = this.constructor;
|
|
81
|
+
if (Ctor.instance)
|
|
82
|
+
return Ctor.instance;
|
|
83
|
+
Ctor.instance = this;
|
|
84
|
+
this.deps = { ...defaultDeps, ...deps };
|
|
85
|
+
this.client = null;
|
|
86
|
+
const dynamoConfig = this.deps.config.orm.dynamodb;
|
|
87
|
+
if (!dynamoConfig)
|
|
88
|
+
throw new Error('DynamoDB configuration (config.orm.dynamodb) is required');
|
|
89
|
+
this.dbConfig = dynamoConfig;
|
|
90
|
+
}
|
|
91
|
+
requireClient() {
|
|
92
|
+
if (!this.client)
|
|
93
|
+
throw new Error('DynamoDBDB client not initialized — call init() first');
|
|
94
|
+
return this.client;
|
|
95
|
+
}
|
|
96
|
+
/** Resolve Orm singleton — falls back to real import in production. */
|
|
97
|
+
async _getOrm() {
|
|
98
|
+
if (this.deps._importOrm)
|
|
99
|
+
return this.deps._importOrm();
|
|
100
|
+
return import('@stonyx/orm');
|
|
101
|
+
}
|
|
102
|
+
// -------------------------------------------------------------------------
|
|
103
|
+
// SqlDb contract — init
|
|
104
|
+
// -------------------------------------------------------------------------
|
|
105
|
+
async init() {
|
|
106
|
+
this.client = await this.deps.createDocumentClient(this.dbConfig);
|
|
107
|
+
this._buildGsiRegistry();
|
|
108
|
+
await this.loadMemoryRecords();
|
|
109
|
+
}
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
// SqlDb contract — startup (table provisioning)
|
|
112
|
+
// -------------------------------------------------------------------------
|
|
113
|
+
/**
|
|
114
|
+
* For each model, DescribeTable — CreateTable if missing (with GSIs, PAY_PER_REQUEST).
|
|
115
|
+
* For existing tables, check for missing GSIs and UpdateTable + poll for ACTIVE.
|
|
116
|
+
*/
|
|
117
|
+
async startup() {
|
|
118
|
+
const schemas = this.deps.introspectModels();
|
|
119
|
+
const { DynamoDBClient, DescribeTableCommand, CreateTableCommand, UpdateTableCommand } = await this.deps.loadTableCommands();
|
|
120
|
+
const clientOptions = {};
|
|
121
|
+
if (this.dbConfig.region)
|
|
122
|
+
clientOptions.region = this.dbConfig.region;
|
|
123
|
+
if (this.dbConfig.endpoint)
|
|
124
|
+
clientOptions.endpoint = this.dbConfig.endpoint;
|
|
125
|
+
const rawClient = new DynamoDBClient(clientOptions);
|
|
126
|
+
for (const [modelName, schema] of Object.entries(schemas)) {
|
|
127
|
+
const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
|
|
128
|
+
const gsis = this._buildGsiDefinitions(modelName, schema);
|
|
129
|
+
try {
|
|
130
|
+
const desc = await rawClient.send(new DescribeTableCommand({ TableName: tableName }));
|
|
131
|
+
// Table exists — check for missing GSIs
|
|
132
|
+
const existingGsiNames = new Set((desc.Table?.GlobalSecondaryIndexes ?? []).map((g) => g.IndexName ?? ''));
|
|
133
|
+
for (const gsi of gsis) {
|
|
134
|
+
const gsiName = gsi.IndexName;
|
|
135
|
+
if (gsiName && !existingGsiNames.has(gsiName)) {
|
|
136
|
+
this.deps.log.db?.(`Adding missing GSI '${gsiName}' to table '${tableName}'`);
|
|
137
|
+
await rawClient.send(new UpdateTableCommand({
|
|
138
|
+
TableName: tableName,
|
|
139
|
+
GlobalSecondaryIndexUpdates: [{ Create: gsi }],
|
|
140
|
+
AttributeDefinitions: this._buildAttributeDefinitions(schema),
|
|
141
|
+
}));
|
|
142
|
+
await this._waitForTableActive(rawClient, tableName, DescribeTableCommand);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
this.deps.log.db?.(`DynamoDB table '${tableName}' is ready`);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const code = err.name;
|
|
149
|
+
if (code !== 'ResourceNotFoundException')
|
|
150
|
+
throw err;
|
|
151
|
+
// Table does not exist — create it
|
|
152
|
+
this.deps.log.db?.(`Creating DynamoDB table '${tableName}'`);
|
|
153
|
+
await rawClient.send(new CreateTableCommand({
|
|
154
|
+
TableName: tableName,
|
|
155
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
156
|
+
AttributeDefinitions: this._buildAttributeDefinitions(schema),
|
|
157
|
+
KeySchema: [{ AttributeName: 'id', KeyType: 'HASH' }],
|
|
158
|
+
...(gsis.length > 0 ? { GlobalSecondaryIndexes: gsis } : {}),
|
|
159
|
+
}));
|
|
160
|
+
await this._waitForTableActive(rawClient, tableName, DescribeTableCommand);
|
|
161
|
+
this.deps.log.db?.(`Created DynamoDB table '${tableName}'`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// -------------------------------------------------------------------------
|
|
166
|
+
// SqlDb contract — shutdown
|
|
167
|
+
// -------------------------------------------------------------------------
|
|
168
|
+
async shutdown() {
|
|
169
|
+
this.client = this.deps.destroyDocumentClient(this.client);
|
|
170
|
+
}
|
|
171
|
+
// -------------------------------------------------------------------------
|
|
172
|
+
// SqlDb contract — persist
|
|
173
|
+
// -------------------------------------------------------------------------
|
|
174
|
+
async persist(operation, modelName, context, response) {
|
|
175
|
+
const OrmModule = await this._getOrm();
|
|
176
|
+
if (OrmModule.default?.instance?.isView?.(modelName))
|
|
177
|
+
return;
|
|
178
|
+
switch (operation) {
|
|
179
|
+
case 'create':
|
|
180
|
+
return this._persistCreate(modelName, context, response);
|
|
181
|
+
case 'update':
|
|
182
|
+
return this._persistUpdate(modelName, context);
|
|
183
|
+
case 'delete':
|
|
184
|
+
return this._persistDelete(modelName, context);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// -------------------------------------------------------------------------
|
|
188
|
+
// SqlDb contract — findRecord
|
|
189
|
+
// -------------------------------------------------------------------------
|
|
190
|
+
async findRecord(modelName, id) {
|
|
191
|
+
const schemas = this.deps.introspectModels();
|
|
192
|
+
const schema = schemas[modelName];
|
|
193
|
+
if (!schema)
|
|
194
|
+
return undefined;
|
|
195
|
+
const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
|
|
196
|
+
const { GetCommand } = await this.deps.loadDocClientCommands();
|
|
197
|
+
const params = this.deps.buildGetItem(tableName, { id });
|
|
198
|
+
try {
|
|
199
|
+
const result = await this.requireClient().send(new GetCommand(params));
|
|
200
|
+
if (!result.Item)
|
|
201
|
+
return undefined;
|
|
202
|
+
const rawData = this._itemToRawData(result.Item, schema);
|
|
203
|
+
const record = this.deps.createRecord(modelName, rawData, {
|
|
204
|
+
isDbRecord: true, serialize: false, transform: false,
|
|
205
|
+
});
|
|
206
|
+
this._evictIfNotMemory(modelName, record);
|
|
207
|
+
return record;
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
if (err.name === 'ResourceNotFoundException')
|
|
211
|
+
return undefined;
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// -------------------------------------------------------------------------
|
|
216
|
+
// SqlDb contract — findAll
|
|
217
|
+
// -------------------------------------------------------------------------
|
|
218
|
+
async findAll(modelName, conditions) {
|
|
219
|
+
const schemas = this.deps.introspectModels();
|
|
220
|
+
const schema = schemas[modelName];
|
|
221
|
+
if (!schema)
|
|
222
|
+
return [];
|
|
223
|
+
const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
|
|
224
|
+
try {
|
|
225
|
+
let items;
|
|
226
|
+
if (!conditions || Object.keys(conditions).length === 0) {
|
|
227
|
+
items = await this._paginatedScan(tableName);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
// Try to route through a GSI if one matches a condition key
|
|
231
|
+
const gsiMatch = this._findGsiMatch(modelName, conditions);
|
|
232
|
+
if (gsiMatch) {
|
|
233
|
+
const { indexName, keyConditions, remainingConditions } = gsiMatch;
|
|
234
|
+
items = await this._paginatedQuery(tableName, indexName, keyConditions);
|
|
235
|
+
// Filter remaining non-key conditions in memory
|
|
236
|
+
if (Object.keys(remainingConditions).length > 0) {
|
|
237
|
+
items = items.filter(item => Object.entries(remainingConditions).every(([k, v]) => item[k] === v));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
// No GSI — fall back to Scan + FilterExpression (warn)
|
|
242
|
+
this.deps.log.warn?.(`[DynamoDB] findAll('${modelName}') using Scan+FilterExpression — no GSI for conditions: ${JSON.stringify(conditions)}. Add a GSI index for better performance.`);
|
|
243
|
+
items = await this._paginatedScan(tableName, conditions);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const records = items.map(item => {
|
|
247
|
+
const rawData = this._itemToRawData(item, schema);
|
|
248
|
+
return this.deps.createRecord(modelName, rawData, {
|
|
249
|
+
isDbRecord: true, serialize: false, transform: false,
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
for (const record of records) {
|
|
253
|
+
this._evictIfNotMemory(modelName, record);
|
|
254
|
+
}
|
|
255
|
+
return records;
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
if (err.name === 'ResourceNotFoundException')
|
|
259
|
+
return [];
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// -------------------------------------------------------------------------
|
|
264
|
+
// loadMemoryRecords
|
|
265
|
+
// -------------------------------------------------------------------------
|
|
266
|
+
async loadMemoryRecords() {
|
|
267
|
+
const schemas = this.deps.introspectModels();
|
|
268
|
+
const order = this.deps.getTopologicalOrder(schemas);
|
|
269
|
+
const OrmModule = await this._getOrm();
|
|
270
|
+
const Orm = OrmModule.default;
|
|
271
|
+
for (const modelName of order) {
|
|
272
|
+
const { modelClass } = Orm.instance.getRecordClasses(modelName);
|
|
273
|
+
if (modelClass?.memory === false) {
|
|
274
|
+
this.deps.log.db?.(`Skipping memory load for '${modelName}' (memory: false)`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const schema = schemas[modelName];
|
|
278
|
+
const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
|
|
279
|
+
try {
|
|
280
|
+
const items = await this._paginatedScan(tableName);
|
|
281
|
+
for (const item of items) {
|
|
282
|
+
const rawData = this._itemToRawData(item, schema);
|
|
283
|
+
this.deps.createRecord(modelName, rawData, {
|
|
284
|
+
isDbRecord: true, serialize: false, transform: false,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
if (err.name === 'ResourceNotFoundException') {
|
|
290
|
+
this.deps.log.db?.(`Table '${tableName}' does not exist yet. Skipping load for '${modelName}'.`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// -------------------------------------------------------------------------
|
|
298
|
+
// Private — persist helpers
|
|
299
|
+
// -------------------------------------------------------------------------
|
|
300
|
+
async _persistCreate(modelName, context, response) {
|
|
301
|
+
const schemas = this.deps.introspectModels();
|
|
302
|
+
const schema = schemas[modelName];
|
|
303
|
+
if (!schema)
|
|
304
|
+
return;
|
|
305
|
+
const recordId = response?.data?.id;
|
|
306
|
+
const storeRef = this.deps.store;
|
|
307
|
+
const record = recordId != null ? storeRef.get(modelName, recordId) : null;
|
|
308
|
+
if (!record)
|
|
309
|
+
return;
|
|
310
|
+
const isPendingId = context.rawData?.__pendingSqlId === true;
|
|
311
|
+
const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
|
|
312
|
+
// For numeric-ID models with a pending ID, generate a ULID
|
|
313
|
+
let finalId = record.id;
|
|
314
|
+
if (isPendingId) {
|
|
315
|
+
finalId = generateUlid();
|
|
316
|
+
}
|
|
317
|
+
const item = this._recordToItem(record, schema, context.rawData);
|
|
318
|
+
item.id = finalId;
|
|
319
|
+
const { PutCommand } = await this.deps.loadDocClientCommands();
|
|
320
|
+
const params = this.deps.buildPutItem(tableName, item, 'attribute_not_exists(id)');
|
|
321
|
+
await this.requireClient().send(new PutCommand(params));
|
|
322
|
+
// Re-key the store record if we generated a new ID
|
|
323
|
+
if (isPendingId) {
|
|
324
|
+
const pendingId = record.id;
|
|
325
|
+
const modelStoreMap = this.deps.store.get(modelName);
|
|
326
|
+
modelStoreMap.delete(pendingId);
|
|
327
|
+
record.__data.id = finalId;
|
|
328
|
+
record.id = finalId;
|
|
329
|
+
modelStoreMap.set(finalId, record);
|
|
330
|
+
if (response?.data)
|
|
331
|
+
response.data.id = finalId;
|
|
332
|
+
delete record.__data.__pendingSqlId;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async _persistUpdate(modelName, context) {
|
|
336
|
+
const schemas = this.deps.introspectModels();
|
|
337
|
+
const schema = schemas[modelName];
|
|
338
|
+
if (!schema)
|
|
339
|
+
return;
|
|
340
|
+
const record = context.record;
|
|
341
|
+
if (!record)
|
|
342
|
+
return;
|
|
343
|
+
const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
|
|
344
|
+
const id = record.id;
|
|
345
|
+
const oldState = context.oldState || {};
|
|
346
|
+
const currentData = record.__data;
|
|
347
|
+
// Build diff of changed columns
|
|
348
|
+
const changedData = {};
|
|
349
|
+
for (const col of Object.keys(schema.columns)) {
|
|
350
|
+
if (currentData[col] !== oldState[col]) {
|
|
351
|
+
changedData[col] = currentData[col] ?? null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// FK changes
|
|
355
|
+
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
356
|
+
const relName = fkCol.replace(/_id$/, '');
|
|
357
|
+
const relValue = record.__relationships[relName];
|
|
358
|
+
const currentFkValue = relValue && typeof relValue === 'object' && relValue !== null
|
|
359
|
+
? relValue.id ?? null
|
|
360
|
+
: relValue ?? record.__data[relName] ?? null;
|
|
361
|
+
const oldFkValue = oldState[relName] ?? null;
|
|
362
|
+
if (currentFkValue !== oldFkValue)
|
|
363
|
+
changedData[fkCol] = currentFkValue;
|
|
364
|
+
}
|
|
365
|
+
if (Object.keys(changedData).length === 0)
|
|
366
|
+
return;
|
|
367
|
+
const { UpdateCommand } = await this.deps.loadDocClientCommands();
|
|
368
|
+
const params = this.deps.buildUpdateItem(tableName, { id }, changedData);
|
|
369
|
+
await this.requireClient().send(new UpdateCommand(params));
|
|
370
|
+
}
|
|
371
|
+
async _persistDelete(modelName, context) {
|
|
372
|
+
const schemas = this.deps.introspectModels();
|
|
373
|
+
const schema = schemas[modelName];
|
|
374
|
+
if (!schema)
|
|
375
|
+
return;
|
|
376
|
+
const id = context.recordId;
|
|
377
|
+
if (id == null)
|
|
378
|
+
return;
|
|
379
|
+
const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
|
|
380
|
+
const { DeleteCommand } = await this.deps.loadDocClientCommands();
|
|
381
|
+
const params = this.deps.buildDeleteItem(tableName, { id });
|
|
382
|
+
await this.requireClient().send(new DeleteCommand(params));
|
|
383
|
+
}
|
|
384
|
+
// -------------------------------------------------------------------------
|
|
385
|
+
// Private — pagination helpers
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
async _paginatedScan(tableName, conditions) {
|
|
388
|
+
const { ScanCommand } = await this.deps.loadDocClientCommands();
|
|
389
|
+
const items = [];
|
|
390
|
+
let lastKey;
|
|
391
|
+
do {
|
|
392
|
+
const params = this.deps.buildScan(tableName, conditions, lastKey);
|
|
393
|
+
const result = await this.requireClient().send(new ScanCommand(params));
|
|
394
|
+
if (result.Items)
|
|
395
|
+
items.push(...result.Items);
|
|
396
|
+
lastKey = result.LastEvaluatedKey;
|
|
397
|
+
} while (lastKey);
|
|
398
|
+
return items;
|
|
399
|
+
}
|
|
400
|
+
async _paginatedQuery(tableName, indexName, keyConditions) {
|
|
401
|
+
const { QueryCommand } = await this.deps.loadDocClientCommands();
|
|
402
|
+
const items = [];
|
|
403
|
+
let lastKey;
|
|
404
|
+
do {
|
|
405
|
+
const params = this.deps.buildQuery(tableName, indexName, keyConditions, lastKey);
|
|
406
|
+
const result = await this.requireClient().send(new QueryCommand(params));
|
|
407
|
+
if (result.Items)
|
|
408
|
+
items.push(...result.Items);
|
|
409
|
+
lastKey = result.LastEvaluatedKey;
|
|
410
|
+
} while (lastKey);
|
|
411
|
+
return items;
|
|
412
|
+
}
|
|
413
|
+
// -------------------------------------------------------------------------
|
|
414
|
+
// Private — GSI registry
|
|
415
|
+
// -------------------------------------------------------------------------
|
|
416
|
+
/**
|
|
417
|
+
* Build the GSI registry from model introspection.
|
|
418
|
+
* Registry: modelName → attrName → gsiName
|
|
419
|
+
*
|
|
420
|
+
* FK columns (belonging to belongsTo relationships) get a GSI automatically.
|
|
421
|
+
*/
|
|
422
|
+
_buildGsiRegistry() {
|
|
423
|
+
const schemas = this.deps.introspectModels();
|
|
424
|
+
for (const [modelName, schema] of Object.entries(schemas)) {
|
|
425
|
+
const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
|
|
426
|
+
const modelGsis = new Map();
|
|
427
|
+
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
428
|
+
const gsiName = `${tableName}-${fkCol}-index`;
|
|
429
|
+
modelGsis.set(fkCol, gsiName);
|
|
430
|
+
}
|
|
431
|
+
this._gsiRegistry.set(modelName, modelGsis);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Find a GSI that can serve the given conditions.
|
|
436
|
+
*/
|
|
437
|
+
_findGsiMatch(modelName, conditions) {
|
|
438
|
+
const modelGsis = this._gsiRegistry.get(modelName);
|
|
439
|
+
if (!modelGsis)
|
|
440
|
+
return null;
|
|
441
|
+
for (const [attrName, indexName] of modelGsis) {
|
|
442
|
+
if (conditions[attrName] !== undefined) {
|
|
443
|
+
const keyConditions = { [attrName]: conditions[attrName] };
|
|
444
|
+
const remainingConditions = {};
|
|
445
|
+
for (const [k, v] of Object.entries(conditions)) {
|
|
446
|
+
if (k !== attrName)
|
|
447
|
+
remainingConditions[k] = v;
|
|
448
|
+
}
|
|
449
|
+
return { indexName, keyConditions, remainingConditions };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
// -------------------------------------------------------------------------
|
|
455
|
+
// Private — table provisioning helpers
|
|
456
|
+
// -------------------------------------------------------------------------
|
|
457
|
+
_buildAttributeDefinitions(schema) {
|
|
458
|
+
const defs = [
|
|
459
|
+
{ AttributeName: 'id', AttributeType: this.deps.getDynamoKeyType(schema.idType) },
|
|
460
|
+
];
|
|
461
|
+
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
462
|
+
defs.push({ AttributeName: fkCol, AttributeType: 'S' });
|
|
463
|
+
}
|
|
464
|
+
// Deduplicate
|
|
465
|
+
const seen = new Set();
|
|
466
|
+
return defs.filter(d => {
|
|
467
|
+
if (seen.has(d.AttributeName))
|
|
468
|
+
return false;
|
|
469
|
+
seen.add(d.AttributeName);
|
|
470
|
+
return true;
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
_buildGsiDefinitions(modelName, schema) {
|
|
474
|
+
const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
|
|
475
|
+
const gsis = [];
|
|
476
|
+
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
477
|
+
const gsiName = `${tableName}-${fkCol}-index`;
|
|
478
|
+
gsis.push({
|
|
479
|
+
IndexName: gsiName,
|
|
480
|
+
KeySchema: [{ AttributeName: fkCol, KeyType: 'HASH' }],
|
|
481
|
+
Projection: { ProjectionType: 'ALL' },
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return gsis;
|
|
485
|
+
}
|
|
486
|
+
async _waitForTableActive(rawClient, tableName, DescribeTableCommand) {
|
|
487
|
+
const MAX_POLLS = 60;
|
|
488
|
+
const POLL_INTERVAL = 2000;
|
|
489
|
+
for (let i = 0; i < MAX_POLLS; i++) {
|
|
490
|
+
const desc = await rawClient.send(new DescribeTableCommand({ TableName: tableName }));
|
|
491
|
+
if (desc.Table?.TableStatus === 'ACTIVE')
|
|
492
|
+
return;
|
|
493
|
+
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
|
|
494
|
+
}
|
|
495
|
+
throw new Error(`DynamoDB table '${tableName}' did not become ACTIVE within ${MAX_POLLS * POLL_INTERVAL / 1000}s`);
|
|
496
|
+
}
|
|
497
|
+
// -------------------------------------------------------------------------
|
|
498
|
+
// Private — data conversion
|
|
499
|
+
// -------------------------------------------------------------------------
|
|
500
|
+
_itemToRawData(item, schema) {
|
|
501
|
+
const rawData = { ...item };
|
|
502
|
+
// Map FK columns back to relationship keys (matching PostgresDB pattern)
|
|
503
|
+
for (const [fkCol] of Object.entries(schema.foreignKeys)) {
|
|
504
|
+
const relName = fkCol.replace(/_id$/, '');
|
|
505
|
+
if (rawData[fkCol] !== undefined) {
|
|
506
|
+
rawData[relName] = rawData[fkCol];
|
|
507
|
+
delete rawData[fkCol];
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return rawData;
|
|
511
|
+
}
|
|
512
|
+
_recordToItem(record, schema, rawData) {
|
|
513
|
+
const item = {};
|
|
514
|
+
const data = record.__data;
|
|
515
|
+
if (data.id !== undefined)
|
|
516
|
+
item.id = data.id;
|
|
517
|
+
for (const col of Object.keys(schema.columns)) {
|
|
518
|
+
if (data[col] !== undefined)
|
|
519
|
+
item[col] = data[col];
|
|
520
|
+
}
|
|
521
|
+
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
522
|
+
const relName = fkCol.replace(/_id$/, '');
|
|
523
|
+
const related = record.__relationships[relName];
|
|
524
|
+
if (related && typeof related === 'object' && related !== null) {
|
|
525
|
+
item[fkCol] = related.id;
|
|
526
|
+
}
|
|
527
|
+
else if (related != null) {
|
|
528
|
+
item[fkCol] = related;
|
|
529
|
+
}
|
|
530
|
+
else if (data[relName] !== undefined) {
|
|
531
|
+
item[fkCol] = data[relName];
|
|
532
|
+
}
|
|
533
|
+
else if (rawData?.[relName] !== undefined) {
|
|
534
|
+
item[fkCol] = rawData[relName];
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return item;
|
|
538
|
+
}
|
|
539
|
+
// -------------------------------------------------------------------------
|
|
540
|
+
// Private — store eviction
|
|
541
|
+
// -------------------------------------------------------------------------
|
|
542
|
+
_evictIfNotMemory(modelName, record) {
|
|
543
|
+
const storeRef = this.deps.store;
|
|
544
|
+
if (storeRef._memoryResolver && !storeRef._memoryResolver(modelName)) {
|
|
545
|
+
const modelStore = storeRef.get?.(modelName) ?? storeRef.data?.get(modelName);
|
|
546
|
+
if (modelStore)
|
|
547
|
+
modelStore.delete(record.id);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// -------------------------------------------------------------------------
|
|
551
|
+
// Deprecated alias
|
|
552
|
+
// -------------------------------------------------------------------------
|
|
553
|
+
async loadAllRecords() {
|
|
554
|
+
return this.loadMemoryRecords();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB operation parameter builders.
|
|
3
|
+
*
|
|
4
|
+
* Each function returns a plain-object "params" bag that can be passed
|
|
5
|
+
* directly to the corresponding DocumentClient command
|
|
6
|
+
* (PutCommand, GetCommand, UpdateCommand, DeleteCommand, ScanCommand, QueryCommand).
|
|
7
|
+
*
|
|
8
|
+
* All functions are pure — no SDK imports here; the caller wraps params
|
|
9
|
+
* in the appropriate Command class.
|
|
10
|
+
*/
|
|
11
|
+
export interface PutItemParams {
|
|
12
|
+
TableName: string;
|
|
13
|
+
Item: Record<string, unknown>;
|
|
14
|
+
ConditionExpression?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface GetItemParams {
|
|
17
|
+
TableName: string;
|
|
18
|
+
Key: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
export interface UpdateItemParams {
|
|
21
|
+
TableName: string;
|
|
22
|
+
Key: Record<string, unknown>;
|
|
23
|
+
UpdateExpression: string;
|
|
24
|
+
ExpressionAttributeNames: Record<string, string>;
|
|
25
|
+
ExpressionAttributeValues: Record<string, unknown>;
|
|
26
|
+
ReturnValues: string;
|
|
27
|
+
}
|
|
28
|
+
export interface DeleteItemParams {
|
|
29
|
+
TableName: string;
|
|
30
|
+
Key: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
export interface ScanParams {
|
|
33
|
+
TableName: string;
|
|
34
|
+
FilterExpression?: string;
|
|
35
|
+
ExpressionAttributeNames?: Record<string, string>;
|
|
36
|
+
ExpressionAttributeValues?: Record<string, unknown>;
|
|
37
|
+
ExclusiveStartKey?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
export interface QueryParams {
|
|
40
|
+
TableName: string;
|
|
41
|
+
IndexName: string;
|
|
42
|
+
KeyConditionExpression: string;
|
|
43
|
+
ExpressionAttributeNames: Record<string, string>;
|
|
44
|
+
ExpressionAttributeValues: Record<string, unknown>;
|
|
45
|
+
ExclusiveStartKey?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* PutItem — optionally with a condition expression.
|
|
49
|
+
*
|
|
50
|
+
* Pass conditionExpression = 'attribute_not_exists(id)' to enforce uniqueness.
|
|
51
|
+
*/
|
|
52
|
+
export declare function buildPutItem(tableName: string, item: Record<string, unknown>, conditionExpression?: string): PutItemParams;
|
|
53
|
+
/**
|
|
54
|
+
* GetItem by primary key.
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildGetItem(tableName: string, key: Record<string, unknown>): GetItemParams;
|
|
57
|
+
/**
|
|
58
|
+
* UpdateItem with a SET expression built from the `updates` object.
|
|
59
|
+
* Only the supplied attributes are updated (diff-based call site).
|
|
60
|
+
*/
|
|
61
|
+
export declare function buildUpdateItem(tableName: string, key: Record<string, unknown>, updates: Record<string, unknown>): UpdateItemParams;
|
|
62
|
+
/**
|
|
63
|
+
* DeleteItem by primary key.
|
|
64
|
+
*/
|
|
65
|
+
export declare function buildDeleteItem(tableName: string, key: Record<string, unknown>): DeleteItemParams;
|
|
66
|
+
/**
|
|
67
|
+
* ScanCommand params.
|
|
68
|
+
* If conditions are supplied they are rendered as a FilterExpression using AND.
|
|
69
|
+
*/
|
|
70
|
+
export declare function buildScan(tableName: string, conditions?: Record<string, unknown>, exclusiveStartKey?: Record<string, unknown>): ScanParams;
|
|
71
|
+
/**
|
|
72
|
+
* QueryCommand params for a GSI.
|
|
73
|
+
* keyConditions must be in the form { attrName: value } and will be rendered
|
|
74
|
+
* as equality expressions joined by AND.
|
|
75
|
+
*/
|
|
76
|
+
export declare function buildQuery(tableName: string, indexName: string, keyConditions: Record<string, unknown>, exclusiveStartKey?: Record<string, unknown>): QueryParams;
|