@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.
@@ -1,5 +1,5 @@
1
1
  import { fabricMcp } from '@jaypie/fabric/mcp';
2
- import { getModelIndexes, populateIndexKeys, SEPARATOR, fabricService, ARCHIVED_SUFFIX, DELETED_SUFFIX, getAllRegisteredIndexes } from '@jaypie/fabric';
2
+ import { getModelIndexes, populateIndexKeys, buildCompositeKey as buildCompositeKey$1, fabricService, ARCHIVED_SUFFIX, DELETED_SUFFIX, getGsiAttributeNames, SEPARATOR, getAllRegisteredIndexes } from '@jaypie/fabric';
3
3
  import { DynamoDBClient, DescribeTableCommand, CreateTableCommand } from '@aws-sdk/client-dynamodb';
4
4
  import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
5
5
  import { ConfigurationError } from '@jaypie/errors';
@@ -75,85 +75,45 @@ function isInitialized() {
75
75
  return docClient !== null && tableName !== null;
76
76
  }
77
77
 
78
- // Re-export shared constants from fabric
79
- // GSI names
80
- const INDEX_ALIAS = "indexAlias";
81
- const INDEX_CATEGORY = "indexCategory";
82
- const INDEX_SCOPE = "indexScope";
83
- const INDEX_TYPE = "indexType";
84
- const INDEX_XID = "indexXid";
85
-
86
78
  // =============================================================================
87
79
  // Key Builders
88
80
  // =============================================================================
89
81
  /**
90
- * Build the indexScope key for hierarchical queries
91
- * @param scope - The scope (APEX or "{parent.model}#{parent.id}")
92
- * @param model - The entity model name
93
- * @returns Composite key: "{scope}#{model}"
94
- */
95
- function buildIndexScope(scope, model) {
96
- return `${scope}${SEPARATOR}${model}`;
97
- }
98
- /**
99
- * Build the indexAlias key for human-friendly lookups
100
- * @param scope - The scope
101
- * @param model - The entity model name
102
- * @param alias - The human-friendly alias
103
- * @returns Composite key: "{scope}#{model}#{alias}"
104
- */
105
- function buildIndexAlias(scope, model, alias) {
106
- return `${scope}${SEPARATOR}${model}${SEPARATOR}${alias}`;
107
- }
108
- /**
109
- * Build the indexCategory key for category filtering
110
- * @param scope - The scope
111
- * @param model - The entity model name
112
- * @param category - The category classification
113
- * @returns Composite key: "{scope}#{model}#{category}"
114
- */
115
- function buildIndexCategory(scope, model, category) {
116
- return `${scope}${SEPARATOR}${model}${SEPARATOR}${category}`;
117
- }
118
- /**
119
- * Build the indexType key for type filtering
120
- * @param scope - The scope
121
- * @param model - The entity model name
122
- * @param type - The type classification
123
- * @returns Composite key: "{scope}#{model}#{type}"
124
- */
125
- function buildIndexType(scope, model, type) {
126
- return `${scope}${SEPARATOR}${model}${SEPARATOR}${type}`;
127
- }
128
- /**
129
- * Build the indexXid key for external ID lookups
130
- * @param scope - The scope
131
- * @param model - The entity model name
132
- * @param xid - The external ID
133
- * @returns Composite key: "{scope}#{model}#{xid}"
82
+ * Build a composite key from entity fields
83
+ *
84
+ * @param entity - Entity with fields to extract
85
+ * @param fields - Field names to combine with SEPARATOR
86
+ * @param suffix - Optional suffix to append (e.g., "#deleted")
87
+ * @returns Composite key string
134
88
  */
135
- function buildIndexXid(scope, model, xid) {
136
- return `${scope}${SEPARATOR}${model}${SEPARATOR}${xid}`;
89
+ function buildCompositeKey(entity, fields, suffix) {
90
+ return buildCompositeKey$1(entity, fields, suffix);
137
91
  }
138
92
  /**
139
- * Auto-populate GSI index keys on an entity
93
+ * Auto-populate GSI index keys on an entity and advance its write timestamps.
140
94
  *
141
- * Uses the model's registered indexes (from the fabric registry).
95
+ * - Bumps `updatedAt` to now on every call.
96
+ * - Backfills `createdAt` to the same now if not already set.
97
+ * - Populates GSI attributes (pk composite and sk composite when applicable)
98
+ * using the indexes registered for the entity's model.
142
99
  *
143
- * - indexScope is always populated from scope + model
144
- * - indexAlias is populated only when alias is present
145
- * - indexCategory is populated only when category is present
146
- * - indexType is populated only when type is present
147
- * - indexXid is populated only when xid is present
100
+ * Callers (putEntity, updateEntity, deleteEntity, archiveEntity,
101
+ * transactWriteEntities) go through this one function so `updatedAt` is
102
+ * always fresh and never forgotten.
148
103
  *
149
- * @param entity - The entity to populate index keys for
150
- * @param suffix - Optional suffix to append to all index keys (e.g., "#deleted", "#archived")
151
- * @returns The entity with populated index keys
104
+ * @param entity - The entity to index
105
+ * @param suffix - Optional suffix override (defaults to archived/deleted state)
106
+ * @returns A new entity with timestamps bumped and index keys populated
152
107
  */
153
- function indexEntity(entity, suffix = "") {
108
+ function indexEntity(entity, suffix) {
109
+ const now = new Date().toISOString();
110
+ const bumped = {
111
+ ...entity,
112
+ createdAt: entity.createdAt ?? now,
113
+ updatedAt: now,
114
+ };
154
115
  const indexes = getModelIndexes(entity.model);
155
- // Cast through unknown to bridge the type gap between StorableEntity and IndexableModel
156
- return populateIndexKeys(entity, indexes, suffix);
116
+ return populateIndexKeys(bumped, indexes, suffix);
157
117
  }
158
118
 
159
119
  /**
@@ -174,20 +134,19 @@ function calculateEntitySuffix(entity) {
174
134
  return "";
175
135
  }
176
136
  /**
177
- * Get a single entity by primary key
137
+ * Get a single entity by primary key (id)
178
138
  */
179
139
  const getEntity = fabricService({
180
140
  alias: "getEntity",
181
- description: "Get a single entity by primary key",
141
+ description: "Get a single entity by id",
182
142
  input: {
183
- id: { type: String, description: "Entity ID (sort key)" },
184
- model: { type: String, description: "Entity model (partition key)" },
143
+ id: { type: String, description: "Entity id (partition key)" },
185
144
  },
186
- service: async ({ id, model }) => {
145
+ service: async ({ id }) => {
187
146
  const docClient = getDocClient();
188
147
  const tableName = getTableName();
189
148
  const command = new GetCommand({
190
- Key: { id, model },
149
+ Key: { id },
191
150
  TableName: tableName,
192
151
  });
193
152
  const response = await docClient.send(command);
@@ -195,16 +154,12 @@ const getEntity = fabricService({
195
154
  },
196
155
  });
197
156
  /**
198
- * Put (create or replace) an entity
199
- * Auto-populates GSI index keys via indexEntity
200
- *
201
- * Note: This is a regular async function (not fabricService) because it accepts
202
- * complex StorableEntity objects that can't be coerced by vocabulary's type system.
157
+ * Put (create or replace) an entity.
158
+ * `indexEntity` auto-bumps `updatedAt` and backfills `createdAt`.
203
159
  */
204
160
  async function putEntity({ entity, }) {
205
161
  const docClient = getDocClient();
206
162
  const tableName = getTableName();
207
- // Auto-populate index keys
208
163
  const indexedEntity = indexEntity(entity);
209
164
  const command = new PutCommand({
210
165
  Item: indexedEntity,
@@ -214,20 +169,13 @@ async function putEntity({ entity, }) {
214
169
  return indexedEntity;
215
170
  }
216
171
  /**
217
- * Update an existing entity
218
- * Auto-populates GSI index keys and sets updatedAt
219
- *
220
- * Note: This is a regular async function (not fabricService) because it accepts
221
- * complex StorableEntity objects that can't be coerced by vocabulary's type system.
172
+ * Update an existing entity.
173
+ * `indexEntity` auto-bumps `updatedAt` callers never set it manually.
222
174
  */
223
175
  async function updateEntity({ entity, }) {
224
176
  const docClient = getDocClient();
225
177
  const tableName = getTableName();
226
- // Update timestamp and re-index
227
- const updatedEntity = indexEntity({
228
- ...entity,
229
- updatedAt: new Date().toISOString(),
230
- });
178
+ const updatedEntity = indexEntity(entity);
231
179
  const command = new PutCommand({
232
180
  Item: updatedEntity,
233
181
  TableName: tableName,
@@ -243,25 +191,21 @@ const deleteEntity = fabricService({
243
191
  alias: "deleteEntity",
244
192
  description: "Soft delete an entity (sets deletedAt timestamp)",
245
193
  input: {
246
- id: { type: String, description: "Entity ID (sort key)" },
247
- model: { type: String, description: "Entity model (partition key)" },
194
+ id: { type: String, description: "Entity id" },
248
195
  },
249
- service: async ({ id, model }) => {
196
+ service: async ({ id }) => {
250
197
  const docClient = getDocClient();
251
198
  const tableName = getTableName();
252
- // Fetch the current entity
253
- const existing = await getEntity({ id, model });
199
+ const existing = await getEntity({ id });
254
200
  if (!existing) {
255
201
  return false;
256
202
  }
257
203
  const now = new Date().toISOString();
258
- // Build updated entity with deletedAt
204
+ // indexEntity will bump updatedAt again; set deletedAt here.
259
205
  const updatedEntity = {
260
206
  ...existing,
261
207
  deletedAt: now,
262
- updatedAt: now,
263
208
  };
264
- // Calculate suffix based on combined state (may already be archived)
265
209
  const suffix = calculateEntitySuffix(updatedEntity);
266
210
  const deletedEntity = indexEntity(updatedEntity, suffix);
267
211
  const command = new PutCommand({
@@ -280,25 +224,20 @@ const archiveEntity = fabricService({
280
224
  alias: "archiveEntity",
281
225
  description: "Archive an entity (sets archivedAt timestamp)",
282
226
  input: {
283
- id: { type: String, description: "Entity ID (sort key)" },
284
- model: { type: String, description: "Entity model (partition key)" },
227
+ id: { type: String, description: "Entity id" },
285
228
  },
286
- service: async ({ id, model }) => {
229
+ service: async ({ id }) => {
287
230
  const docClient = getDocClient();
288
231
  const tableName = getTableName();
289
- // Fetch the current entity
290
- const existing = await getEntity({ id, model });
232
+ const existing = await getEntity({ id });
291
233
  if (!existing) {
292
234
  return false;
293
235
  }
294
236
  const now = new Date().toISOString();
295
- // Build updated entity with archivedAt
296
237
  const updatedEntity = {
297
238
  ...existing,
298
239
  archivedAt: now,
299
- updatedAt: now,
300
240
  };
301
- // Calculate suffix based on combined state (may already be deleted)
302
241
  const suffix = calculateEntitySuffix(updatedEntity);
303
242
  const archivedEntity = indexEntity(updatedEntity, suffix);
304
243
  const command = new PutCommand({
@@ -317,14 +256,13 @@ const destroyEntity = fabricService({
317
256
  alias: "destroyEntity",
318
257
  description: "Hard delete an entity (permanently removes from table)",
319
258
  input: {
320
- id: { type: String, description: "Entity ID (sort key)" },
321
- model: { type: String, description: "Entity model (partition key)" },
259
+ id: { type: String, description: "Entity id" },
322
260
  },
323
- service: async ({ id, model }) => {
261
+ service: async ({ id }) => {
324
262
  const docClient = getDocClient();
325
263
  const tableName = getTableName();
326
264
  const command = new DeleteCommand({
327
- Key: { id, model },
265
+ Key: { id },
328
266
  TableName: tableName,
329
267
  });
330
268
  await docClient.send(command);
@@ -332,9 +270,13 @@ const destroyEntity = fabricService({
332
270
  },
333
271
  });
334
272
 
273
+ // =============================================================================
274
+ // Helpers
275
+ // =============================================================================
335
276
  /**
336
- * Calculate the suffix based on archived/deleted flags
337
- * When both are true, returns combined suffix (archived first, alphabetically)
277
+ * Calculate the suffix for the GSI partition key based on archived/deleted
278
+ * flags. Suffix stays on pk so deleted/archived entities are queried as their
279
+ * own partition (active queries skip them naturally).
338
280
  */
339
281
  function calculateSuffix({ archived, deleted, }) {
340
282
  if (archived && deleted) {
@@ -349,22 +291,51 @@ function calculateSuffix({ archived, deleted, }) {
349
291
  return "";
350
292
  }
351
293
  /**
352
- * Execute a GSI query with common options
294
+ * Find the registered index for a model that matches a given partition-key
295
+ * shape. The matching index is the first one whose pk equals the expected
296
+ * fields. Throws ConfigurationError if no match is found.
297
+ */
298
+ function requireIndex(model, pkFields) {
299
+ const indexes = getModelIndexes(model);
300
+ const match = indexes.find((index) => index.pk.length === pkFields.length &&
301
+ index.pk.every((field, i) => field === pkFields[i]));
302
+ if (!match) {
303
+ throw new ConfigurationError(`Model "${model}" has no index with pk=[${pkFields.join(", ")}]. ` +
304
+ `Register one with fabricIndex(${pkFields.length > 1 ? `"${pkFields[1]}"` : ""}).`);
305
+ }
306
+ return match;
307
+ }
308
+ /**
309
+ * Execute a GSI query.
310
+ *
311
+ * - pk: exact match on the index partition key
312
+ * - skPrefix: optional begins_with on the index sort key (used when the index
313
+ * has a composite sk like [scope, updatedAt])
353
314
  */
354
- async function executeQuery(indexName, keyValue, options = {}) {
355
- const { ascending = false, limit, startKey } = options;
315
+ async function executeQuery(index, pkValue, options = {}) {
316
+ const { ascending = false, limit, skPrefix, startKey } = options;
317
+ const attrs = getGsiAttributeNames(index);
318
+ const indexName = attrs.pk;
319
+ const expressionAttributeNames = {
320
+ "#pk": indexName,
321
+ };
322
+ const expressionAttributeValues = {
323
+ ":pkValue": pkValue,
324
+ };
325
+ let keyConditionExpression = "#pk = :pkValue";
326
+ if (skPrefix !== undefined && attrs.sk) {
327
+ expressionAttributeNames["#sk"] = attrs.sk;
328
+ expressionAttributeValues[":skPrefix"] = skPrefix;
329
+ keyConditionExpression += " AND begins_with(#sk, :skPrefix)";
330
+ }
356
331
  const docClient = getDocClient();
357
332
  const tableName = getTableName();
358
333
  const command = new QueryCommand({
359
334
  ExclusiveStartKey: startKey,
360
- ExpressionAttributeNames: {
361
- "#pk": indexName,
362
- },
363
- ExpressionAttributeValues: {
364
- ":pkValue": keyValue,
365
- },
335
+ ExpressionAttributeNames: expressionAttributeNames,
336
+ ExpressionAttributeValues: expressionAttributeValues,
366
337
  IndexName: indexName,
367
- KeyConditionExpression: "#pk = :pkValue",
338
+ KeyConditionExpression: keyConditionExpression,
368
339
  ...(limit && { Limit: limit }),
369
340
  ScanIndexForward: ascending,
370
341
  TableName: tableName,
@@ -375,25 +346,30 @@ async function executeQuery(indexName, keyValue, options = {}) {
375
346
  lastEvaluatedKey: response.LastEvaluatedKey,
376
347
  };
377
348
  }
349
+ function scopePrefix(scope) {
350
+ return scope === undefined ? undefined : `${scope}${SEPARATOR}`;
351
+ }
352
+ // =============================================================================
353
+ // Query Functions
354
+ // =============================================================================
378
355
  /**
379
- * Query entities by scope (parent hierarchy)
380
- * Uses indexScope GSI
381
- *
382
- * Note: This is a regular async function (not fabricService) because it accepts
383
- * complex startKey objects that can't be coerced by vocabulary's type system.
356
+ * List entities of a model, optionally narrowed to a scope.
357
+ * Requires the model to register `fabricIndex()` (pk=[model]).
384
358
  */
385
359
  async function queryByScope({ archived = false, ascending = false, deleted = false, limit, model, scope, startKey, }) {
360
+ const index = requireIndex(model, ["model"]);
386
361
  const suffix = calculateSuffix({ archived, deleted });
387
- const keyValue = buildIndexScope(scope, model) + suffix;
388
- return executeQuery(INDEX_SCOPE, keyValue, {
362
+ const pkValue = buildCompositeKey({ model }, ["model"], suffix);
363
+ return executeQuery(index, pkValue, {
389
364
  ascending,
390
365
  limit,
366
+ skPrefix: scopePrefix(scope),
391
367
  startKey,
392
368
  });
393
369
  }
394
370
  /**
395
- * Query a single entity by human-friendly alias
396
- * Uses indexAlias GSI
371
+ * Query a single entity by human-friendly alias.
372
+ * Requires the model to register `fabricIndex("alias")`.
397
373
  */
398
374
  const queryByAlias = fabricService({
399
375
  alias: "queryByAlias",
@@ -413,7 +389,11 @@ const queryByAlias = fabricService({
413
389
  description: "Query deleted entities instead of active ones",
414
390
  },
415
391
  model: { type: String, description: "Entity model name" },
416
- scope: { type: String, description: "Scope (@ for root)" },
392
+ scope: {
393
+ type: String,
394
+ required: false,
395
+ description: "Optional scope narrower (begins_with on sk)",
396
+ },
417
397
  },
418
398
  service: async ({ alias, archived, deleted, model, scope, }) => {
419
399
  const aliasStr = alias;
@@ -421,52 +401,52 @@ const queryByAlias = fabricService({
421
401
  const deletedBool = deleted;
422
402
  const modelStr = model;
423
403
  const scopeStr = scope;
404
+ const index = requireIndex(modelStr, ["model", "alias"]);
424
405
  const suffix = calculateSuffix({
425
406
  archived: archivedBool,
426
407
  deleted: deletedBool,
427
408
  });
428
- const keyValue = buildIndexAlias(scopeStr, modelStr, aliasStr) + suffix;
429
- const result = await executeQuery(INDEX_ALIAS, keyValue, {
409
+ const pkValue = buildCompositeKey({ model: modelStr, alias: aliasStr }, ["model", "alias"], suffix);
410
+ const result = await executeQuery(index, pkValue, {
430
411
  limit: 1,
412
+ skPrefix: scopePrefix(scopeStr),
431
413
  });
432
414
  return result.items[0] ?? null;
433
415
  },
434
416
  });
435
417
  /**
436
- * Query entities by category classification
437
- * Uses indexCategory GSI
438
- *
439
- * Note: This is a regular async function (not fabricService) because it accepts
440
- * complex startKey objects that can't be coerced by vocabulary's type system.
418
+ * Query entities by category classification.
419
+ * Requires the model to register `fabricIndex("category")`.
441
420
  */
442
421
  async function queryByCategory({ archived = false, ascending = false, category, deleted = false, limit, model, scope, startKey, }) {
422
+ const index = requireIndex(model, ["model", "category"]);
443
423
  const suffix = calculateSuffix({ archived, deleted });
444
- const keyValue = buildIndexCategory(scope, model, category) + suffix;
445
- return executeQuery(INDEX_CATEGORY, keyValue, {
424
+ const pkValue = buildCompositeKey({ model, category }, ["model", "category"], suffix);
425
+ return executeQuery(index, pkValue, {
446
426
  ascending,
447
427
  limit,
428
+ skPrefix: scopePrefix(scope),
448
429
  startKey,
449
430
  });
450
431
  }
451
432
  /**
452
- * Query entities by type classification
453
- * Uses indexType GSI
454
- *
455
- * Note: This is a regular async function (not fabricService) because it accepts
456
- * complex startKey objects that can't be coerced by vocabulary's type system.
433
+ * Query entities by type classification.
434
+ * Requires the model to register `fabricIndex("type")`.
457
435
  */
458
436
  async function queryByType({ archived = false, ascending = false, deleted = false, limit, model, scope, startKey, type, }) {
437
+ const index = requireIndex(model, ["model", "type"]);
459
438
  const suffix = calculateSuffix({ archived, deleted });
460
- const keyValue = buildIndexType(scope, model, type) + suffix;
461
- return executeQuery(INDEX_TYPE, keyValue, {
439
+ const pkValue = buildCompositeKey({ model, type }, ["model", "type"], suffix);
440
+ return executeQuery(index, pkValue, {
462
441
  ascending,
463
442
  limit,
443
+ skPrefix: scopePrefix(scope),
464
444
  startKey,
465
445
  });
466
446
  }
467
447
  /**
468
- * Query a single entity by external ID
469
- * Uses indexXid GSI
448
+ * Query a single entity by external ID.
449
+ * Requires the model to register `fabricIndex("xid")`.
470
450
  */
471
451
  const queryByXid = fabricService({
472
452
  alias: "queryByXid",
@@ -485,7 +465,11 @@ const queryByXid = fabricService({
485
465
  description: "Query deleted entities instead of active ones",
486
466
  },
487
467
  model: { type: String, description: "Entity model name" },
488
- scope: { type: String, description: "Scope (@ for root)" },
468
+ scope: {
469
+ type: String,
470
+ required: false,
471
+ description: "Optional scope narrower (begins_with on sk)",
472
+ },
489
473
  xid: { type: String, description: "External ID" },
490
474
  },
491
475
  service: async ({ archived, deleted, model, scope, xid, }) => {
@@ -494,13 +478,15 @@ const queryByXid = fabricService({
494
478
  const modelStr = model;
495
479
  const scopeStr = scope;
496
480
  const xidStr = xid;
481
+ const index = requireIndex(modelStr, ["model", "xid"]);
497
482
  const suffix = calculateSuffix({
498
483
  archived: archivedBool,
499
484
  deleted: deletedBool,
500
485
  });
501
- const keyValue = buildIndexXid(scopeStr, modelStr, xidStr) + suffix;
502
- const result = await executeQuery(INDEX_XID, keyValue, {
486
+ const pkValue = buildCompositeKey({ model: modelStr, xid: xidStr }, ["model", "xid"], suffix);
487
+ const result = await executeQuery(index, pkValue, {
503
488
  limit: 1,
489
+ skPrefix: scopePrefix(scopeStr),
504
490
  });
505
491
  return result.items[0] ?? null;
506
492
  },
@@ -510,53 +496,26 @@ const DEFAULT_ENDPOINT = "http://127.0.0.1:8000";
510
496
  const DEFAULT_REGION$1 = "us-east-1";
511
497
  const DEFAULT_TABLE_NAME$1 = "jaypie-local";
512
498
  // =============================================================================
513
- // Index to GSI Conversion
499
+ // Index GSI Conversion
514
500
  // =============================================================================
515
501
  /**
516
- * Generate an index name from pk fields (if not provided)
517
- */
518
- function generateIndexName(pk) {
519
- const suffix = pk
520
- .map((field) => field.charAt(0).toUpperCase() + field.slice(1))
521
- .join("");
522
- return `index${suffix}`;
523
- }
524
- /**
525
- * Collect all unique indexes from registered models
526
- */
527
- function collectAllIndexes() {
528
- const indexMap = new Map();
529
- for (const index of getAllRegisteredIndexes()) {
530
- const name = index.name ?? generateIndexName(index.pk);
531
- if (!indexMap.has(name)) {
532
- indexMap.set(name, { ...index, name });
533
- }
534
- }
535
- return Array.from(indexMap.values());
536
- }
537
- /**
538
- * Build attribute definitions from indexes
502
+ * Build attribute definitions from registered indexes.
503
+ * Primary key is `id` (STRING) only; GSI pk and composite sk attributes
504
+ * are all STRING. A single-field sk (e.g., raw `updatedAt`) is also STRING.
539
505
  */
540
506
  function buildAttributeDefinitions(indexes) {
541
507
  const attrs = new Map();
542
- // Primary key attributes
543
- attrs.set("model", "S");
508
+ // Primary key: id only
544
509
  attrs.set("id", "S");
545
- attrs.set("sequence", "N");
546
- // GSI attributes (partition keys are always strings)
547
- for (const index of indexes) {
548
- const indexName = index.name ?? generateIndexName(index.pk);
549
- attrs.set(indexName, "S");
550
- }
551
- // Sort keys (sequence is always a number, others would be strings)
552
- // Note: Currently all indexes use sequence as SK, so this is mostly future-proofing
553
510
  for (const index of indexes) {
554
- const sk = index.sk ?? ["sequence"];
555
- for (const skField of sk) {
556
- if (!attrs.has(skField)) {
557
- // Assume string unless it's sequence
558
- attrs.set(skField, skField === "sequence" ? "N" : "S");
559
- }
511
+ const { pk, sk } = getGsiAttributeNames(index);
512
+ // All pk attributes are composite strings
513
+ if (!attrs.has(pk))
514
+ attrs.set(pk, "S");
515
+ if (sk && !attrs.has(sk)) {
516
+ // Single-field `sequence` remains NUMBER for back-compat callers;
517
+ // every other sk attribute (composite or not) is STRING.
518
+ attrs.set(sk, sk === "sequence" ? "N" : "S");
560
519
  }
561
520
  }
562
521
  return Array.from(attrs.entries())
@@ -567,22 +526,19 @@ function buildAttributeDefinitions(indexes) {
567
526
  }));
568
527
  }
569
528
  /**
570
- * Build GSI definitions from indexes
529
+ * Build GSI definitions from registered indexes
571
530
  */
572
531
  function buildGSIs(indexes) {
573
532
  const gsiProjection = { ProjectionType: "ALL" };
574
533
  return indexes.map((index) => {
575
- const indexName = index.name ?? generateIndexName(index.pk);
576
- const sk = index.sk ?? ["sequence"];
577
- // For GSIs, the partition key attribute name IS the index name
578
- // (e.g., indexOu stores the composite key value "@#record")
534
+ const { pk, sk } = getGsiAttributeNames(index);
535
+ const keySchema = [{ AttributeName: pk, KeyType: "HASH" }];
536
+ if (sk) {
537
+ keySchema.push({ AttributeName: sk, KeyType: "RANGE" });
538
+ }
579
539
  return {
580
- IndexName: indexName,
581
- KeySchema: [
582
- { AttributeName: indexName, KeyType: "HASH" },
583
- // Use first SK field as the range key attribute
584
- { AttributeName: sk[0], KeyType: "RANGE" },
585
- ],
540
+ IndexName: pk,
541
+ KeySchema: keySchema,
586
542
  Projection: gsiProjection,
587
543
  };
588
544
  });
@@ -591,20 +547,17 @@ function buildGSIs(indexes) {
591
547
  // Table Creation
592
548
  // =============================================================================
593
549
  /**
594
- * DynamoDB table schema with Jaypie GSI pattern
595
- *
596
- * Collects indexes from models registered via registerModel()
550
+ * DynamoDB table schema with Jaypie GSI pattern.
551
+ * Primary key is `id` only. GSIs come from models registered via
552
+ * `registerModel()`, shaped by `fabricIndex()`.
597
553
  */
598
554
  function createTableParams(tableName, billingMode) {
599
- const allIndexes = collectAllIndexes();
555
+ const allIndexes = getAllRegisteredIndexes();
600
556
  return {
601
557
  AttributeDefinitions: buildAttributeDefinitions(allIndexes),
602
558
  BillingMode: billingMode,
603
559
  GlobalSecondaryIndexes: buildGSIs(allIndexes),
604
- KeySchema: [
605
- { AttributeName: "model", KeyType: "HASH" },
606
- { AttributeName: "id", KeyType: "RANGE" },
607
- ],
560
+ KeySchema: [{ AttributeName: "id", KeyType: "HASH" }],
608
561
  TableName: tableName,
609
562
  };
610
563
  }
@@ -644,7 +597,6 @@ const createTableHandler = fabricService({
644
597
  region: DEFAULT_REGION$1,
645
598
  });
646
599
  try {
647
- // Check if table already exists
648
600
  await client.send(new DescribeTableCommand({ TableName: tableNameStr }));
649
601
  return {
650
602
  message: `Table "${tableNameStr}" already exists`,
@@ -657,7 +609,6 @@ const createTableHandler = fabricService({
657
609
  throw error;
658
610
  }
659
611
  }
660
- // Create the table
661
612
  const tableParams = createTableParams(tableNameStr, billingModeStr);
662
613
  await client.send(new CreateTableCommand(tableParams));
663
614
  return {