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