@jaypie/dynamodb 0.4.3 → 0.5.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 (putEntity, 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,16 +172,12 @@ 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
+ * Put (create or replace) an entity.
176
+ * `indexEntity` auto-bumps `updatedAt` and backfills `createdAt`.
235
177
  */
236
178
  async function putEntity({ entity, }) {
237
179
  const docClient = getDocClient();
238
180
  const tableName = getTableName();
239
- // Auto-populate index keys
240
181
  const indexedEntity = indexEntity(entity);
241
182
  const command = new PutCommand({
242
183
  Item: indexedEntity,
@@ -246,20 +187,13 @@ async function putEntity({ entity, }) {
246
187
  return indexedEntity;
247
188
  }
248
189
  /**
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.
190
+ * Update an existing entity.
191
+ * `indexEntity` auto-bumps `updatedAt` callers never set it manually.
254
192
  */
255
193
  async function updateEntity({ entity, }) {
256
194
  const docClient = getDocClient();
257
195
  const tableName = getTableName();
258
- // Update timestamp and re-index
259
- const updatedEntity = indexEntity({
260
- ...entity,
261
- updatedAt: new Date().toISOString(),
262
- });
196
+ const updatedEntity = indexEntity(entity);
263
197
  const command = new PutCommand({
264
198
  Item: updatedEntity,
265
199
  TableName: tableName,
@@ -275,25 +209,21 @@ const deleteEntity = fabricService({
275
209
  alias: "deleteEntity",
276
210
  description: "Soft delete an entity (sets deletedAt timestamp)",
277
211
  input: {
278
- id: { type: String, description: "Entity ID (sort key)" },
279
- model: { type: String, description: "Entity model (partition key)" },
212
+ id: { type: String, description: "Entity id" },
280
213
  },
281
- service: async ({ id, model }) => {
214
+ service: async ({ id }) => {
282
215
  const docClient = getDocClient();
283
216
  const tableName = getTableName();
284
- // Fetch the current entity
285
- const existing = await getEntity({ id, model });
217
+ const existing = await getEntity({ id });
286
218
  if (!existing) {
287
219
  return false;
288
220
  }
289
221
  const now = new Date().toISOString();
290
- // Build updated entity with deletedAt
222
+ // indexEntity will bump updatedAt again; set deletedAt here.
291
223
  const updatedEntity = {
292
224
  ...existing,
293
225
  deletedAt: now,
294
- updatedAt: now,
295
226
  };
296
- // Calculate suffix based on combined state (may already be archived)
297
227
  const suffix = calculateEntitySuffix(updatedEntity);
298
228
  const deletedEntity = indexEntity(updatedEntity, suffix);
299
229
  const command = new PutCommand({
@@ -312,25 +242,20 @@ const archiveEntity = fabricService({
312
242
  alias: "archiveEntity",
313
243
  description: "Archive an entity (sets archivedAt timestamp)",
314
244
  input: {
315
- id: { type: String, description: "Entity ID (sort key)" },
316
- model: { type: String, description: "Entity model (partition key)" },
245
+ id: { type: String, description: "Entity id" },
317
246
  },
318
- service: async ({ id, model }) => {
247
+ service: async ({ id }) => {
319
248
  const docClient = getDocClient();
320
249
  const tableName = getTableName();
321
- // Fetch the current entity
322
- const existing = await getEntity({ id, model });
250
+ const existing = await getEntity({ id });
323
251
  if (!existing) {
324
252
  return false;
325
253
  }
326
254
  const now = new Date().toISOString();
327
- // Build updated entity with archivedAt
328
255
  const updatedEntity = {
329
256
  ...existing,
330
257
  archivedAt: now,
331
- updatedAt: now,
332
258
  };
333
- // Calculate suffix based on combined state (may already be deleted)
334
259
  const suffix = calculateEntitySuffix(updatedEntity);
335
260
  const archivedEntity = indexEntity(updatedEntity, suffix);
336
261
  const command = new PutCommand({
@@ -349,14 +274,13 @@ const destroyEntity = fabricService({
349
274
  alias: "destroyEntity",
350
275
  description: "Hard delete an entity (permanently removes from table)",
351
276
  input: {
352
- id: { type: String, description: "Entity ID (sort key)" },
353
- model: { type: String, description: "Entity model (partition key)" },
277
+ id: { type: String, description: "Entity id" },
354
278
  },
355
- service: async ({ id, model }) => {
279
+ service: async ({ id }) => {
356
280
  const docClient = getDocClient();
357
281
  const tableName = getTableName();
358
282
  const command = new DeleteCommand({
359
- Key: { id, model },
283
+ Key: { id },
360
284
  TableName: tableName,
361
285
  });
362
286
  await docClient.send(command);
@@ -382,11 +306,15 @@ async function transactWriteEntities({ entities, }) {
382
306
  await docClient.send(command);
383
307
  }
384
308
 
309
+ // =============================================================================
310
+ // Helpers
311
+ // =============================================================================
385
312
  /**
386
- * Calculate the suffix based on archived/deleted flags
387
- * When both are true, returns combined suffix (archived first, alphabetically)
313
+ * Calculate the suffix for the GSI partition key based on archived/deleted
314
+ * flags. Suffix stays on pk so deleted/archived entities are queried as their
315
+ * own partition (active queries skip them naturally).
388
316
  */
389
- function calculateSuffix$1({ archived, deleted, }) {
317
+ function calculateSuffix({ archived, deleted, }) {
390
318
  if (archived && deleted) {
391
319
  return ARCHIVED_SUFFIX + DELETED_SUFFIX;
392
320
  }
@@ -399,22 +327,51 @@ function calculateSuffix$1({ archived, deleted, }) {
399
327
  return "";
400
328
  }
401
329
  /**
402
- * Execute a GSI query with common options
330
+ * Find the registered index for a model that matches a given partition-key
331
+ * shape. The matching index is the first one whose pk equals the expected
332
+ * fields. Throws ConfigurationError if no match is found.
403
333
  */
404
- async function executeQuery(indexName, keyValue, options = {}) {
405
- const { ascending = false, limit, startKey } = options;
334
+ function requireIndex(model, pkFields) {
335
+ const indexes = getModelIndexes(model);
336
+ const match = indexes.find((index) => index.pk.length === pkFields.length &&
337
+ index.pk.every((field, i) => field === pkFields[i]));
338
+ if (!match) {
339
+ throw new ConfigurationError(`Model "${model}" has no index with pk=[${pkFields.join(", ")}]. ` +
340
+ `Register one with fabricIndex(${pkFields.length > 1 ? `"${pkFields[1]}"` : ""}).`);
341
+ }
342
+ return match;
343
+ }
344
+ /**
345
+ * Execute a GSI query.
346
+ *
347
+ * - pk: exact match on the index partition key
348
+ * - skPrefix: optional begins_with on the index sort key (used when the index
349
+ * has a composite sk like [scope, updatedAt])
350
+ */
351
+ async function executeQuery(index, pkValue, options = {}) {
352
+ const { ascending = false, limit, skPrefix, startKey } = options;
353
+ const attrs = getGsiAttributeNames(index);
354
+ const indexName = attrs.pk;
355
+ const expressionAttributeNames = {
356
+ "#pk": indexName,
357
+ };
358
+ const expressionAttributeValues = {
359
+ ":pkValue": pkValue,
360
+ };
361
+ let keyConditionExpression = "#pk = :pkValue";
362
+ if (skPrefix !== undefined && attrs.sk) {
363
+ expressionAttributeNames["#sk"] = attrs.sk;
364
+ expressionAttributeValues[":skPrefix"] = skPrefix;
365
+ keyConditionExpression += " AND begins_with(#sk, :skPrefix)";
366
+ }
406
367
  const docClient = getDocClient();
407
368
  const tableName = getTableName();
408
369
  const command = new QueryCommand({
409
370
  ExclusiveStartKey: startKey,
410
- ExpressionAttributeNames: {
411
- "#pk": indexName,
412
- },
413
- ExpressionAttributeValues: {
414
- ":pkValue": keyValue,
415
- },
371
+ ExpressionAttributeNames: expressionAttributeNames,
372
+ ExpressionAttributeValues: expressionAttributeValues,
416
373
  IndexName: indexName,
417
- KeyConditionExpression: "#pk = :pkValue",
374
+ KeyConditionExpression: keyConditionExpression,
418
375
  ...(limit && { Limit: limit }),
419
376
  ScanIndexForward: ascending,
420
377
  TableName: tableName,
@@ -425,25 +382,30 @@ async function executeQuery(indexName, keyValue, options = {}) {
425
382
  lastEvaluatedKey: response.LastEvaluatedKey,
426
383
  };
427
384
  }
385
+ function scopePrefix(scope) {
386
+ return scope === undefined ? undefined : `${scope}${SEPARATOR}`;
387
+ }
388
+ // =============================================================================
389
+ // Query Functions
390
+ // =============================================================================
428
391
  /**
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.
392
+ * List entities of a model, optionally narrowed to a scope.
393
+ * Requires the model to register `fabricIndex()` (pk=[model]).
434
394
  */
435
395
  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, {
396
+ const index = requireIndex(model, ["model"]);
397
+ const suffix = calculateSuffix({ archived, deleted });
398
+ const pkValue = buildCompositeKey({ model }, ["model"], suffix);
399
+ return executeQuery(index, pkValue, {
439
400
  ascending,
440
401
  limit,
402
+ skPrefix: scopePrefix(scope),
441
403
  startKey,
442
404
  });
443
405
  }
444
406
  /**
445
- * Query a single entity by human-friendly alias
446
- * Uses indexAlias GSI
407
+ * Query a single entity by human-friendly alias.
408
+ * Requires the model to register `fabricIndex("alias")`.
447
409
  */
448
410
  const queryByAlias = fabricService({
449
411
  alias: "queryByAlias",
@@ -463,7 +425,11 @@ const queryByAlias = fabricService({
463
425
  description: "Query deleted entities instead of active ones",
464
426
  },
465
427
  model: { type: String, description: "Entity model name" },
466
- scope: { type: String, description: "Scope (@ for root)" },
428
+ scope: {
429
+ type: String,
430
+ required: false,
431
+ description: "Optional scope narrower (begins_with on sk)",
432
+ },
467
433
  },
468
434
  service: async ({ alias, archived, deleted, model, scope, }) => {
469
435
  const aliasStr = alias;
@@ -471,52 +437,52 @@ const queryByAlias = fabricService({
471
437
  const deletedBool = deleted;
472
438
  const modelStr = model;
473
439
  const scopeStr = scope;
474
- const suffix = calculateSuffix$1({
440
+ const index = requireIndex(modelStr, ["model", "alias"]);
441
+ const suffix = calculateSuffix({
475
442
  archived: archivedBool,
476
443
  deleted: deletedBool,
477
444
  });
478
- const keyValue = buildIndexAlias(scopeStr, modelStr, aliasStr) + suffix;
479
- const result = await executeQuery(INDEX_ALIAS, keyValue, {
445
+ const pkValue = buildCompositeKey({ model: modelStr, alias: aliasStr }, ["model", "alias"], suffix);
446
+ const result = await executeQuery(index, pkValue, {
480
447
  limit: 1,
448
+ skPrefix: scopePrefix(scopeStr),
481
449
  });
482
450
  return result.items[0] ?? null;
483
451
  },
484
452
  });
485
453
  /**
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.
454
+ * Query entities by category classification.
455
+ * Requires the model to register `fabricIndex("category")`.
491
456
  */
492
457
  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, {
458
+ const index = requireIndex(model, ["model", "category"]);
459
+ const suffix = calculateSuffix({ archived, deleted });
460
+ const pkValue = buildCompositeKey({ model, category }, ["model", "category"], suffix);
461
+ return executeQuery(index, pkValue, {
496
462
  ascending,
497
463
  limit,
464
+ skPrefix: scopePrefix(scope),
498
465
  startKey,
499
466
  });
500
467
  }
501
468
  /**
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.
469
+ * Query entities by type classification.
470
+ * Requires the model to register `fabricIndex("type")`.
507
471
  */
508
472
  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, {
473
+ const index = requireIndex(model, ["model", "type"]);
474
+ const suffix = calculateSuffix({ archived, deleted });
475
+ const pkValue = buildCompositeKey({ model, type }, ["model", "type"], suffix);
476
+ return executeQuery(index, pkValue, {
512
477
  ascending,
513
478
  limit,
479
+ skPrefix: scopePrefix(scope),
514
480
  startKey,
515
481
  });
516
482
  }
517
483
  /**
518
- * Query a single entity by external ID
519
- * Uses indexXid GSI
484
+ * Query a single entity by external ID.
485
+ * Requires the model to register `fabricIndex("xid")`.
520
486
  */
521
487
  const queryByXid = fabricService({
522
488
  alias: "queryByXid",
@@ -535,7 +501,11 @@ const queryByXid = fabricService({
535
501
  description: "Query deleted entities instead of active ones",
536
502
  },
537
503
  model: { type: String, description: "Entity model name" },
538
- scope: { type: String, description: "Scope (@ for root)" },
504
+ scope: {
505
+ type: String,
506
+ required: false,
507
+ description: "Optional scope narrower (begins_with on sk)",
508
+ },
539
509
  xid: { type: String, description: "External ID" },
540
510
  },
541
511
  service: async ({ archived, deleted, model, scope, xid, }) => {
@@ -544,13 +514,15 @@ const queryByXid = fabricService({
544
514
  const modelStr = model;
545
515
  const scopeStr = scope;
546
516
  const xidStr = xid;
547
- const suffix = calculateSuffix$1({
517
+ const index = requireIndex(modelStr, ["model", "xid"]);
518
+ const suffix = calculateSuffix({
548
519
  archived: archivedBool,
549
520
  deleted: deletedBool,
550
521
  });
551
- const keyValue = buildIndexXid(scopeStr, modelStr, xidStr) + suffix;
552
- const result = await executeQuery(INDEX_XID, keyValue, {
522
+ const pkValue = buildCompositeKey({ model: modelStr, xid: xidStr }, ["model", "xid"], suffix);
523
+ const result = await executeQuery(index, pkValue, {
553
524
  limit: 1,
525
+ skPrefix: scopePrefix(scopeStr),
554
526
  });
555
527
  return result.items[0] ?? null;
556
528
  },
@@ -564,162 +536,77 @@ const queryByXid = fabricService({
564
536
  * removing the need to know which specific GSI to use.
565
537
  */
566
538
  // =============================================================================
567
- // Helper Functions
539
+ // Index Selection
568
540
  // =============================================================================
569
541
  /**
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
542
+ * Select the best index for the given filter.
621
543
  *
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(", ")}]`)
544
+ * Every model-level index has `model` as its first pk field. The picker
545
+ * prefers the most specific index whose remaining pk fields are all
546
+ * satisfied by the filter.
547
+ */
548
+ function selectBestIndex(indexes, filter) {
549
+ // Candidates: indexes whose pk starts with "model" and whose remaining
550
+ // fields are all present in the filter.
551
+ const candidates = indexes.filter((index) => {
552
+ if (index.pk.length === 0 || index.pk[0] !== "model")
553
+ return false;
554
+ for (let i = 1; i < index.pk.length; i++) {
555
+ if (filter[index.pk[i]] === undefined)
556
+ return false;
557
+ }
558
+ return true;
559
+ });
560
+ if (candidates.length === 0) {
561
+ const available = indexes
562
+ .map((i) => `[${i.pk.join(", ")}]`)
634
563
  .join(", ");
635
- const providedFields = Object.keys(filterFields).join(", ");
636
- throw new ConfigurationError(`No index matches filter fields. ` +
637
- `Provided: ${providedFields}. ` +
638
- `Available indexes: ${availableIndexes}`);
564
+ const provided = Object.keys(filter).join(", ") || "(none)";
565
+ throw new ConfigurationError(`No index matches filter for model. ` +
566
+ `Filter fields: ${provided}. Available indexes: ${available}`);
639
567
  }
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;
568
+ // Prefer the most specific index (longest pk).
569
+ candidates.sort((a, b) => b.pk.length - a.pk.length);
570
+ return candidates[0];
650
571
  }
651
572
  // =============================================================================
652
573
  // Main Query Function
653
574
  // =============================================================================
654
575
  /**
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.
576
+ * Query entities with automatic index selection.
660
577
  *
661
578
  * @example
662
- * // Uses indexScope (pk: ["scope", "model"])
663
- * const allMessages = await query({ model: "message", scope: `chat#${chatId}` });
579
+ * // Uses indexModel (pk: ["model"]), optionally narrowed by scope
580
+ * const records = await query({ model: "record", scope: "@" });
664
581
  *
665
582
  * @example
666
- * // Uses indexAlias (pk: ["scope", "model", "alias"])
667
- * const byAlias = await query({
668
- * model: "record",
669
- * scope: "@",
670
- * filter: { alias: "my-record" },
671
- * });
583
+ * // Uses indexModelAlias (pk: ["model", "alias"])
584
+ * const byAlias = await query({
585
+ * model: "record",
586
+ * scope: "@",
587
+ * filter: { alias: "my-record" },
588
+ * });
672
589
  *
673
590
  * @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
- * });
591
+ * // Cross-scope listing (no scope narrower)
592
+ * const all = await query({ model: "record" });
679
593
  */
680
594
  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
595
+ const { archived = false, ascending = false, deleted = false, filter, limit, model, scope, startKey, } = params;
685
596
  const indexes = getModelIndexes(model);
686
- // Select the best matching index
597
+ const filterFields = {
598
+ model,
599
+ ...filter,
600
+ };
687
601
  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,
602
+ const suffix = calculateSuffix({ archived, deleted });
603
+ const pkValue = buildCompositeKey(filterFields, selectedIndex.pk, suffix);
604
+ return executeQuery(selectedIndex, pkValue, {
605
+ ascending,
606
+ limit,
607
+ skPrefix: scopePrefix(scope),
608
+ startKey,
708
609
  });
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
610
  }
724
611
 
725
612
  /**
@@ -741,16 +628,12 @@ async function seedEntityIfNotExists(entity) {
741
628
  if (existing) {
742
629
  return false;
743
630
  }
744
- // Generate required fields if missing
745
- const now = new Date().toISOString();
631
+ // Generate required fields if missing; indexEntity manages timestamps
746
632
  const completeEntity = {
747
- createdAt: entity.createdAt ?? now,
748
633
  id: entity.id ?? crypto.randomUUID(),
749
634
  model: entity.model,
750
635
  name: entity.name ?? entity.alias,
751
636
  scope: entity.scope,
752
- sequence: entity.sequence ?? Date.now(),
753
- updatedAt: entity.updatedAt ?? now,
754
637
  ...entity,
755
638
  };
756
639
  await putEntity({ entity: completeEntity });
@@ -801,16 +684,12 @@ async function seedEntities(entities, options = {}) {
801
684
  result.created.push(alias);
802
685
  continue;
803
686
  }
804
- // Generate required fields if missing
805
- const now = new Date().toISOString();
687
+ // Generate required fields if missing; indexEntity manages timestamps
806
688
  const completeEntity = {
807
- createdAt: entity.createdAt ?? now,
808
689
  id: entity.id ?? crypto.randomUUID(),
809
690
  model: entity.model,
810
691
  name: entity.name ?? entity.alias ?? "Unnamed",
811
692
  scope: entity.scope,
812
- sequence: entity.sequence ?? Date.now(),
813
- updatedAt: entity.updatedAt ?? now,
814
693
  ...entity,
815
694
  };
816
695
  await putEntity({ entity: completeEntity });
@@ -871,5 +750,5 @@ async function exportEntitiesToJson(model, scope, pretty = true) {
871
750
  return pretty ? JSON.stringify(entities, null, 2) : JSON.stringify(entities);
872
751
  }
873
752
 
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 };
753
+ export { archiveEntity, buildCompositeKey, calculateScope, deleteEntity, destroyEntity, exportEntities, exportEntitiesToJson, getDocClient, getEntity, getTableName, indexEntity, initClient, isInitialized, putEntity, query, queryByAlias, queryByCategory, queryByScope, queryByType, queryByXid, resetClient, seedEntities, seedEntityIfNotExists, transactWriteEntities, updateEntity };
875
754
  //# sourceMappingURL=index.js.map