@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/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, SEPARATOR, buildCompositeKey as buildCompositeKey$1, APEX, calculateScope as calculateScope$1, fabricService, ARCHIVED_SUFFIX, DELETED_SUFFIX } from '@jaypie/fabric';
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
- * Uses the model's registered indexes (from the fabric registry).
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
- * - indexScope is always populated from scope + model
176
- * - indexAlias is populated only when alias is present
177
- * - indexCategory is populated only when category is present
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 populate index keys for
182
- * @param suffix - Optional suffix to append to all index keys (e.g., "#deleted", "#archived")
183
- * @returns The entity with populated index keys
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
- // Cast through unknown to bridge the type gap between StorableEntity and IndexableModel
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 primary key",
159
+ description: "Get a single entity by id",
214
160
  input: {
215
- id: { type: String, description: "Entity ID (sort key)" },
216
- model: { type: String, description: "Entity model (partition key)" },
161
+ id: { type: String, description: "Entity id (partition key)" },
217
162
  },
218
- service: async ({ id, model }) => {
163
+ service: async ({ id }) => {
219
164
  const docClient = getDocClient();
220
165
  const tableName = getTableName();
221
166
  const command = new GetCommand({
222
- Key: { id, model },
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
- * Put (create or replace) an entity
231
- * Auto-populates GSI index keys via indexEntity
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 putEntity({ entity, }) {
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
- await docClient.send(command);
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
- * Auto-populates GSI index keys and sets updatedAt
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
- // Update timestamp and re-index
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 ID (sort key)" },
279
- model: { type: String, description: "Entity model (partition key)" },
222
+ id: { type: String, description: "Entity id" },
280
223
  },
281
- service: async ({ id, model }) => {
224
+ service: async ({ id }) => {
282
225
  const docClient = getDocClient();
283
226
  const tableName = getTableName();
284
- // Fetch the current entity
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
- // Build updated entity with deletedAt
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 ID (sort key)" },
316
- model: { type: String, description: "Entity model (partition key)" },
255
+ id: { type: String, description: "Entity id" },
317
256
  },
318
- service: async ({ id, model }) => {
257
+ service: async ({ id }) => {
319
258
  const docClient = getDocClient();
320
259
  const tableName = getTableName();
321
- // Fetch the current entity
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 ID (sort key)" },
353
- model: { type: String, description: "Entity model (partition key)" },
287
+ id: { type: String, description: "Entity id" },
354
288
  },
355
- service: async ({ id, model }) => {
289
+ service: async ({ id }) => {
356
290
  const docClient = getDocClient();
357
291
  const tableName = getTableName();
358
292
  const command = new DeleteCommand({
359
- Key: { id, model },
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 flags
387
- * When both are true, returns combined suffix (archived first, alphabetically)
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$1({ archived, deleted, }) {
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
- * Execute a GSI query with common options
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
- async function executeQuery(indexName, keyValue, options = {}) {
405
- const { ascending = false, limit, startKey } = options;
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
- "#pk": indexName,
412
- },
413
- ExpressionAttributeValues: {
414
- ":pkValue": keyValue,
415
- },
381
+ ExpressionAttributeNames: expressionAttributeNames,
382
+ ExpressionAttributeValues: expressionAttributeValues,
416
383
  IndexName: indexName,
417
- KeyConditionExpression: "#pk = :pkValue",
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
- * Query entities by scope (parent hierarchy)
430
- * Uses indexScope GSI
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 suffix = calculateSuffix$1({ archived, deleted });
437
- const keyValue = buildIndexScope(scope, model) + suffix;
438
- return executeQuery(INDEX_SCOPE, keyValue, {
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
- * Uses indexAlias GSI
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: { type: String, description: "Scope (@ for root)" },
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 suffix = calculateSuffix$1({
450
+ const index = requireIndex(modelStr, ["model", "alias"]);
451
+ const suffix = calculateSuffix({
475
452
  archived: archivedBool,
476
453
  deleted: deletedBool,
477
454
  });
478
- const keyValue = buildIndexAlias(scopeStr, modelStr, aliasStr) + suffix;
479
- const result = await executeQuery(INDEX_ALIAS, keyValue, {
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
- * Uses indexCategory GSI
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 suffix = calculateSuffix$1({ archived, deleted });
494
- const keyValue = buildIndexCategory(scope, model, category) + suffix;
495
- return executeQuery(INDEX_CATEGORY, keyValue, {
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
- * Uses indexType GSI
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 suffix = calculateSuffix$1({ archived, deleted });
510
- const keyValue = buildIndexType(scope, model, type) + suffix;
511
- return executeQuery(INDEX_TYPE, keyValue, {
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
- * Uses indexXid GSI
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: { type: String, description: "Scope (@ for root)" },
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 suffix = calculateSuffix$1({
527
+ const index = requireIndex(modelStr, ["model", "xid"]);
528
+ const suffix = calculateSuffix({
548
529
  archived: archivedBool,
549
530
  deleted: deletedBool,
550
531
  });
551
- const keyValue = buildIndexXid(scopeStr, modelStr, xidStr) + suffix;
552
- const result = await executeQuery(INDEX_XID, keyValue, {
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
- // Helper Functions
549
+ // Index Selection
568
550
  // =============================================================================
569
551
  /**
570
- * Calculate the suffix based on archived/deleted flags
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
- * Scoring criteria:
623
- * 1. Index must have all pk fields present (pkComplete)
624
- * 2. Prefer indexes with more matched fields
625
- * 3. Prefer more specific indexes (more pk fields)
626
- */
627
- function selectBestIndex(indexes, filterFields) {
628
- const scores = indexes.map((index) => scoreIndex(index, filterFields));
629
- // Filter to only complete matches
630
- const completeMatches = scores.filter((s) => s.pkComplete);
631
- if (completeMatches.length === 0) {
632
- const availableIndexes = indexes
633
- .map((i) => i.name ?? `[${i.pk.join(", ")}]`)
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 providedFields = Object.keys(filterFields).join(", ");
636
- throw new ConfigurationError(`No index matches filter fields. ` +
637
- `Provided: ${providedFields}. ` +
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
- // Sort by:
641
- // 1. More matched fields first (descending)
642
- // 2. More pk fields (more specific) first (descending)
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
- * // Uses indexScope (pk: ["scope", "model"])
663
- * const allMessages = await query({ model: "message", scope: `chat#${chatId}` });
589
+ * // Uses indexModel (pk: ["model"]), optionally narrowed by scope
590
+ * const records = await query({ model: "record", scope: "@" });
664
591
  *
665
592
  * @example
666
- * // Uses indexAlias (pk: ["scope", "model", "alias"])
667
- * const byAlias = await query({
668
- * model: "record",
669
- * scope: "@",
670
- * filter: { alias: "my-record" },
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
- * // Uses a custom registered index if model has one
675
- * const byChat = await query({
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
- // Select the best matching index
607
+ const filterFields = {
608
+ model,
609
+ ...filter,
610
+ };
687
611
  const selectedIndex = selectBestIndex(indexes, filterFields);
688
- const indexName = selectedIndex.name ?? generateIndexName(selectedIndex.pk);
689
- // Build the partition key value
690
- const suffix = calculateSuffix(archived, deleted);
691
- const keyValue = buildCompositeKey(filterFields, selectedIndex.pk, suffix);
692
- // Execute the query
693
- const docClient = getDocClient();
694
- const tableName = getTableName();
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 putEntity({ entity: completeEntity });
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
- await putEntity({ entity: completeEntity });
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 { INDEX_ALIAS, INDEX_CATEGORY, INDEX_SCOPE, INDEX_TYPE, INDEX_XID, archiveEntity, buildCompositeKey, buildIndexAlias, buildIndexCategory, buildIndexScope, buildIndexType, buildIndexXid, calculateScope, deleteEntity, destroyEntity, exportEntities, exportEntitiesToJson, getDocClient, getEntity, getTableName, indexEntity, initClient, isInitialized, putEntity, query, queryByAlias, queryByCategory, queryByScope, queryByType, queryByXid, resetClient, seedEntities, seedEntityIfNotExists, transactWriteEntities, updateEntity };
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