@jaypie/dynamodb 0.4.4 → 0.6.0
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/dist/cjs/constants.d.ts +0 -5
- package/dist/cjs/entities.d.ts +8 -13
- package/dist/cjs/index.cjs +210 -324
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.ts +3 -3
- package/dist/cjs/keyBuilders.d.ts +11 -49
- package/dist/cjs/mcp/index.cjs +184 -223
- package/dist/cjs/mcp/index.cjs.map +1 -1
- package/dist/cjs/queries.d.ts +39 -41
- package/dist/cjs/query.d.ts +20 -20
- package/dist/cjs/types.d.ts +26 -41
- package/dist/esm/constants.d.ts +0 -5
- package/dist/esm/entities.d.ts +8 -13
- package/dist/esm/index.d.ts +3 -3
- package/dist/esm/index.js +211 -315
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/keyBuilders.d.ts +11 -49
- package/dist/esm/mcp/index.js +185 -224
- package/dist/esm/mcp/index.js.map +1 -1
- package/dist/esm/queries.d.ts +39 -41
- package/dist/esm/query.d.ts +20 -20
- package/dist/esm/types.d.ts +26 -41
- package/package.json +1 -1
package/dist/esm/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
2
2
|
import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, TransactWriteCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
|
|
3
3
|
import { ConfigurationError } from '@jaypie/errors';
|
|
4
|
-
import { getModelIndexes, populateIndexKeys,
|
|
4
|
+
import { getModelIndexes, populateIndexKeys, buildCompositeKey as buildCompositeKey$1, APEX, calculateScope as calculateScope$1, fabricService, ARCHIVED_SUFFIX, DELETED_SUFFIX, getGsiAttributeNames, SEPARATOR } from '@jaypie/fabric';
|
|
5
5
|
export { APEX, ARCHIVED_SUFFIX, DELETED_SUFFIX, SEPARATOR } from '@jaypie/fabric';
|
|
6
6
|
|
|
7
7
|
// Environment variable names
|
|
@@ -82,69 +82,9 @@ function resetClient() {
|
|
|
82
82
|
tableName = null;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
// Re-export shared constants from fabric
|
|
86
|
-
// GSI names
|
|
87
|
-
const INDEX_ALIAS = "indexAlias";
|
|
88
|
-
const INDEX_CATEGORY = "indexCategory";
|
|
89
|
-
const INDEX_SCOPE = "indexScope";
|
|
90
|
-
const INDEX_TYPE = "indexType";
|
|
91
|
-
const INDEX_XID = "indexXid";
|
|
92
|
-
|
|
93
85
|
// =============================================================================
|
|
94
86
|
// Key Builders
|
|
95
87
|
// =============================================================================
|
|
96
|
-
/**
|
|
97
|
-
* Build the indexScope key for hierarchical queries
|
|
98
|
-
* @param scope - The scope (APEX or "{parent.model}#{parent.id}")
|
|
99
|
-
* @param model - The entity model name
|
|
100
|
-
* @returns Composite key: "{scope}#{model}"
|
|
101
|
-
*/
|
|
102
|
-
function buildIndexScope(scope, model) {
|
|
103
|
-
return `${scope}${SEPARATOR}${model}`;
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* Build the indexAlias key for human-friendly lookups
|
|
107
|
-
* @param scope - The scope
|
|
108
|
-
* @param model - The entity model name
|
|
109
|
-
* @param alias - The human-friendly alias
|
|
110
|
-
* @returns Composite key: "{scope}#{model}#{alias}"
|
|
111
|
-
*/
|
|
112
|
-
function buildIndexAlias(scope, model, alias) {
|
|
113
|
-
return `${scope}${SEPARATOR}${model}${SEPARATOR}${alias}`;
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Build the indexCategory key for category filtering
|
|
117
|
-
* @param scope - The scope
|
|
118
|
-
* @param model - The entity model name
|
|
119
|
-
* @param category - The category classification
|
|
120
|
-
* @returns Composite key: "{scope}#{model}#{category}"
|
|
121
|
-
*/
|
|
122
|
-
function buildIndexCategory(scope, model, category) {
|
|
123
|
-
return `${scope}${SEPARATOR}${model}${SEPARATOR}${category}`;
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Build the indexType key for type filtering
|
|
127
|
-
* @param scope - The scope
|
|
128
|
-
* @param model - The entity model name
|
|
129
|
-
* @param type - The type classification
|
|
130
|
-
* @returns Composite key: "{scope}#{model}#{type}"
|
|
131
|
-
*/
|
|
132
|
-
function buildIndexType(scope, model, type) {
|
|
133
|
-
return `${scope}${SEPARATOR}${model}${SEPARATOR}${type}`;
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* Build the indexXid key for external ID lookups
|
|
137
|
-
* @param scope - The scope
|
|
138
|
-
* @param model - The entity model name
|
|
139
|
-
* @param xid - The external ID
|
|
140
|
-
* @returns Composite key: "{scope}#{model}#{xid}"
|
|
141
|
-
*/
|
|
142
|
-
function buildIndexXid(scope, model, xid) {
|
|
143
|
-
return `${scope}${SEPARATOR}${model}${SEPARATOR}${xid}`;
|
|
144
|
-
}
|
|
145
|
-
// =============================================================================
|
|
146
|
-
// New Vocabulary-Based Functions
|
|
147
|
-
// =============================================================================
|
|
148
88
|
/**
|
|
149
89
|
* Build a composite key from entity fields
|
|
150
90
|
*
|
|
@@ -168,24 +108,30 @@ function calculateScope(parent) {
|
|
|
168
108
|
return calculateScope$1(parent);
|
|
169
109
|
}
|
|
170
110
|
/**
|
|
171
|
-
* Auto-populate GSI index keys on an entity
|
|
111
|
+
* Auto-populate GSI index keys on an entity and advance its write timestamps.
|
|
172
112
|
*
|
|
173
|
-
*
|
|
113
|
+
* - Bumps `updatedAt` to now on every call.
|
|
114
|
+
* - Backfills `createdAt` to the same now if not already set.
|
|
115
|
+
* - Populates GSI attributes (pk composite and sk composite when applicable)
|
|
116
|
+
* using the indexes registered for the entity's model.
|
|
174
117
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* - indexType is populated only when type is present
|
|
179
|
-
* - indexXid is populated only when xid is present
|
|
118
|
+
* Callers (createEntity, updateEntity, deleteEntity, archiveEntity,
|
|
119
|
+
* transactWriteEntities) go through this one function so `updatedAt` is
|
|
120
|
+
* always fresh and never forgotten.
|
|
180
121
|
*
|
|
181
|
-
* @param entity - The entity to
|
|
182
|
-
* @param suffix - Optional suffix
|
|
183
|
-
* @returns
|
|
122
|
+
* @param entity - The entity to index
|
|
123
|
+
* @param suffix - Optional suffix override (defaults to archived/deleted state)
|
|
124
|
+
* @returns A new entity with timestamps bumped and index keys populated
|
|
184
125
|
*/
|
|
185
|
-
function indexEntity(entity, suffix
|
|
126
|
+
function indexEntity(entity, suffix) {
|
|
127
|
+
const now = new Date().toISOString();
|
|
128
|
+
const bumped = {
|
|
129
|
+
...entity,
|
|
130
|
+
createdAt: entity.createdAt ?? now,
|
|
131
|
+
updatedAt: now,
|
|
132
|
+
};
|
|
186
133
|
const indexes = getModelIndexes(entity.model);
|
|
187
|
-
|
|
188
|
-
return populateIndexKeys(entity, indexes, suffix);
|
|
134
|
+
return populateIndexKeys(bumped, indexes, suffix);
|
|
189
135
|
}
|
|
190
136
|
|
|
191
137
|
/**
|
|
@@ -206,20 +152,19 @@ function calculateEntitySuffix(entity) {
|
|
|
206
152
|
return "";
|
|
207
153
|
}
|
|
208
154
|
/**
|
|
209
|
-
* Get a single entity by primary key
|
|
155
|
+
* Get a single entity by primary key (id)
|
|
210
156
|
*/
|
|
211
157
|
const getEntity = fabricService({
|
|
212
158
|
alias: "getEntity",
|
|
213
|
-
description: "Get a single entity by
|
|
159
|
+
description: "Get a single entity by id",
|
|
214
160
|
input: {
|
|
215
|
-
id: { type: String, description: "Entity
|
|
216
|
-
model: { type: String, description: "Entity model (partition key)" },
|
|
161
|
+
id: { type: String, description: "Entity id (partition key)" },
|
|
217
162
|
},
|
|
218
|
-
service: async ({ id
|
|
163
|
+
service: async ({ id }) => {
|
|
219
164
|
const docClient = getDocClient();
|
|
220
165
|
const tableName = getTableName();
|
|
221
166
|
const command = new GetCommand({
|
|
222
|
-
Key: { id
|
|
167
|
+
Key: { id },
|
|
223
168
|
TableName: tableName,
|
|
224
169
|
});
|
|
225
170
|
const response = await docClient.send(command);
|
|
@@ -227,39 +172,38 @@ const getEntity = fabricService({
|
|
|
227
172
|
},
|
|
228
173
|
});
|
|
229
174
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* Note: This is a regular async function (not fabricService) because it accepts
|
|
234
|
-
* complex StorableEntity objects that can't be coerced by vocabulary's type system.
|
|
175
|
+
* Create an entity. Fails the conditional write if `id` already exists,
|
|
176
|
+
* returning `null` instead of throwing. Use `updateEntity` to overwrite.
|
|
177
|
+
* `indexEntity` auto-bumps `updatedAt` and backfills `createdAt`.
|
|
235
178
|
*/
|
|
236
|
-
async function
|
|
179
|
+
async function createEntity({ entity, }) {
|
|
237
180
|
const docClient = getDocClient();
|
|
238
181
|
const tableName = getTableName();
|
|
239
|
-
// Auto-populate index keys
|
|
240
182
|
const indexedEntity = indexEntity(entity);
|
|
241
183
|
const command = new PutCommand({
|
|
184
|
+
ConditionExpression: "attribute_not_exists(id)",
|
|
242
185
|
Item: indexedEntity,
|
|
243
186
|
TableName: tableName,
|
|
244
187
|
});
|
|
245
|
-
|
|
188
|
+
try {
|
|
189
|
+
await docClient.send(command);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error?.name === "ConditionalCheckFailedException") {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
246
197
|
return indexedEntity;
|
|
247
198
|
}
|
|
248
199
|
/**
|
|
249
|
-
* Update an existing entity
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* Note: This is a regular async function (not fabricService) because it accepts
|
|
253
|
-
* complex StorableEntity objects that can't be coerced by vocabulary's type system.
|
|
200
|
+
* Update an existing entity.
|
|
201
|
+
* `indexEntity` auto-bumps `updatedAt` — callers never set it manually.
|
|
254
202
|
*/
|
|
255
203
|
async function updateEntity({ entity, }) {
|
|
256
204
|
const docClient = getDocClient();
|
|
257
205
|
const tableName = getTableName();
|
|
258
|
-
|
|
259
|
-
const updatedEntity = indexEntity({
|
|
260
|
-
...entity,
|
|
261
|
-
updatedAt: new Date().toISOString(),
|
|
262
|
-
});
|
|
206
|
+
const updatedEntity = indexEntity(entity);
|
|
263
207
|
const command = new PutCommand({
|
|
264
208
|
Item: updatedEntity,
|
|
265
209
|
TableName: tableName,
|
|
@@ -275,25 +219,21 @@ const deleteEntity = fabricService({
|
|
|
275
219
|
alias: "deleteEntity",
|
|
276
220
|
description: "Soft delete an entity (sets deletedAt timestamp)",
|
|
277
221
|
input: {
|
|
278
|
-
id: { type: String, description: "Entity
|
|
279
|
-
model: { type: String, description: "Entity model (partition key)" },
|
|
222
|
+
id: { type: String, description: "Entity id" },
|
|
280
223
|
},
|
|
281
|
-
service: async ({ id
|
|
224
|
+
service: async ({ id }) => {
|
|
282
225
|
const docClient = getDocClient();
|
|
283
226
|
const tableName = getTableName();
|
|
284
|
-
|
|
285
|
-
const existing = await getEntity({ id, model });
|
|
227
|
+
const existing = await getEntity({ id });
|
|
286
228
|
if (!existing) {
|
|
287
229
|
return false;
|
|
288
230
|
}
|
|
289
231
|
const now = new Date().toISOString();
|
|
290
|
-
//
|
|
232
|
+
// indexEntity will bump updatedAt again; set deletedAt here.
|
|
291
233
|
const updatedEntity = {
|
|
292
234
|
...existing,
|
|
293
235
|
deletedAt: now,
|
|
294
|
-
updatedAt: now,
|
|
295
236
|
};
|
|
296
|
-
// Calculate suffix based on combined state (may already be archived)
|
|
297
237
|
const suffix = calculateEntitySuffix(updatedEntity);
|
|
298
238
|
const deletedEntity = indexEntity(updatedEntity, suffix);
|
|
299
239
|
const command = new PutCommand({
|
|
@@ -312,25 +252,20 @@ const archiveEntity = fabricService({
|
|
|
312
252
|
alias: "archiveEntity",
|
|
313
253
|
description: "Archive an entity (sets archivedAt timestamp)",
|
|
314
254
|
input: {
|
|
315
|
-
id: { type: String, description: "Entity
|
|
316
|
-
model: { type: String, description: "Entity model (partition key)" },
|
|
255
|
+
id: { type: String, description: "Entity id" },
|
|
317
256
|
},
|
|
318
|
-
service: async ({ id
|
|
257
|
+
service: async ({ id }) => {
|
|
319
258
|
const docClient = getDocClient();
|
|
320
259
|
const tableName = getTableName();
|
|
321
|
-
|
|
322
|
-
const existing = await getEntity({ id, model });
|
|
260
|
+
const existing = await getEntity({ id });
|
|
323
261
|
if (!existing) {
|
|
324
262
|
return false;
|
|
325
263
|
}
|
|
326
264
|
const now = new Date().toISOString();
|
|
327
|
-
// Build updated entity with archivedAt
|
|
328
265
|
const updatedEntity = {
|
|
329
266
|
...existing,
|
|
330
267
|
archivedAt: now,
|
|
331
|
-
updatedAt: now,
|
|
332
268
|
};
|
|
333
|
-
// Calculate suffix based on combined state (may already be deleted)
|
|
334
269
|
const suffix = calculateEntitySuffix(updatedEntity);
|
|
335
270
|
const archivedEntity = indexEntity(updatedEntity, suffix);
|
|
336
271
|
const command = new PutCommand({
|
|
@@ -349,14 +284,13 @@ const destroyEntity = fabricService({
|
|
|
349
284
|
alias: "destroyEntity",
|
|
350
285
|
description: "Hard delete an entity (permanently removes from table)",
|
|
351
286
|
input: {
|
|
352
|
-
id: { type: String, description: "Entity
|
|
353
|
-
model: { type: String, description: "Entity model (partition key)" },
|
|
287
|
+
id: { type: String, description: "Entity id" },
|
|
354
288
|
},
|
|
355
|
-
service: async ({ id
|
|
289
|
+
service: async ({ id }) => {
|
|
356
290
|
const docClient = getDocClient();
|
|
357
291
|
const tableName = getTableName();
|
|
358
292
|
const command = new DeleteCommand({
|
|
359
|
-
Key: { id
|
|
293
|
+
Key: { id },
|
|
360
294
|
TableName: tableName,
|
|
361
295
|
});
|
|
362
296
|
await docClient.send(command);
|
|
@@ -382,11 +316,15 @@ async function transactWriteEntities({ entities, }) {
|
|
|
382
316
|
await docClient.send(command);
|
|
383
317
|
}
|
|
384
318
|
|
|
319
|
+
// =============================================================================
|
|
320
|
+
// Helpers
|
|
321
|
+
// =============================================================================
|
|
385
322
|
/**
|
|
386
|
-
* Calculate the suffix based on archived/deleted
|
|
387
|
-
*
|
|
323
|
+
* Calculate the suffix for the GSI partition key based on archived/deleted
|
|
324
|
+
* flags. Suffix stays on pk so deleted/archived entities are queried as their
|
|
325
|
+
* own partition (active queries skip them naturally).
|
|
388
326
|
*/
|
|
389
|
-
function calculateSuffix
|
|
327
|
+
function calculateSuffix({ archived, deleted, }) {
|
|
390
328
|
if (archived && deleted) {
|
|
391
329
|
return ARCHIVED_SUFFIX + DELETED_SUFFIX;
|
|
392
330
|
}
|
|
@@ -399,22 +337,51 @@ function calculateSuffix$1({ archived, deleted, }) {
|
|
|
399
337
|
return "";
|
|
400
338
|
}
|
|
401
339
|
/**
|
|
402
|
-
*
|
|
340
|
+
* Find the registered index for a model that matches a given partition-key
|
|
341
|
+
* shape. The matching index is the first one whose pk equals the expected
|
|
342
|
+
* fields. Throws ConfigurationError if no match is found.
|
|
403
343
|
*/
|
|
404
|
-
|
|
405
|
-
const
|
|
344
|
+
function requireIndex(model, pkFields) {
|
|
345
|
+
const indexes = getModelIndexes(model);
|
|
346
|
+
const match = indexes.find((index) => index.pk.length === pkFields.length &&
|
|
347
|
+
index.pk.every((field, i) => field === pkFields[i]));
|
|
348
|
+
if (!match) {
|
|
349
|
+
throw new ConfigurationError(`Model "${model}" has no index with pk=[${pkFields.join(", ")}]. ` +
|
|
350
|
+
`Register one with fabricIndex(${pkFields.length > 1 ? `"${pkFields[1]}"` : ""}).`);
|
|
351
|
+
}
|
|
352
|
+
return match;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Execute a GSI query.
|
|
356
|
+
*
|
|
357
|
+
* - pk: exact match on the index partition key
|
|
358
|
+
* - skPrefix: optional begins_with on the index sort key (used when the index
|
|
359
|
+
* has a composite sk like [scope, updatedAt])
|
|
360
|
+
*/
|
|
361
|
+
async function executeQuery(index, pkValue, options = {}) {
|
|
362
|
+
const { ascending = false, limit, skPrefix, startKey } = options;
|
|
363
|
+
const attrs = getGsiAttributeNames(index);
|
|
364
|
+
const indexName = attrs.pk;
|
|
365
|
+
const expressionAttributeNames = {
|
|
366
|
+
"#pk": indexName,
|
|
367
|
+
};
|
|
368
|
+
const expressionAttributeValues = {
|
|
369
|
+
":pkValue": pkValue,
|
|
370
|
+
};
|
|
371
|
+
let keyConditionExpression = "#pk = :pkValue";
|
|
372
|
+
if (skPrefix !== undefined && attrs.sk) {
|
|
373
|
+
expressionAttributeNames["#sk"] = attrs.sk;
|
|
374
|
+
expressionAttributeValues[":skPrefix"] = skPrefix;
|
|
375
|
+
keyConditionExpression += " AND begins_with(#sk, :skPrefix)";
|
|
376
|
+
}
|
|
406
377
|
const docClient = getDocClient();
|
|
407
378
|
const tableName = getTableName();
|
|
408
379
|
const command = new QueryCommand({
|
|
409
380
|
ExclusiveStartKey: startKey,
|
|
410
|
-
ExpressionAttributeNames:
|
|
411
|
-
|
|
412
|
-
},
|
|
413
|
-
ExpressionAttributeValues: {
|
|
414
|
-
":pkValue": keyValue,
|
|
415
|
-
},
|
|
381
|
+
ExpressionAttributeNames: expressionAttributeNames,
|
|
382
|
+
ExpressionAttributeValues: expressionAttributeValues,
|
|
416
383
|
IndexName: indexName,
|
|
417
|
-
KeyConditionExpression:
|
|
384
|
+
KeyConditionExpression: keyConditionExpression,
|
|
418
385
|
...(limit && { Limit: limit }),
|
|
419
386
|
ScanIndexForward: ascending,
|
|
420
387
|
TableName: tableName,
|
|
@@ -425,25 +392,30 @@ async function executeQuery(indexName, keyValue, options = {}) {
|
|
|
425
392
|
lastEvaluatedKey: response.LastEvaluatedKey,
|
|
426
393
|
};
|
|
427
394
|
}
|
|
395
|
+
function scopePrefix(scope) {
|
|
396
|
+
return scope === undefined ? undefined : `${scope}${SEPARATOR}`;
|
|
397
|
+
}
|
|
398
|
+
// =============================================================================
|
|
399
|
+
// Query Functions
|
|
400
|
+
// =============================================================================
|
|
428
401
|
/**
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
* Note: This is a regular async function (not fabricService) because it accepts
|
|
433
|
-
* complex startKey objects that can't be coerced by vocabulary's type system.
|
|
402
|
+
* List entities of a model, optionally narrowed to a scope.
|
|
403
|
+
* Requires the model to register `fabricIndex()` (pk=[model]).
|
|
434
404
|
*/
|
|
435
405
|
async function queryByScope({ archived = false, ascending = false, deleted = false, limit, model, scope, startKey, }) {
|
|
436
|
-
const
|
|
437
|
-
const
|
|
438
|
-
|
|
406
|
+
const index = requireIndex(model, ["model"]);
|
|
407
|
+
const suffix = calculateSuffix({ archived, deleted });
|
|
408
|
+
const pkValue = buildCompositeKey({ model }, ["model"], suffix);
|
|
409
|
+
return executeQuery(index, pkValue, {
|
|
439
410
|
ascending,
|
|
440
411
|
limit,
|
|
412
|
+
skPrefix: scopePrefix(scope),
|
|
441
413
|
startKey,
|
|
442
414
|
});
|
|
443
415
|
}
|
|
444
416
|
/**
|
|
445
|
-
* Query a single entity by human-friendly alias
|
|
446
|
-
*
|
|
417
|
+
* Query a single entity by human-friendly alias.
|
|
418
|
+
* Requires the model to register `fabricIndex("alias")`.
|
|
447
419
|
*/
|
|
448
420
|
const queryByAlias = fabricService({
|
|
449
421
|
alias: "queryByAlias",
|
|
@@ -463,7 +435,11 @@ const queryByAlias = fabricService({
|
|
|
463
435
|
description: "Query deleted entities instead of active ones",
|
|
464
436
|
},
|
|
465
437
|
model: { type: String, description: "Entity model name" },
|
|
466
|
-
scope: {
|
|
438
|
+
scope: {
|
|
439
|
+
type: String,
|
|
440
|
+
required: false,
|
|
441
|
+
description: "Optional scope narrower (begins_with on sk)",
|
|
442
|
+
},
|
|
467
443
|
},
|
|
468
444
|
service: async ({ alias, archived, deleted, model, scope, }) => {
|
|
469
445
|
const aliasStr = alias;
|
|
@@ -471,52 +447,52 @@ const queryByAlias = fabricService({
|
|
|
471
447
|
const deletedBool = deleted;
|
|
472
448
|
const modelStr = model;
|
|
473
449
|
const scopeStr = scope;
|
|
474
|
-
const
|
|
450
|
+
const index = requireIndex(modelStr, ["model", "alias"]);
|
|
451
|
+
const suffix = calculateSuffix({
|
|
475
452
|
archived: archivedBool,
|
|
476
453
|
deleted: deletedBool,
|
|
477
454
|
});
|
|
478
|
-
const
|
|
479
|
-
const result = await executeQuery(
|
|
455
|
+
const pkValue = buildCompositeKey({ model: modelStr, alias: aliasStr }, ["model", "alias"], suffix);
|
|
456
|
+
const result = await executeQuery(index, pkValue, {
|
|
480
457
|
limit: 1,
|
|
458
|
+
skPrefix: scopePrefix(scopeStr),
|
|
481
459
|
});
|
|
482
460
|
return result.items[0] ?? null;
|
|
483
461
|
},
|
|
484
462
|
});
|
|
485
463
|
/**
|
|
486
|
-
* Query entities by category classification
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
* Note: This is a regular async function (not fabricService) because it accepts
|
|
490
|
-
* complex startKey objects that can't be coerced by vocabulary's type system.
|
|
464
|
+
* Query entities by category classification.
|
|
465
|
+
* Requires the model to register `fabricIndex("category")`.
|
|
491
466
|
*/
|
|
492
467
|
async function queryByCategory({ archived = false, ascending = false, category, deleted = false, limit, model, scope, startKey, }) {
|
|
493
|
-
const
|
|
494
|
-
const
|
|
495
|
-
|
|
468
|
+
const index = requireIndex(model, ["model", "category"]);
|
|
469
|
+
const suffix = calculateSuffix({ archived, deleted });
|
|
470
|
+
const pkValue = buildCompositeKey({ model, category }, ["model", "category"], suffix);
|
|
471
|
+
return executeQuery(index, pkValue, {
|
|
496
472
|
ascending,
|
|
497
473
|
limit,
|
|
474
|
+
skPrefix: scopePrefix(scope),
|
|
498
475
|
startKey,
|
|
499
476
|
});
|
|
500
477
|
}
|
|
501
478
|
/**
|
|
502
|
-
* Query entities by type classification
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
* Note: This is a regular async function (not fabricService) because it accepts
|
|
506
|
-
* complex startKey objects that can't be coerced by vocabulary's type system.
|
|
479
|
+
* Query entities by type classification.
|
|
480
|
+
* Requires the model to register `fabricIndex("type")`.
|
|
507
481
|
*/
|
|
508
482
|
async function queryByType({ archived = false, ascending = false, deleted = false, limit, model, scope, startKey, type, }) {
|
|
509
|
-
const
|
|
510
|
-
const
|
|
511
|
-
|
|
483
|
+
const index = requireIndex(model, ["model", "type"]);
|
|
484
|
+
const suffix = calculateSuffix({ archived, deleted });
|
|
485
|
+
const pkValue = buildCompositeKey({ model, type }, ["model", "type"], suffix);
|
|
486
|
+
return executeQuery(index, pkValue, {
|
|
512
487
|
ascending,
|
|
513
488
|
limit,
|
|
489
|
+
skPrefix: scopePrefix(scope),
|
|
514
490
|
startKey,
|
|
515
491
|
});
|
|
516
492
|
}
|
|
517
493
|
/**
|
|
518
|
-
* Query a single entity by external ID
|
|
519
|
-
*
|
|
494
|
+
* Query a single entity by external ID.
|
|
495
|
+
* Requires the model to register `fabricIndex("xid")`.
|
|
520
496
|
*/
|
|
521
497
|
const queryByXid = fabricService({
|
|
522
498
|
alias: "queryByXid",
|
|
@@ -535,7 +511,11 @@ const queryByXid = fabricService({
|
|
|
535
511
|
description: "Query deleted entities instead of active ones",
|
|
536
512
|
},
|
|
537
513
|
model: { type: String, description: "Entity model name" },
|
|
538
|
-
scope: {
|
|
514
|
+
scope: {
|
|
515
|
+
type: String,
|
|
516
|
+
required: false,
|
|
517
|
+
description: "Optional scope narrower (begins_with on sk)",
|
|
518
|
+
},
|
|
539
519
|
xid: { type: String, description: "External ID" },
|
|
540
520
|
},
|
|
541
521
|
service: async ({ archived, deleted, model, scope, xid, }) => {
|
|
@@ -544,13 +524,15 @@ const queryByXid = fabricService({
|
|
|
544
524
|
const modelStr = model;
|
|
545
525
|
const scopeStr = scope;
|
|
546
526
|
const xidStr = xid;
|
|
547
|
-
const
|
|
527
|
+
const index = requireIndex(modelStr, ["model", "xid"]);
|
|
528
|
+
const suffix = calculateSuffix({
|
|
548
529
|
archived: archivedBool,
|
|
549
530
|
deleted: deletedBool,
|
|
550
531
|
});
|
|
551
|
-
const
|
|
552
|
-
const result = await executeQuery(
|
|
532
|
+
const pkValue = buildCompositeKey({ model: modelStr, xid: xidStr }, ["model", "xid"], suffix);
|
|
533
|
+
const result = await executeQuery(index, pkValue, {
|
|
553
534
|
limit: 1,
|
|
535
|
+
skPrefix: scopePrefix(scopeStr),
|
|
554
536
|
});
|
|
555
537
|
return result.items[0] ?? null;
|
|
556
538
|
},
|
|
@@ -564,162 +546,77 @@ const queryByXid = fabricService({
|
|
|
564
546
|
* removing the need to know which specific GSI to use.
|
|
565
547
|
*/
|
|
566
548
|
// =============================================================================
|
|
567
|
-
//
|
|
549
|
+
// Index Selection
|
|
568
550
|
// =============================================================================
|
|
569
551
|
/**
|
|
570
|
-
*
|
|
571
|
-
*/
|
|
572
|
-
function calculateSuffix(archived, deleted) {
|
|
573
|
-
if (archived && deleted) {
|
|
574
|
-
return ARCHIVED_SUFFIX + DELETED_SUFFIX;
|
|
575
|
-
}
|
|
576
|
-
if (archived) {
|
|
577
|
-
return ARCHIVED_SUFFIX;
|
|
578
|
-
}
|
|
579
|
-
if (deleted) {
|
|
580
|
-
return DELETED_SUFFIX;
|
|
581
|
-
}
|
|
582
|
-
return "";
|
|
583
|
-
}
|
|
584
|
-
/**
|
|
585
|
-
* Build a combined filter object from params
|
|
586
|
-
*/
|
|
587
|
-
function buildFilterObject(params) {
|
|
588
|
-
const result = {
|
|
589
|
-
model: params.model,
|
|
590
|
-
};
|
|
591
|
-
if (params.scope !== undefined) {
|
|
592
|
-
result.scope = params.scope;
|
|
593
|
-
}
|
|
594
|
-
if (params.filter) {
|
|
595
|
-
Object.assign(result, params.filter);
|
|
596
|
-
}
|
|
597
|
-
return result;
|
|
598
|
-
}
|
|
599
|
-
/**
|
|
600
|
-
* Score an index based on how well it matches the filter fields
|
|
601
|
-
*/
|
|
602
|
-
function scoreIndex(index, filterFields) {
|
|
603
|
-
let matchedFields = 0;
|
|
604
|
-
let pkComplete = true;
|
|
605
|
-
for (const field of index.pk) {
|
|
606
|
-
if (filterFields[field] !== undefined) {
|
|
607
|
-
matchedFields++;
|
|
608
|
-
}
|
|
609
|
-
else {
|
|
610
|
-
pkComplete = false;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
return {
|
|
614
|
-
index,
|
|
615
|
-
matchedFields,
|
|
616
|
-
pkComplete,
|
|
617
|
-
};
|
|
618
|
-
}
|
|
619
|
-
/**
|
|
620
|
-
* Select the best index for the given filter
|
|
552
|
+
* Select the best index for the given filter.
|
|
621
553
|
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
554
|
+
* Every model-level index has `model` as its first pk field. The picker
|
|
555
|
+
* prefers the most specific index whose remaining pk fields are all
|
|
556
|
+
* satisfied by the filter.
|
|
557
|
+
*/
|
|
558
|
+
function selectBestIndex(indexes, filter) {
|
|
559
|
+
// Candidates: indexes whose pk starts with "model" and whose remaining
|
|
560
|
+
// fields are all present in the filter.
|
|
561
|
+
const candidates = indexes.filter((index) => {
|
|
562
|
+
if (index.pk.length === 0 || index.pk[0] !== "model")
|
|
563
|
+
return false;
|
|
564
|
+
for (let i = 1; i < index.pk.length; i++) {
|
|
565
|
+
if (filter[index.pk[i]] === undefined)
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
return true;
|
|
569
|
+
});
|
|
570
|
+
if (candidates.length === 0) {
|
|
571
|
+
const available = indexes
|
|
572
|
+
.map((i) => `[${i.pk.join(", ")}]`)
|
|
634
573
|
.join(", ");
|
|
635
|
-
const
|
|
636
|
-
throw new ConfigurationError(`No index matches filter
|
|
637
|
-
`
|
|
638
|
-
`Available indexes: ${availableIndexes}`);
|
|
574
|
+
const provided = Object.keys(filter).join(", ") || "(none)";
|
|
575
|
+
throw new ConfigurationError(`No index matches filter for model. ` +
|
|
576
|
+
`Filter fields: ${provided}. Available indexes: ${available}`);
|
|
639
577
|
}
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
completeMatches.sort((a, b) => {
|
|
644
|
-
const fieldDiff = b.matchedFields - a.matchedFields;
|
|
645
|
-
if (fieldDiff !== 0)
|
|
646
|
-
return fieldDiff;
|
|
647
|
-
return b.index.pk.length - a.index.pk.length;
|
|
648
|
-
});
|
|
649
|
-
return completeMatches[0].index;
|
|
578
|
+
// Prefer the most specific index (longest pk).
|
|
579
|
+
candidates.sort((a, b) => b.pk.length - a.pk.length);
|
|
580
|
+
return candidates[0];
|
|
650
581
|
}
|
|
651
582
|
// =============================================================================
|
|
652
583
|
// Main Query Function
|
|
653
584
|
// =============================================================================
|
|
654
585
|
/**
|
|
655
|
-
* Query entities with automatic index selection
|
|
656
|
-
*
|
|
657
|
-
* The query function automatically selects the best GSI based on
|
|
658
|
-
* the filter fields provided. This removes the need to know which
|
|
659
|
-
* specific query function (queryByOu, queryByAlias, etc.) to use.
|
|
586
|
+
* Query entities with automatic index selection.
|
|
660
587
|
*
|
|
661
588
|
* @example
|
|
662
|
-
*
|
|
663
|
-
*
|
|
589
|
+
* // Uses indexModel (pk: ["model"]), optionally narrowed by scope
|
|
590
|
+
* const records = await query({ model: "record", scope: "@" });
|
|
664
591
|
*
|
|
665
592
|
* @example
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
593
|
+
* // Uses indexModelAlias (pk: ["model", "alias"])
|
|
594
|
+
* const byAlias = await query({
|
|
595
|
+
* model: "record",
|
|
596
|
+
* scope: "@",
|
|
597
|
+
* filter: { alias: "my-record" },
|
|
598
|
+
* });
|
|
672
599
|
*
|
|
673
600
|
* @example
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
* model: "message",
|
|
677
|
-
* filter: { chatId: "abc-123" },
|
|
678
|
-
* });
|
|
601
|
+
* // Cross-scope listing (no scope narrower)
|
|
602
|
+
* const all = await query({ model: "record" });
|
|
679
603
|
*/
|
|
680
604
|
async function query(params) {
|
|
681
|
-
const { archived = false, ascending = false, deleted = false, limit, model, startKey, } = params;
|
|
682
|
-
// Build the combined filter object
|
|
683
|
-
const filterFields = buildFilterObject(params);
|
|
684
|
-
// Get indexes for this model
|
|
605
|
+
const { archived = false, ascending = false, deleted = false, filter, limit, model, scope, startKey, } = params;
|
|
685
606
|
const indexes = getModelIndexes(model);
|
|
686
|
-
|
|
607
|
+
const filterFields = {
|
|
608
|
+
model,
|
|
609
|
+
...filter,
|
|
610
|
+
};
|
|
687
611
|
const selectedIndex = selectBestIndex(indexes, filterFields);
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
const command = new QueryCommand({
|
|
696
|
-
ExclusiveStartKey: startKey,
|
|
697
|
-
ExpressionAttributeNames: {
|
|
698
|
-
"#pk": indexName,
|
|
699
|
-
},
|
|
700
|
-
ExpressionAttributeValues: {
|
|
701
|
-
":pkValue": keyValue,
|
|
702
|
-
},
|
|
703
|
-
IndexName: indexName,
|
|
704
|
-
KeyConditionExpression: "#pk = :pkValue",
|
|
705
|
-
...(limit && { Limit: limit }),
|
|
706
|
-
ScanIndexForward: ascending,
|
|
707
|
-
TableName: tableName,
|
|
612
|
+
const suffix = calculateSuffix({ archived, deleted });
|
|
613
|
+
const pkValue = buildCompositeKey(filterFields, selectedIndex.pk, suffix);
|
|
614
|
+
return executeQuery(selectedIndex, pkValue, {
|
|
615
|
+
ascending,
|
|
616
|
+
limit,
|
|
617
|
+
skPrefix: scopePrefix(scope),
|
|
618
|
+
startKey,
|
|
708
619
|
});
|
|
709
|
-
const response = await docClient.send(command);
|
|
710
|
-
return {
|
|
711
|
-
items: (response.Items ?? []),
|
|
712
|
-
lastEvaluatedKey: response.LastEvaluatedKey,
|
|
713
|
-
};
|
|
714
|
-
}
|
|
715
|
-
/**
|
|
716
|
-
* Generate an index name from pk fields
|
|
717
|
-
*/
|
|
718
|
-
function generateIndexName(pk) {
|
|
719
|
-
const suffix = pk
|
|
720
|
-
.map((field) => field.charAt(0).toUpperCase() + field.slice(1))
|
|
721
|
-
.join("");
|
|
722
|
-
return `index${suffix}`;
|
|
723
620
|
}
|
|
724
621
|
|
|
725
622
|
/**
|
|
@@ -741,19 +638,15 @@ async function seedEntityIfNotExists(entity) {
|
|
|
741
638
|
if (existing) {
|
|
742
639
|
return false;
|
|
743
640
|
}
|
|
744
|
-
// Generate required fields if missing
|
|
745
|
-
const now = new Date().toISOString();
|
|
641
|
+
// Generate required fields if missing; indexEntity manages timestamps
|
|
746
642
|
const completeEntity = {
|
|
747
|
-
createdAt: entity.createdAt ?? now,
|
|
748
643
|
id: entity.id ?? crypto.randomUUID(),
|
|
749
644
|
model: entity.model,
|
|
750
645
|
name: entity.name ?? entity.alias,
|
|
751
646
|
scope: entity.scope,
|
|
752
|
-
sequence: entity.sequence ?? Date.now(),
|
|
753
|
-
updatedAt: entity.updatedAt ?? now,
|
|
754
647
|
...entity,
|
|
755
648
|
};
|
|
756
|
-
await
|
|
649
|
+
await createEntity({ entity: completeEntity });
|
|
757
650
|
return true;
|
|
758
651
|
}
|
|
759
652
|
/**
|
|
@@ -782,6 +675,7 @@ async function seedEntities(entities, options = {}) {
|
|
|
782
675
|
throw new Error("Entity must have model and scope");
|
|
783
676
|
}
|
|
784
677
|
// For entities with alias, check existence
|
|
678
|
+
let isReplace = false;
|
|
785
679
|
if (entity.alias) {
|
|
786
680
|
const existing = await queryByAlias({
|
|
787
681
|
alias: entity.alias,
|
|
@@ -795,25 +689,27 @@ async function seedEntities(entities, options = {}) {
|
|
|
795
689
|
// If replacing, use existing ID to update rather than create new
|
|
796
690
|
if (existing && replace) {
|
|
797
691
|
entity.id = existing.id;
|
|
692
|
+
isReplace = true;
|
|
798
693
|
}
|
|
799
694
|
}
|
|
800
695
|
if (dryRun) {
|
|
801
696
|
result.created.push(alias);
|
|
802
697
|
continue;
|
|
803
698
|
}
|
|
804
|
-
// Generate required fields if missing
|
|
805
|
-
const now = new Date().toISOString();
|
|
699
|
+
// Generate required fields if missing; indexEntity manages timestamps
|
|
806
700
|
const completeEntity = {
|
|
807
|
-
createdAt: entity.createdAt ?? now,
|
|
808
701
|
id: entity.id ?? crypto.randomUUID(),
|
|
809
702
|
model: entity.model,
|
|
810
703
|
name: entity.name ?? entity.alias ?? "Unnamed",
|
|
811
704
|
scope: entity.scope,
|
|
812
|
-
sequence: entity.sequence ?? Date.now(),
|
|
813
|
-
updatedAt: entity.updatedAt ?? now,
|
|
814
705
|
...entity,
|
|
815
706
|
};
|
|
816
|
-
|
|
707
|
+
if (isReplace) {
|
|
708
|
+
await updateEntity({ entity: completeEntity });
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
await createEntity({ entity: completeEntity });
|
|
712
|
+
}
|
|
817
713
|
result.created.push(alias);
|
|
818
714
|
}
|
|
819
715
|
catch (error) {
|
|
@@ -871,5 +767,5 @@ async function exportEntitiesToJson(model, scope, pretty = true) {
|
|
|
871
767
|
return pretty ? JSON.stringify(entities, null, 2) : JSON.stringify(entities);
|
|
872
768
|
}
|
|
873
769
|
|
|
874
|
-
export {
|
|
770
|
+
export { archiveEntity, buildCompositeKey, calculateScope, createEntity, deleteEntity, destroyEntity, exportEntities, exportEntitiesToJson, getDocClient, getEntity, getTableName, indexEntity, initClient, isInitialized, query, queryByAlias, queryByCategory, queryByScope, queryByType, queryByXid, resetClient, seedEntities, seedEntityIfNotExists, transactWriteEntities, updateEntity };
|
|
875
771
|
//# sourceMappingURL=index.js.map
|