@jaypie/dynamodb 0.1.2 → 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,6 +1,10 @@
1
1
  export { getDocClient, getTableName, initClient, isInitialized, resetClient, } from "./client.js";
2
2
  export { APEX, ARCHIVED_SUFFIX, DELETED_SUFFIX, INDEX_ALIAS, INDEX_CLASS, INDEX_OU, INDEX_TYPE, INDEX_XID, SEPARATOR, } from "./constants.js";
3
3
  export { archiveEntity, deleteEntity, destroyEntity, getEntity, putEntity, updateEntity, } from "./entities.js";
4
- export { buildIndexAlias, buildIndexClass, buildIndexOu, buildIndexType, buildIndexXid, calculateOu, indexEntity, } from "./keyBuilders.js";
4
+ export { buildCompositeKey, buildIndexAlias, buildIndexClass, buildIndexOu, buildIndexType, buildIndexXid, calculateOu, DEFAULT_INDEXES, indexEntity, } from "./keyBuilders.js";
5
5
  export { queryByAlias, queryByClass, queryByOu, queryByType, queryByXid, } from "./queries.js";
6
- export type { BaseQueryOptions, DynamoClientConfig, FabricEntity, ParentReference, QueryResult, } from "./types.js";
6
+ export { query } from "./query.js";
7
+ export type { QueryParams } from "./query.js";
8
+ export { exportEntities, exportEntitiesToJson, seedEntities, seedEntityIfNotExists, } from "./seedExport.js";
9
+ export type { BaseQueryOptions, DynamoClientConfig, ParentReference, QueryResult, StorableEntity, } from "./types.js";
10
+ export type { ExportResult, SeedOptions, SeedResult } from "./seedExport.js";
package/dist/esm/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
2
  import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
3
3
  import { ConfigurationError } from '@jaypie/errors';
4
- import { serviceHandler } from '@jaypie/vocabulary';
4
+ import { getModelIndexes, populateIndexKeys, SEPARATOR, buildCompositeKey as buildCompositeKey$1, APEX, calculateOu as calculateOu$1, serviceHandler, ARCHIVED_SUFFIX, DELETED_SUFFIX } from '@jaypie/vocabulary';
5
+ export { APEX, ARCHIVED_SUFFIX, DEFAULT_INDEXES, DELETED_SUFFIX, SEPARATOR } from '@jaypie/vocabulary';
5
6
 
6
7
  // Environment variable names
7
8
  const ENV_AWS_REGION = "AWS_REGION";
@@ -81,19 +82,17 @@ function resetClient() {
81
82
  tableName = null;
82
83
  }
83
84
 
84
- // Primary markers
85
- const APEX = "@"; // Root-level marker (DynamoDB prohibits empty strings)
86
- const SEPARATOR = "#"; // Composite key separator
87
- // GSI names
85
+ // Re-export shared constants from vocabulary
86
+ // GSI names (derived from DEFAULT_INDEXES)
88
87
  const INDEX_ALIAS = "indexAlias";
89
88
  const INDEX_CLASS = "indexClass";
90
89
  const INDEX_OU = "indexOu";
91
90
  const INDEX_TYPE = "indexType";
92
91
  const INDEX_XID = "indexXid";
93
- // Index suffixes for soft state
94
- const ARCHIVED_SUFFIX = "#archived";
95
- const DELETED_SUFFIX = "#deleted";
96
92
 
93
+ // =============================================================================
94
+ // Key Builders
95
+ // =============================================================================
97
96
  /**
98
97
  * Build the indexOu key for hierarchical queries
99
98
  * @param ou - The organizational unit (APEX or "{parent.model}#{parent.id}")
@@ -143,6 +142,20 @@ function buildIndexType(ou, model, type) {
143
142
  function buildIndexXid(ou, model, xid) {
144
143
  return `${ou}${SEPARATOR}${model}${SEPARATOR}${xid}`;
145
144
  }
145
+ // =============================================================================
146
+ // New Vocabulary-Based Functions
147
+ // =============================================================================
148
+ /**
149
+ * Build a composite key from entity fields
150
+ *
151
+ * @param entity - Entity with fields to extract
152
+ * @param fields - Field names to combine with SEPARATOR
153
+ * @param suffix - Optional suffix to append (e.g., "#deleted")
154
+ * @returns Composite key string
155
+ */
156
+ function buildCompositeKey(entity, fields, suffix) {
157
+ return buildCompositeKey$1(entity, fields, suffix);
158
+ }
146
159
  /**
147
160
  * Calculate the organizational unit from a parent reference
148
161
  * @param parent - Optional parent entity reference
@@ -152,10 +165,14 @@ function calculateOu(parent) {
152
165
  if (!parent) {
153
166
  return APEX;
154
167
  }
155
- return `${parent.model}${SEPARATOR}${parent.id}`;
168
+ return calculateOu$1(parent);
156
169
  }
157
170
  /**
158
171
  * Auto-populate GSI index keys on an entity
172
+ *
173
+ * Uses the model's registered indexes (from vocabulary registry) or
174
+ * DEFAULT_INDEXES if no custom indexes are registered.
175
+ *
159
176
  * - indexOu is always populated from ou + model
160
177
  * - indexAlias is populated only when alias is present
161
178
  * - indexClass is populated only when class is present
@@ -167,27 +184,9 @@ function calculateOu(parent) {
167
184
  * @returns The entity with populated index keys
168
185
  */
169
186
  function indexEntity(entity, suffix = "") {
170
- const result = { ...entity };
171
- // indexOu is always set (from ou + model)
172
- result.indexOu = buildIndexOu(entity.ou, entity.model) + suffix;
173
- // Optional indexes - only set when the source field is present
174
- if (entity.alias !== undefined) {
175
- result.indexAlias =
176
- buildIndexAlias(entity.ou, entity.model, entity.alias) + suffix;
177
- }
178
- if (entity.class !== undefined) {
179
- result.indexClass =
180
- buildIndexClass(entity.ou, entity.model, entity.class) + suffix;
181
- }
182
- if (entity.type !== undefined) {
183
- result.indexType =
184
- buildIndexType(entity.ou, entity.model, entity.type) + suffix;
185
- }
186
- if (entity.xid !== undefined) {
187
- result.indexXid =
188
- buildIndexXid(entity.ou, entity.model, entity.xid) + suffix;
189
- }
190
- return result;
187
+ const indexes = getModelIndexes(entity.model);
188
+ // Cast through unknown to bridge the type gap between StorableEntity and IndexableEntity
189
+ return populateIndexKeys(entity, indexes, suffix);
191
190
  }
192
191
 
193
192
  /**
@@ -233,7 +232,7 @@ const getEntity = serviceHandler({
233
232
  * Auto-populates GSI index keys via indexEntity
234
233
  *
235
234
  * Note: This is a regular async function (not serviceHandler) because it accepts
236
- * complex FabricEntity objects that can't be coerced by vocabulary's type system.
235
+ * complex StorableEntity objects that can't be coerced by vocabulary's type system.
237
236
  */
238
237
  async function putEntity({ entity, }) {
239
238
  const docClient = getDocClient();
@@ -252,7 +251,7 @@ async function putEntity({ entity, }) {
252
251
  * Auto-populates GSI index keys and sets updatedAt
253
252
  *
254
253
  * Note: This is a regular async function (not serviceHandler) because it accepts
255
- * complex FabricEntity objects that can't be coerced by vocabulary's type system.
254
+ * complex StorableEntity objects that can't be coerced by vocabulary's type system.
256
255
  */
257
256
  async function updateEntity({ entity, }) {
258
257
  const docClient = getDocClient();
@@ -370,7 +369,7 @@ const destroyEntity = serviceHandler({
370
369
  * Calculate the suffix based on archived/deleted flags
371
370
  * When both are true, returns combined suffix (archived first, alphabetically)
372
371
  */
373
- function calculateSuffix({ archived, deleted, }) {
372
+ function calculateSuffix$1({ archived, deleted, }) {
374
373
  if (archived && deleted) {
375
374
  return ARCHIVED_SUFFIX + DELETED_SUFFIX;
376
375
  }
@@ -417,7 +416,7 @@ async function executeQuery(indexName, keyValue, options = {}) {
417
416
  * complex startKey objects that can't be coerced by vocabulary's type system.
418
417
  */
419
418
  async function queryByOu({ archived = false, ascending = false, deleted = false, limit, model, ou, startKey, }) {
420
- const suffix = calculateSuffix({ archived, deleted });
419
+ const suffix = calculateSuffix$1({ archived, deleted });
421
420
  const keyValue = buildIndexOu(ou, model) + suffix;
422
421
  return executeQuery(INDEX_OU, keyValue, {
423
422
  ascending,
@@ -455,7 +454,7 @@ const queryByAlias = serviceHandler({
455
454
  const deletedBool = deleted;
456
455
  const modelStr = model;
457
456
  const ouStr = ou;
458
- const suffix = calculateSuffix({ archived: archivedBool, deleted: deletedBool });
457
+ const suffix = calculateSuffix$1({ archived: archivedBool, deleted: deletedBool });
459
458
  const keyValue = buildIndexAlias(ouStr, modelStr, aliasStr) + suffix;
460
459
  const result = await executeQuery(INDEX_ALIAS, keyValue, {
461
460
  limit: 1,
@@ -471,7 +470,7 @@ const queryByAlias = serviceHandler({
471
470
  * complex startKey objects that can't be coerced by vocabulary's type system.
472
471
  */
473
472
  async function queryByClass({ archived = false, ascending = false, deleted = false, limit, model, ou, recordClass, startKey, }) {
474
- const suffix = calculateSuffix({ archived, deleted });
473
+ const suffix = calculateSuffix$1({ archived, deleted });
475
474
  const keyValue = buildIndexClass(ou, model, recordClass) + suffix;
476
475
  return executeQuery(INDEX_CLASS, keyValue, {
477
476
  ascending,
@@ -487,7 +486,7 @@ async function queryByClass({ archived = false, ascending = false, deleted = fal
487
486
  * complex startKey objects that can't be coerced by vocabulary's type system.
488
487
  */
489
488
  async function queryByType({ archived = false, ascending = false, deleted = false, limit, model, ou, startKey, type, }) {
490
- const suffix = calculateSuffix({ archived, deleted });
489
+ const suffix = calculateSuffix$1({ archived, deleted });
491
490
  const keyValue = buildIndexType(ou, model, type) + suffix;
492
491
  return executeQuery(INDEX_TYPE, keyValue, {
493
492
  ascending,
@@ -525,7 +524,7 @@ const queryByXid = serviceHandler({
525
524
  const modelStr = model;
526
525
  const ouStr = ou;
527
526
  const xidStr = xid;
528
- const suffix = calculateSuffix({ archived: archivedBool, deleted: deletedBool });
527
+ const suffix = calculateSuffix$1({ archived: archivedBool, deleted: deletedBool });
529
528
  const keyValue = buildIndexXid(ouStr, modelStr, xidStr) + suffix;
530
529
  const result = await executeQuery(INDEX_XID, keyValue, {
531
530
  limit: 1,
@@ -534,5 +533,320 @@ const queryByXid = serviceHandler({
534
533
  },
535
534
  });
536
535
 
537
- export { APEX, ARCHIVED_SUFFIX, DELETED_SUFFIX, INDEX_ALIAS, INDEX_CLASS, INDEX_OU, INDEX_TYPE, INDEX_XID, SEPARATOR, archiveEntity, buildIndexAlias, buildIndexClass, buildIndexOu, buildIndexType, buildIndexXid, calculateOu, deleteEntity, destroyEntity, getDocClient, getEntity, getTableName, indexEntity, initClient, isInitialized, putEntity, queryByAlias, queryByClass, queryByOu, queryByType, queryByXid, resetClient, updateEntity };
536
+ /**
537
+ * Unified Query Function with Auto-Detect Index Selection
538
+ *
539
+ * The query() function automatically selects the best index based on
540
+ * the filter fields provided. This simplifies query construction by
541
+ * removing the need to know which specific GSI to use.
542
+ */
543
+ // =============================================================================
544
+ // Helper Functions
545
+ // =============================================================================
546
+ /**
547
+ * Calculate the suffix based on archived/deleted flags
548
+ */
549
+ function calculateSuffix(archived, deleted) {
550
+ if (archived && deleted) {
551
+ return ARCHIVED_SUFFIX + DELETED_SUFFIX;
552
+ }
553
+ if (archived) {
554
+ return ARCHIVED_SUFFIX;
555
+ }
556
+ if (deleted) {
557
+ return DELETED_SUFFIX;
558
+ }
559
+ return "";
560
+ }
561
+ /**
562
+ * Build a combined filter object from params
563
+ */
564
+ function buildFilterObject(params) {
565
+ const result = {
566
+ model: params.model,
567
+ };
568
+ if (params.ou !== undefined) {
569
+ result.ou = params.ou;
570
+ }
571
+ if (params.filter) {
572
+ Object.assign(result, params.filter);
573
+ }
574
+ return result;
575
+ }
576
+ /**
577
+ * Score an index based on how well it matches the filter fields
578
+ */
579
+ function scoreIndex(index, filterFields) {
580
+ let matchedFields = 0;
581
+ let pkComplete = true;
582
+ for (const field of index.pk) {
583
+ if (filterFields[field] !== undefined) {
584
+ matchedFields++;
585
+ }
586
+ else {
587
+ pkComplete = false;
588
+ }
589
+ }
590
+ return {
591
+ index,
592
+ matchedFields,
593
+ pkComplete,
594
+ };
595
+ }
596
+ /**
597
+ * Select the best index for the given filter
598
+ *
599
+ * Scoring criteria:
600
+ * 1. Index must have all pk fields present (pkComplete)
601
+ * 2. Prefer indexes with more matched fields
602
+ * 3. Prefer more specific indexes (more pk fields)
603
+ */
604
+ function selectBestIndex(indexes, filterFields) {
605
+ const scores = indexes.map((index) => scoreIndex(index, filterFields));
606
+ // Filter to only complete matches
607
+ const completeMatches = scores.filter((s) => s.pkComplete);
608
+ if (completeMatches.length === 0) {
609
+ const availableIndexes = indexes
610
+ .map((i) => i.name ?? `[${i.pk.join(", ")}]`)
611
+ .join(", ");
612
+ const providedFields = Object.keys(filterFields).join(", ");
613
+ throw new ConfigurationError(`No index matches filter fields. ` +
614
+ `Provided: ${providedFields}. ` +
615
+ `Available indexes: ${availableIndexes}`);
616
+ }
617
+ // Sort by:
618
+ // 1. More matched fields first (descending)
619
+ // 2. More pk fields (more specific) first (descending)
620
+ completeMatches.sort((a, b) => {
621
+ const fieldDiff = b.matchedFields - a.matchedFields;
622
+ if (fieldDiff !== 0)
623
+ return fieldDiff;
624
+ return b.index.pk.length - a.index.pk.length;
625
+ });
626
+ return completeMatches[0].index;
627
+ }
628
+ // =============================================================================
629
+ // Main Query Function
630
+ // =============================================================================
631
+ /**
632
+ * Query entities with automatic index selection
633
+ *
634
+ * The query function automatically selects the best GSI based on
635
+ * the filter fields provided. This removes the need to know which
636
+ * specific query function (queryByOu, queryByAlias, etc.) to use.
637
+ *
638
+ * @example
639
+ * // Uses indexOu (pk: ["ou", "model"])
640
+ * const allMessages = await query({ model: "message", ou: `chat#${chatId}` });
641
+ *
642
+ * @example
643
+ * // Uses indexAlias (pk: ["ou", "model", "alias"])
644
+ * const byAlias = await query({
645
+ * model: "record",
646
+ * ou: "@",
647
+ * filter: { alias: "my-record" },
648
+ * });
649
+ *
650
+ * @example
651
+ * // Uses a custom registered index if model has one
652
+ * const byChat = await query({
653
+ * model: "message",
654
+ * filter: { chatId: "abc-123" },
655
+ * });
656
+ */
657
+ async function query(params) {
658
+ const { archived = false, ascending = false, deleted = false, limit, model, startKey, } = params;
659
+ // Build the combined filter object
660
+ const filterFields = buildFilterObject(params);
661
+ // Get indexes for this model (custom or DEFAULT_INDEXES)
662
+ const indexes = getModelIndexes(model);
663
+ // Select the best matching index
664
+ const selectedIndex = selectBestIndex(indexes, filterFields);
665
+ const indexName = selectedIndex.name ?? generateIndexName(selectedIndex.pk);
666
+ // Build the partition key value
667
+ const suffix = calculateSuffix(archived, deleted);
668
+ const keyValue = buildCompositeKey(filterFields, selectedIndex.pk, suffix);
669
+ // Execute the query
670
+ const docClient = getDocClient();
671
+ const tableName = getTableName();
672
+ const command = new QueryCommand({
673
+ ExclusiveStartKey: startKey,
674
+ ExpressionAttributeNames: {
675
+ "#pk": indexName,
676
+ },
677
+ ExpressionAttributeValues: {
678
+ ":pkValue": keyValue,
679
+ },
680
+ IndexName: indexName,
681
+ KeyConditionExpression: "#pk = :pkValue",
682
+ ...(limit && { Limit: limit }),
683
+ ScanIndexForward: ascending,
684
+ TableName: tableName,
685
+ });
686
+ const response = await docClient.send(command);
687
+ return {
688
+ items: (response.Items ?? []),
689
+ lastEvaluatedKey: response.LastEvaluatedKey,
690
+ };
691
+ }
692
+ /**
693
+ * Generate an index name from pk fields
694
+ */
695
+ function generateIndexName(pk) {
696
+ const suffix = pk
697
+ .map((field) => field.charAt(0).toUpperCase() + field.slice(1))
698
+ .join("");
699
+ return `index${suffix}`;
700
+ }
701
+
702
+ /**
703
+ * Seed a single entity if it doesn't already exist
704
+ *
705
+ * @param entity - Partial entity with at least alias, model, and ou
706
+ * @returns true if entity was created, false if it already exists
707
+ */
708
+ async function seedEntityIfNotExists(entity) {
709
+ if (!entity.alias || !entity.model || !entity.ou) {
710
+ throw new Error("Entity must have alias, model, and ou to check existence");
711
+ }
712
+ // Check if entity already exists
713
+ const existing = await queryByAlias({
714
+ alias: entity.alias,
715
+ model: entity.model,
716
+ ou: entity.ou,
717
+ });
718
+ if (existing) {
719
+ return false;
720
+ }
721
+ // Generate required fields if missing
722
+ const now = new Date().toISOString();
723
+ const completeEntity = {
724
+ createdAt: entity.createdAt ?? now,
725
+ id: entity.id ?? crypto.randomUUID(),
726
+ model: entity.model,
727
+ name: entity.name ?? entity.alias,
728
+ ou: entity.ou,
729
+ sequence: entity.sequence ?? Date.now(),
730
+ updatedAt: entity.updatedAt ?? now,
731
+ ...entity,
732
+ };
733
+ await putEntity({ entity: completeEntity });
734
+ return true;
735
+ }
736
+ /**
737
+ * Seed multiple entities (idempotent)
738
+ *
739
+ * - Checks existence by alias (via queryByAlias) before creating
740
+ * - Auto-generates id (UUID), createdAt, updatedAt if missing
741
+ * - Skip existing unless replace: true
742
+ * - Returns counts of created/skipped/errors
743
+ *
744
+ * @param entities - Array of partial entities to seed
745
+ * @param options - Seed options
746
+ * @returns Result with created, skipped, and errors arrays
747
+ */
748
+ async function seedEntities(entities, options = {}) {
749
+ const { dryRun = false, replace = false } = options;
750
+ const result = {
751
+ created: [],
752
+ errors: [],
753
+ skipped: [],
754
+ };
755
+ for (const entity of entities) {
756
+ const alias = entity.alias ?? entity.name ?? "unknown";
757
+ try {
758
+ if (!entity.model || !entity.ou) {
759
+ throw new Error("Entity must have model and ou");
760
+ }
761
+ // For entities with alias, check existence
762
+ if (entity.alias) {
763
+ const existing = await queryByAlias({
764
+ alias: entity.alias,
765
+ model: entity.model,
766
+ ou: entity.ou,
767
+ });
768
+ if (existing && !replace) {
769
+ result.skipped.push(alias);
770
+ continue;
771
+ }
772
+ // If replacing, use existing ID to update rather than create new
773
+ if (existing && replace) {
774
+ entity.id = existing.id;
775
+ }
776
+ }
777
+ if (dryRun) {
778
+ result.created.push(alias);
779
+ continue;
780
+ }
781
+ // Generate required fields if missing
782
+ const now = new Date().toISOString();
783
+ const completeEntity = {
784
+ createdAt: entity.createdAt ?? now,
785
+ id: entity.id ?? crypto.randomUUID(),
786
+ model: entity.model,
787
+ name: entity.name ?? entity.alias ?? "Unnamed",
788
+ ou: entity.ou,
789
+ sequence: entity.sequence ?? Date.now(),
790
+ updatedAt: entity.updatedAt ?? now,
791
+ ...entity,
792
+ };
793
+ await putEntity({ entity: completeEntity });
794
+ result.created.push(alias);
795
+ }
796
+ catch (error) {
797
+ const errorMessage = error instanceof Error ? error.message : String(error);
798
+ result.errors.push({ alias, error: errorMessage });
799
+ }
800
+ }
801
+ return result;
802
+ }
803
+ /**
804
+ * Export entities by model and organizational unit
805
+ *
806
+ * - Paginates through all matching entities via queryByOu
807
+ * - Returns entities sorted by sequence (ascending)
808
+ *
809
+ * @param model - The entity model name
810
+ * @param ou - The organizational unit
811
+ * @param limit - Optional maximum number of entities to export
812
+ * @returns Export result with entities and count
813
+ */
814
+ async function exportEntities(model, ou, limit) {
815
+ const entities = [];
816
+ let startKey;
817
+ let remaining = limit;
818
+ do {
819
+ const batchLimit = remaining !== undefined ? Math.min(remaining, 100) : undefined;
820
+ const { items, lastEvaluatedKey } = await queryByOu({
821
+ ascending: true,
822
+ limit: batchLimit,
823
+ model,
824
+ ou,
825
+ startKey,
826
+ });
827
+ entities.push(...items);
828
+ startKey = lastEvaluatedKey;
829
+ if (remaining !== undefined) {
830
+ remaining -= items.length;
831
+ }
832
+ } while (startKey && (remaining === undefined || remaining > 0));
833
+ return {
834
+ count: entities.length,
835
+ entities,
836
+ };
837
+ }
838
+ /**
839
+ * Export entities as a JSON string
840
+ *
841
+ * @param model - The entity model name
842
+ * @param ou - The organizational unit
843
+ * @param pretty - Format JSON with indentation (default: true)
844
+ * @returns JSON string of exported entities
845
+ */
846
+ async function exportEntitiesToJson(model, ou, pretty = true) {
847
+ const { entities } = await exportEntities(model, ou);
848
+ return pretty ? JSON.stringify(entities, null, 2) : JSON.stringify(entities);
849
+ }
850
+
851
+ export { INDEX_ALIAS, INDEX_CLASS, INDEX_OU, INDEX_TYPE, INDEX_XID, archiveEntity, buildCompositeKey, buildIndexAlias, buildIndexClass, buildIndexOu, buildIndexType, buildIndexXid, calculateOu, deleteEntity, destroyEntity, exportEntities, exportEntitiesToJson, getDocClient, getEntity, getTableName, indexEntity, initClient, isInitialized, putEntity, query, queryByAlias, queryByClass, queryByOu, queryByType, queryByXid, resetClient, seedEntities, seedEntityIfNotExists, updateEntity };
538
852
  //# sourceMappingURL=index.js.map