@jaypie/dynamodb 0.1.3 → 0.2.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.
@@ -1,9 +1,6 @@
1
- export declare const APEX = "@";
2
- export declare const SEPARATOR = "#";
1
+ export { APEX, ARCHIVED_SUFFIX, DELETED_SUFFIX, SEPARATOR } from "@jaypie/vocabulary";
3
2
  export declare const INDEX_ALIAS = "indexAlias";
4
3
  export declare const INDEX_CLASS = "indexClass";
5
4
  export declare const INDEX_OU = "indexOu";
6
5
  export declare const INDEX_TYPE = "indexType";
7
6
  export declare const INDEX_XID = "indexXid";
8
- export declare const ARCHIVED_SUFFIX = "#archived";
9
- export declare const DELETED_SUFFIX = "#deleted";
@@ -1,28 +1,28 @@
1
- import type { FabricEntity } from "./types.js";
1
+ import type { StorableEntity } from "./types.js";
2
2
  /**
3
3
  * Get a single entity by primary key
4
4
  */
5
- export declare const getEntity: import("@jaypie/vocabulary").ServiceHandlerFunction<Record<string, unknown>, FabricEntity | null>;
5
+ export declare const getEntity: import("@jaypie/vocabulary").ServiceHandlerFunction<Record<string, unknown>, StorableEntity | null>;
6
6
  /**
7
7
  * Put (create or replace) an entity
8
8
  * Auto-populates GSI index keys via indexEntity
9
9
  *
10
10
  * Note: This is a regular async function (not serviceHandler) because it accepts
11
- * complex FabricEntity objects that can't be coerced by vocabulary's type system.
11
+ * complex StorableEntity objects that can't be coerced by vocabulary's type system.
12
12
  */
13
13
  export declare function putEntity({ entity, }: {
14
- entity: FabricEntity;
15
- }): Promise<FabricEntity>;
14
+ entity: StorableEntity;
15
+ }): Promise<StorableEntity>;
16
16
  /**
17
17
  * Update an existing entity
18
18
  * Auto-populates GSI index keys and sets updatedAt
19
19
  *
20
20
  * Note: This is a regular async function (not serviceHandler) because it accepts
21
- * complex FabricEntity objects that can't be coerced by vocabulary's type system.
21
+ * complex StorableEntity objects that can't be coerced by vocabulary's type system.
22
22
  */
23
23
  export declare function updateEntity({ entity, }: {
24
- entity: FabricEntity;
25
- }): Promise<FabricEntity>;
24
+ entity: StorableEntity;
25
+ }): Promise<StorableEntity>;
26
26
  /**
27
27
  * Soft delete an entity by setting deletedAt timestamp
28
28
  * Re-indexes with appropriate suffix based on archived/deleted state
@@ -83,19 +83,17 @@ function resetClient() {
83
83
  tableName = null;
84
84
  }
85
85
 
86
- // Primary markers
87
- const APEX = "@"; // Root-level marker (DynamoDB prohibits empty strings)
88
- const SEPARATOR = "#"; // Composite key separator
89
- // GSI names
86
+ // Re-export shared constants from vocabulary
87
+ // GSI names (derived from DEFAULT_INDEXES)
90
88
  const INDEX_ALIAS = "indexAlias";
91
89
  const INDEX_CLASS = "indexClass";
92
90
  const INDEX_OU = "indexOu";
93
91
  const INDEX_TYPE = "indexType";
94
92
  const INDEX_XID = "indexXid";
95
- // Index suffixes for soft state
96
- const ARCHIVED_SUFFIX = "#archived";
97
- const DELETED_SUFFIX = "#deleted";
98
93
 
94
+ // =============================================================================
95
+ // Key Builders
96
+ // =============================================================================
99
97
  /**
100
98
  * Build the indexOu key for hierarchical queries
101
99
  * @param ou - The organizational unit (APEX or "{parent.model}#{parent.id}")
@@ -103,7 +101,7 @@ const DELETED_SUFFIX = "#deleted";
103
101
  * @returns Composite key: "{ou}#{model}"
104
102
  */
105
103
  function buildIndexOu(ou, model) {
106
- return `${ou}${SEPARATOR}${model}`;
104
+ return `${ou}${vocabulary.SEPARATOR}${model}`;
107
105
  }
108
106
  /**
109
107
  * Build the indexAlias key for human-friendly lookups
@@ -113,7 +111,7 @@ function buildIndexOu(ou, model) {
113
111
  * @returns Composite key: "{ou}#{model}#{alias}"
114
112
  */
115
113
  function buildIndexAlias(ou, model, alias) {
116
- return `${ou}${SEPARATOR}${model}${SEPARATOR}${alias}`;
114
+ return `${ou}${vocabulary.SEPARATOR}${model}${vocabulary.SEPARATOR}${alias}`;
117
115
  }
118
116
  /**
119
117
  * Build the indexClass key for category filtering
@@ -123,7 +121,7 @@ function buildIndexAlias(ou, model, alias) {
123
121
  * @returns Composite key: "{ou}#{model}#{class}"
124
122
  */
125
123
  function buildIndexClass(ou, model, recordClass) {
126
- return `${ou}${SEPARATOR}${model}${SEPARATOR}${recordClass}`;
124
+ return `${ou}${vocabulary.SEPARATOR}${model}${vocabulary.SEPARATOR}${recordClass}`;
127
125
  }
128
126
  /**
129
127
  * Build the indexType key for type filtering
@@ -133,7 +131,7 @@ function buildIndexClass(ou, model, recordClass) {
133
131
  * @returns Composite key: "{ou}#{model}#{type}"
134
132
  */
135
133
  function buildIndexType(ou, model, type) {
136
- return `${ou}${SEPARATOR}${model}${SEPARATOR}${type}`;
134
+ return `${ou}${vocabulary.SEPARATOR}${model}${vocabulary.SEPARATOR}${type}`;
137
135
  }
138
136
  /**
139
137
  * Build the indexXid key for external ID lookups
@@ -143,7 +141,21 @@ function buildIndexType(ou, model, type) {
143
141
  * @returns Composite key: "{ou}#{model}#{xid}"
144
142
  */
145
143
  function buildIndexXid(ou, model, xid) {
146
- return `${ou}${SEPARATOR}${model}${SEPARATOR}${xid}`;
144
+ return `${ou}${vocabulary.SEPARATOR}${model}${vocabulary.SEPARATOR}${xid}`;
145
+ }
146
+ // =============================================================================
147
+ // New Vocabulary-Based Functions
148
+ // =============================================================================
149
+ /**
150
+ * Build a composite key from entity fields
151
+ *
152
+ * @param entity - Entity with fields to extract
153
+ * @param fields - Field names to combine with SEPARATOR
154
+ * @param suffix - Optional suffix to append (e.g., "#deleted")
155
+ * @returns Composite key string
156
+ */
157
+ function buildCompositeKey(entity, fields, suffix) {
158
+ return vocabulary.buildCompositeKey(entity, fields, suffix);
147
159
  }
148
160
  /**
149
161
  * Calculate the organizational unit from a parent reference
@@ -152,12 +164,16 @@ function buildIndexXid(ou, model, xid) {
152
164
  */
153
165
  function calculateOu(parent) {
154
166
  if (!parent) {
155
- return APEX;
167
+ return vocabulary.APEX;
156
168
  }
157
- return `${parent.model}${SEPARATOR}${parent.id}`;
169
+ return vocabulary.calculateOu(parent);
158
170
  }
159
171
  /**
160
172
  * Auto-populate GSI index keys on an entity
173
+ *
174
+ * Uses the model's registered indexes (from vocabulary registry) or
175
+ * DEFAULT_INDEXES if no custom indexes are registered.
176
+ *
161
177
  * - indexOu is always populated from ou + model
162
178
  * - indexAlias is populated only when alias is present
163
179
  * - indexClass is populated only when class is present
@@ -169,27 +185,9 @@ function calculateOu(parent) {
169
185
  * @returns The entity with populated index keys
170
186
  */
171
187
  function indexEntity(entity, suffix = "") {
172
- const result = { ...entity };
173
- // indexOu is always set (from ou + model)
174
- result.indexOu = buildIndexOu(entity.ou, entity.model) + suffix;
175
- // Optional indexes - only set when the source field is present
176
- if (entity.alias !== undefined) {
177
- result.indexAlias =
178
- buildIndexAlias(entity.ou, entity.model, entity.alias) + suffix;
179
- }
180
- if (entity.class !== undefined) {
181
- result.indexClass =
182
- buildIndexClass(entity.ou, entity.model, entity.class) + suffix;
183
- }
184
- if (entity.type !== undefined) {
185
- result.indexType =
186
- buildIndexType(entity.ou, entity.model, entity.type) + suffix;
187
- }
188
- if (entity.xid !== undefined) {
189
- result.indexXid =
190
- buildIndexXid(entity.ou, entity.model, entity.xid) + suffix;
191
- }
192
- return result;
188
+ const indexes = vocabulary.getModelIndexes(entity.model);
189
+ // Cast through unknown to bridge the type gap between StorableEntity and IndexableEntity
190
+ return vocabulary.populateIndexKeys(entity, indexes, suffix);
193
191
  }
194
192
 
195
193
  /**
@@ -199,13 +197,13 @@ function calculateEntitySuffix(entity) {
199
197
  const hasArchived = Boolean(entity.archivedAt);
200
198
  const hasDeleted = Boolean(entity.deletedAt);
201
199
  if (hasArchived && hasDeleted) {
202
- return ARCHIVED_SUFFIX + DELETED_SUFFIX;
200
+ return vocabulary.ARCHIVED_SUFFIX + vocabulary.DELETED_SUFFIX;
203
201
  }
204
202
  if (hasArchived) {
205
- return ARCHIVED_SUFFIX;
203
+ return vocabulary.ARCHIVED_SUFFIX;
206
204
  }
207
205
  if (hasDeleted) {
208
- return DELETED_SUFFIX;
206
+ return vocabulary.DELETED_SUFFIX;
209
207
  }
210
208
  return "";
211
209
  }
@@ -235,7 +233,7 @@ const getEntity = vocabulary.serviceHandler({
235
233
  * Auto-populates GSI index keys via indexEntity
236
234
  *
237
235
  * Note: This is a regular async function (not serviceHandler) because it accepts
238
- * complex FabricEntity objects that can't be coerced by vocabulary's type system.
236
+ * complex StorableEntity objects that can't be coerced by vocabulary's type system.
239
237
  */
240
238
  async function putEntity({ entity, }) {
241
239
  const docClient = getDocClient();
@@ -254,7 +252,7 @@ async function putEntity({ entity, }) {
254
252
  * Auto-populates GSI index keys and sets updatedAt
255
253
  *
256
254
  * Note: This is a regular async function (not serviceHandler) because it accepts
257
- * complex FabricEntity objects that can't be coerced by vocabulary's type system.
255
+ * complex StorableEntity objects that can't be coerced by vocabulary's type system.
258
256
  */
259
257
  async function updateEntity({ entity, }) {
260
258
  const docClient = getDocClient();
@@ -372,15 +370,15 @@ const destroyEntity = vocabulary.serviceHandler({
372
370
  * Calculate the suffix based on archived/deleted flags
373
371
  * When both are true, returns combined suffix (archived first, alphabetically)
374
372
  */
375
- function calculateSuffix({ archived, deleted, }) {
373
+ function calculateSuffix$1({ archived, deleted, }) {
376
374
  if (archived && deleted) {
377
- return ARCHIVED_SUFFIX + DELETED_SUFFIX;
375
+ return vocabulary.ARCHIVED_SUFFIX + vocabulary.DELETED_SUFFIX;
378
376
  }
379
377
  if (archived) {
380
- return ARCHIVED_SUFFIX;
378
+ return vocabulary.ARCHIVED_SUFFIX;
381
379
  }
382
380
  if (deleted) {
383
- return DELETED_SUFFIX;
381
+ return vocabulary.DELETED_SUFFIX;
384
382
  }
385
383
  return "";
386
384
  }
@@ -419,7 +417,7 @@ async function executeQuery(indexName, keyValue, options = {}) {
419
417
  * complex startKey objects that can't be coerced by vocabulary's type system.
420
418
  */
421
419
  async function queryByOu({ archived = false, ascending = false, deleted = false, limit, model, ou, startKey, }) {
422
- const suffix = calculateSuffix({ archived, deleted });
420
+ const suffix = calculateSuffix$1({ archived, deleted });
423
421
  const keyValue = buildIndexOu(ou, model) + suffix;
424
422
  return executeQuery(INDEX_OU, keyValue, {
425
423
  ascending,
@@ -457,7 +455,7 @@ const queryByAlias = vocabulary.serviceHandler({
457
455
  const deletedBool = deleted;
458
456
  const modelStr = model;
459
457
  const ouStr = ou;
460
- const suffix = calculateSuffix({ archived: archivedBool, deleted: deletedBool });
458
+ const suffix = calculateSuffix$1({ archived: archivedBool, deleted: deletedBool });
461
459
  const keyValue = buildIndexAlias(ouStr, modelStr, aliasStr) + suffix;
462
460
  const result = await executeQuery(INDEX_ALIAS, keyValue, {
463
461
  limit: 1,
@@ -473,7 +471,7 @@ const queryByAlias = vocabulary.serviceHandler({
473
471
  * complex startKey objects that can't be coerced by vocabulary's type system.
474
472
  */
475
473
  async function queryByClass({ archived = false, ascending = false, deleted = false, limit, model, ou, recordClass, startKey, }) {
476
- const suffix = calculateSuffix({ archived, deleted });
474
+ const suffix = calculateSuffix$1({ archived, deleted });
477
475
  const keyValue = buildIndexClass(ou, model, recordClass) + suffix;
478
476
  return executeQuery(INDEX_CLASS, keyValue, {
479
477
  ascending,
@@ -489,7 +487,7 @@ async function queryByClass({ archived = false, ascending = false, deleted = fal
489
487
  * complex startKey objects that can't be coerced by vocabulary's type system.
490
488
  */
491
489
  async function queryByType({ archived = false, ascending = false, deleted = false, limit, model, ou, startKey, type, }) {
492
- const suffix = calculateSuffix({ archived, deleted });
490
+ const suffix = calculateSuffix$1({ archived, deleted });
493
491
  const keyValue = buildIndexType(ou, model, type) + suffix;
494
492
  return executeQuery(INDEX_TYPE, keyValue, {
495
493
  ascending,
@@ -527,7 +525,7 @@ const queryByXid = vocabulary.serviceHandler({
527
525
  const modelStr = model;
528
526
  const ouStr = ou;
529
527
  const xidStr = xid;
530
- const suffix = calculateSuffix({ archived: archivedBool, deleted: deletedBool });
528
+ const suffix = calculateSuffix$1({ archived: archivedBool, deleted: deletedBool });
531
529
  const keyValue = buildIndexXid(ouStr, modelStr, xidStr) + suffix;
532
530
  const result = await executeQuery(INDEX_XID, keyValue, {
533
531
  limit: 1,
@@ -536,6 +534,172 @@ const queryByXid = vocabulary.serviceHandler({
536
534
  },
537
535
  });
538
536
 
537
+ /**
538
+ * Unified Query Function with Auto-Detect Index Selection
539
+ *
540
+ * The query() function automatically selects the best index based on
541
+ * the filter fields provided. This simplifies query construction by
542
+ * removing the need to know which specific GSI to use.
543
+ */
544
+ // =============================================================================
545
+ // Helper Functions
546
+ // =============================================================================
547
+ /**
548
+ * Calculate the suffix based on archived/deleted flags
549
+ */
550
+ function calculateSuffix(archived, deleted) {
551
+ if (archived && deleted) {
552
+ return vocabulary.ARCHIVED_SUFFIX + vocabulary.DELETED_SUFFIX;
553
+ }
554
+ if (archived) {
555
+ return vocabulary.ARCHIVED_SUFFIX;
556
+ }
557
+ if (deleted) {
558
+ return vocabulary.DELETED_SUFFIX;
559
+ }
560
+ return "";
561
+ }
562
+ /**
563
+ * Build a combined filter object from params
564
+ */
565
+ function buildFilterObject(params) {
566
+ const result = {
567
+ model: params.model,
568
+ };
569
+ if (params.ou !== undefined) {
570
+ result.ou = params.ou;
571
+ }
572
+ if (params.filter) {
573
+ Object.assign(result, params.filter);
574
+ }
575
+ return result;
576
+ }
577
+ /**
578
+ * Score an index based on how well it matches the filter fields
579
+ */
580
+ function scoreIndex(index, filterFields) {
581
+ let matchedFields = 0;
582
+ let pkComplete = true;
583
+ for (const field of index.pk) {
584
+ if (filterFields[field] !== undefined) {
585
+ matchedFields++;
586
+ }
587
+ else {
588
+ pkComplete = false;
589
+ }
590
+ }
591
+ return {
592
+ index,
593
+ matchedFields,
594
+ pkComplete,
595
+ };
596
+ }
597
+ /**
598
+ * Select the best index for the given filter
599
+ *
600
+ * Scoring criteria:
601
+ * 1. Index must have all pk fields present (pkComplete)
602
+ * 2. Prefer indexes with more matched fields
603
+ * 3. Prefer more specific indexes (more pk fields)
604
+ */
605
+ function selectBestIndex(indexes, filterFields) {
606
+ const scores = indexes.map((index) => scoreIndex(index, filterFields));
607
+ // Filter to only complete matches
608
+ const completeMatches = scores.filter((s) => s.pkComplete);
609
+ if (completeMatches.length === 0) {
610
+ const availableIndexes = indexes
611
+ .map((i) => i.name ?? `[${i.pk.join(", ")}]`)
612
+ .join(", ");
613
+ const providedFields = Object.keys(filterFields).join(", ");
614
+ throw new errors.ConfigurationError(`No index matches filter fields. ` +
615
+ `Provided: ${providedFields}. ` +
616
+ `Available indexes: ${availableIndexes}`);
617
+ }
618
+ // Sort by:
619
+ // 1. More matched fields first (descending)
620
+ // 2. More pk fields (more specific) first (descending)
621
+ completeMatches.sort((a, b) => {
622
+ const fieldDiff = b.matchedFields - a.matchedFields;
623
+ if (fieldDiff !== 0)
624
+ return fieldDiff;
625
+ return b.index.pk.length - a.index.pk.length;
626
+ });
627
+ return completeMatches[0].index;
628
+ }
629
+ // =============================================================================
630
+ // Main Query Function
631
+ // =============================================================================
632
+ /**
633
+ * Query entities with automatic index selection
634
+ *
635
+ * The query function automatically selects the best GSI based on
636
+ * the filter fields provided. This removes the need to know which
637
+ * specific query function (queryByOu, queryByAlias, etc.) to use.
638
+ *
639
+ * @example
640
+ * // Uses indexOu (pk: ["ou", "model"])
641
+ * const allMessages = await query({ model: "message", ou: `chat#${chatId}` });
642
+ *
643
+ * @example
644
+ * // Uses indexAlias (pk: ["ou", "model", "alias"])
645
+ * const byAlias = await query({
646
+ * model: "record",
647
+ * ou: "@",
648
+ * filter: { alias: "my-record" },
649
+ * });
650
+ *
651
+ * @example
652
+ * // Uses a custom registered index if model has one
653
+ * const byChat = await query({
654
+ * model: "message",
655
+ * filter: { chatId: "abc-123" },
656
+ * });
657
+ */
658
+ async function query(params) {
659
+ const { archived = false, ascending = false, deleted = false, limit, model, startKey, } = params;
660
+ // Build the combined filter object
661
+ const filterFields = buildFilterObject(params);
662
+ // Get indexes for this model (custom or DEFAULT_INDEXES)
663
+ const indexes = vocabulary.getModelIndexes(model);
664
+ // Select the best matching index
665
+ const selectedIndex = selectBestIndex(indexes, filterFields);
666
+ const indexName = selectedIndex.name ?? generateIndexName(selectedIndex.pk);
667
+ // Build the partition key value
668
+ const suffix = calculateSuffix(archived, deleted);
669
+ const keyValue = buildCompositeKey(filterFields, selectedIndex.pk, suffix);
670
+ // Execute the query
671
+ const docClient = getDocClient();
672
+ const tableName = getTableName();
673
+ const command = new libDynamodb.QueryCommand({
674
+ ExclusiveStartKey: startKey,
675
+ ExpressionAttributeNames: {
676
+ "#pk": indexName,
677
+ },
678
+ ExpressionAttributeValues: {
679
+ ":pkValue": keyValue,
680
+ },
681
+ IndexName: indexName,
682
+ KeyConditionExpression: "#pk = :pkValue",
683
+ ...(limit && { Limit: limit }),
684
+ ScanIndexForward: ascending,
685
+ TableName: tableName,
686
+ });
687
+ const response = await docClient.send(command);
688
+ return {
689
+ items: (response.Items ?? []),
690
+ lastEvaluatedKey: response.LastEvaluatedKey,
691
+ };
692
+ }
693
+ /**
694
+ * Generate an index name from pk fields
695
+ */
696
+ function generateIndexName(pk) {
697
+ const suffix = pk
698
+ .map((field) => field.charAt(0).toUpperCase() + field.slice(1))
699
+ .join("");
700
+ return `index${suffix}`;
701
+ }
702
+
539
703
  /**
540
704
  * Seed a single entity if it doesn't already exist
541
705
  *
@@ -685,16 +849,33 @@ async function exportEntitiesToJson(model, ou, pretty = true) {
685
849
  return pretty ? JSON.stringify(entities, null, 2) : JSON.stringify(entities);
686
850
  }
687
851
 
688
- exports.APEX = APEX;
689
- exports.ARCHIVED_SUFFIX = ARCHIVED_SUFFIX;
690
- exports.DELETED_SUFFIX = DELETED_SUFFIX;
852
+ Object.defineProperty(exports, "APEX", {
853
+ enumerable: true,
854
+ get: function () { return vocabulary.APEX; }
855
+ });
856
+ Object.defineProperty(exports, "ARCHIVED_SUFFIX", {
857
+ enumerable: true,
858
+ get: function () { return vocabulary.ARCHIVED_SUFFIX; }
859
+ });
860
+ Object.defineProperty(exports, "DEFAULT_INDEXES", {
861
+ enumerable: true,
862
+ get: function () { return vocabulary.DEFAULT_INDEXES; }
863
+ });
864
+ Object.defineProperty(exports, "DELETED_SUFFIX", {
865
+ enumerable: true,
866
+ get: function () { return vocabulary.DELETED_SUFFIX; }
867
+ });
868
+ Object.defineProperty(exports, "SEPARATOR", {
869
+ enumerable: true,
870
+ get: function () { return vocabulary.SEPARATOR; }
871
+ });
691
872
  exports.INDEX_ALIAS = INDEX_ALIAS;
692
873
  exports.INDEX_CLASS = INDEX_CLASS;
693
874
  exports.INDEX_OU = INDEX_OU;
694
875
  exports.INDEX_TYPE = INDEX_TYPE;
695
876
  exports.INDEX_XID = INDEX_XID;
696
- exports.SEPARATOR = SEPARATOR;
697
877
  exports.archiveEntity = archiveEntity;
878
+ exports.buildCompositeKey = buildCompositeKey;
698
879
  exports.buildIndexAlias = buildIndexAlias;
699
880
  exports.buildIndexClass = buildIndexClass;
700
881
  exports.buildIndexOu = buildIndexOu;
@@ -712,6 +893,7 @@ exports.indexEntity = indexEntity;
712
893
  exports.initClient = initClient;
713
894
  exports.isInitialized = isInitialized;
714
895
  exports.putEntity = putEntity;
896
+ exports.query = query;
715
897
  exports.queryByAlias = queryByAlias;
716
898
  exports.queryByClass = queryByClass;
717
899
  exports.queryByOu = queryByOu;