@jaypie/dynamodb 0.4.4 → 0.6.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 (createEntity, 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,39 +156,38 @@ 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
+ * Create an entity. Fails the conditional write if `id` already exists,
160
+ * returning `null` instead of throwing. Use `updateEntity` to overwrite.
161
+ * `indexEntity` auto-bumps `updatedAt` and backfills `createdAt`.
205
162
  */
206
- async function putEntity({ entity, }) {
163
+ async function createEntity({ entity, }) {
207
164
  const docClient = getDocClient();
208
165
  const tableName = getTableName();
209
- // Auto-populate index keys
210
166
  const indexedEntity = indexEntity(entity);
211
167
  const command = new libDynamodb.PutCommand({
168
+ ConditionExpression: "attribute_not_exists(id)",
212
169
  Item: indexedEntity,
213
170
  TableName: tableName,
214
171
  });
215
- await docClient.send(command);
172
+ try {
173
+ await docClient.send(command);
174
+ }
175
+ catch (error) {
176
+ if (error?.name === "ConditionalCheckFailedException") {
177
+ return null;
178
+ }
179
+ throw error;
180
+ }
216
181
  return indexedEntity;
217
182
  }
218
183
  /**
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.
184
+ * Update an existing entity.
185
+ * `indexEntity` auto-bumps `updatedAt` callers never set it manually.
224
186
  */
225
187
  async function updateEntity({ entity, }) {
226
188
  const docClient = getDocClient();
227
189
  const tableName = getTableName();
228
- // Update timestamp and re-index
229
- const updatedEntity = indexEntity({
230
- ...entity,
231
- updatedAt: new Date().toISOString(),
232
- });
190
+ const updatedEntity = indexEntity(entity);
233
191
  const command = new libDynamodb.PutCommand({
234
192
  Item: updatedEntity,
235
193
  TableName: tableName,
@@ -245,25 +203,21 @@ const deleteEntity = fabric.fabricService({
245
203
  alias: "deleteEntity",
246
204
  description: "Soft delete an entity (sets deletedAt timestamp)",
247
205
  input: {
248
- id: { type: String, description: "Entity ID (sort key)" },
249
- model: { type: String, description: "Entity model (partition key)" },
206
+ id: { type: String, description: "Entity id" },
250
207
  },
251
- service: async ({ id, model }) => {
208
+ service: async ({ id }) => {
252
209
  const docClient = getDocClient();
253
210
  const tableName = getTableName();
254
- // Fetch the current entity
255
- const existing = await getEntity({ id, model });
211
+ const existing = await getEntity({ id });
256
212
  if (!existing) {
257
213
  return false;
258
214
  }
259
215
  const now = new Date().toISOString();
260
- // Build updated entity with deletedAt
216
+ // indexEntity will bump updatedAt again; set deletedAt here.
261
217
  const updatedEntity = {
262
218
  ...existing,
263
219
  deletedAt: now,
264
- updatedAt: now,
265
220
  };
266
- // Calculate suffix based on combined state (may already be archived)
267
221
  const suffix = calculateEntitySuffix(updatedEntity);
268
222
  const deletedEntity = indexEntity(updatedEntity, suffix);
269
223
  const command = new libDynamodb.PutCommand({
@@ -282,25 +236,20 @@ const archiveEntity = fabric.fabricService({
282
236
  alias: "archiveEntity",
283
237
  description: "Archive an entity (sets archivedAt timestamp)",
284
238
  input: {
285
- id: { type: String, description: "Entity ID (sort key)" },
286
- model: { type: String, description: "Entity model (partition key)" },
239
+ id: { type: String, description: "Entity id" },
287
240
  },
288
- service: async ({ id, model }) => {
241
+ service: async ({ id }) => {
289
242
  const docClient = getDocClient();
290
243
  const tableName = getTableName();
291
- // Fetch the current entity
292
- const existing = await getEntity({ id, model });
244
+ const existing = await getEntity({ id });
293
245
  if (!existing) {
294
246
  return false;
295
247
  }
296
248
  const now = new Date().toISOString();
297
- // Build updated entity with archivedAt
298
249
  const updatedEntity = {
299
250
  ...existing,
300
251
  archivedAt: now,
301
- updatedAt: now,
302
252
  };
303
- // Calculate suffix based on combined state (may already be deleted)
304
253
  const suffix = calculateEntitySuffix(updatedEntity);
305
254
  const archivedEntity = indexEntity(updatedEntity, suffix);
306
255
  const command = new libDynamodb.PutCommand({
@@ -319,14 +268,13 @@ const destroyEntity = fabric.fabricService({
319
268
  alias: "destroyEntity",
320
269
  description: "Hard delete an entity (permanently removes from table)",
321
270
  input: {
322
- id: { type: String, description: "Entity ID (sort key)" },
323
- model: { type: String, description: "Entity model (partition key)" },
271
+ id: { type: String, description: "Entity id" },
324
272
  },
325
- service: async ({ id, model }) => {
273
+ service: async ({ id }) => {
326
274
  const docClient = getDocClient();
327
275
  const tableName = getTableName();
328
276
  const command = new libDynamodb.DeleteCommand({
329
- Key: { id, model },
277
+ Key: { id },
330
278
  TableName: tableName,
331
279
  });
332
280
  await docClient.send(command);
@@ -334,9 +282,13 @@ const destroyEntity = fabric.fabricService({
334
282
  },
335
283
  });
336
284
 
285
+ // =============================================================================
286
+ // Helpers
287
+ // =============================================================================
337
288
  /**
338
- * Calculate the suffix based on archived/deleted flags
339
- * When both are true, returns combined suffix (archived first, alphabetically)
289
+ * Calculate the suffix for the GSI partition key based on archived/deleted
290
+ * flags. Suffix stays on pk so deleted/archived entities are queried as their
291
+ * own partition (active queries skip them naturally).
340
292
  */
341
293
  function calculateSuffix({ archived, deleted, }) {
342
294
  if (archived && deleted) {
@@ -351,22 +303,51 @@ function calculateSuffix({ archived, deleted, }) {
351
303
  return "";
352
304
  }
353
305
  /**
354
- * Execute a GSI query with common options
306
+ * Find the registered index for a model that matches a given partition-key
307
+ * shape. The matching index is the first one whose pk equals the expected
308
+ * fields. Throws ConfigurationError if no match is found.
309
+ */
310
+ function requireIndex(model, pkFields) {
311
+ const indexes = fabric.getModelIndexes(model);
312
+ const match = indexes.find((index) => index.pk.length === pkFields.length &&
313
+ index.pk.every((field, i) => field === pkFields[i]));
314
+ if (!match) {
315
+ throw new errors.ConfigurationError(`Model "${model}" has no index with pk=[${pkFields.join(", ")}]. ` +
316
+ `Register one with fabricIndex(${pkFields.length > 1 ? `"${pkFields[1]}"` : ""}).`);
317
+ }
318
+ return match;
319
+ }
320
+ /**
321
+ * Execute a GSI query.
322
+ *
323
+ * - pk: exact match on the index partition key
324
+ * - skPrefix: optional begins_with on the index sort key (used when the index
325
+ * has a composite sk like [scope, updatedAt])
355
326
  */
356
- async function executeQuery(indexName, keyValue, options = {}) {
357
- const { ascending = false, limit, startKey } = options;
327
+ async function executeQuery(index, pkValue, options = {}) {
328
+ const { ascending = false, limit, skPrefix, startKey } = options;
329
+ const attrs = fabric.getGsiAttributeNames(index);
330
+ const indexName = attrs.pk;
331
+ const expressionAttributeNames = {
332
+ "#pk": indexName,
333
+ };
334
+ const expressionAttributeValues = {
335
+ ":pkValue": pkValue,
336
+ };
337
+ let keyConditionExpression = "#pk = :pkValue";
338
+ if (skPrefix !== undefined && attrs.sk) {
339
+ expressionAttributeNames["#sk"] = attrs.sk;
340
+ expressionAttributeValues[":skPrefix"] = skPrefix;
341
+ keyConditionExpression += " AND begins_with(#sk, :skPrefix)";
342
+ }
358
343
  const docClient = getDocClient();
359
344
  const tableName = getTableName();
360
345
  const command = new libDynamodb.QueryCommand({
361
346
  ExclusiveStartKey: startKey,
362
- ExpressionAttributeNames: {
363
- "#pk": indexName,
364
- },
365
- ExpressionAttributeValues: {
366
- ":pkValue": keyValue,
367
- },
347
+ ExpressionAttributeNames: expressionAttributeNames,
348
+ ExpressionAttributeValues: expressionAttributeValues,
368
349
  IndexName: indexName,
369
- KeyConditionExpression: "#pk = :pkValue",
350
+ KeyConditionExpression: keyConditionExpression,
370
351
  ...(limit && { Limit: limit }),
371
352
  ScanIndexForward: ascending,
372
353
  TableName: tableName,
@@ -377,25 +358,30 @@ async function executeQuery(indexName, keyValue, options = {}) {
377
358
  lastEvaluatedKey: response.LastEvaluatedKey,
378
359
  };
379
360
  }
361
+ function scopePrefix(scope) {
362
+ return scope === undefined ? undefined : `${scope}${fabric.SEPARATOR}`;
363
+ }
364
+ // =============================================================================
365
+ // Query Functions
366
+ // =============================================================================
380
367
  /**
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.
368
+ * List entities of a model, optionally narrowed to a scope.
369
+ * Requires the model to register `fabricIndex()` (pk=[model]).
386
370
  */
387
371
  async function queryByScope({ archived = false, ascending = false, deleted = false, limit, model, scope, startKey, }) {
372
+ const index = requireIndex(model, ["model"]);
388
373
  const suffix = calculateSuffix({ archived, deleted });
389
- const keyValue = buildIndexScope(scope, model) + suffix;
390
- return executeQuery(INDEX_SCOPE, keyValue, {
374
+ const pkValue = buildCompositeKey({ model }, ["model"], suffix);
375
+ return executeQuery(index, pkValue, {
391
376
  ascending,
392
377
  limit,
378
+ skPrefix: scopePrefix(scope),
393
379
  startKey,
394
380
  });
395
381
  }
396
382
  /**
397
- * Query a single entity by human-friendly alias
398
- * Uses indexAlias GSI
383
+ * Query a single entity by human-friendly alias.
384
+ * Requires the model to register `fabricIndex("alias")`.
399
385
  */
400
386
  const queryByAlias = fabric.fabricService({
401
387
  alias: "queryByAlias",
@@ -415,7 +401,11 @@ const queryByAlias = fabric.fabricService({
415
401
  description: "Query deleted entities instead of active ones",
416
402
  },
417
403
  model: { type: String, description: "Entity model name" },
418
- scope: { type: String, description: "Scope (@ for root)" },
404
+ scope: {
405
+ type: String,
406
+ required: false,
407
+ description: "Optional scope narrower (begins_with on sk)",
408
+ },
419
409
  },
420
410
  service: async ({ alias, archived, deleted, model, scope, }) => {
421
411
  const aliasStr = alias;
@@ -423,52 +413,52 @@ const queryByAlias = fabric.fabricService({
423
413
  const deletedBool = deleted;
424
414
  const modelStr = model;
425
415
  const scopeStr = scope;
416
+ const index = requireIndex(modelStr, ["model", "alias"]);
426
417
  const suffix = calculateSuffix({
427
418
  archived: archivedBool,
428
419
  deleted: deletedBool,
429
420
  });
430
- const keyValue = buildIndexAlias(scopeStr, modelStr, aliasStr) + suffix;
431
- const result = await executeQuery(INDEX_ALIAS, keyValue, {
421
+ const pkValue = buildCompositeKey({ model: modelStr, alias: aliasStr }, ["model", "alias"], suffix);
422
+ const result = await executeQuery(index, pkValue, {
432
423
  limit: 1,
424
+ skPrefix: scopePrefix(scopeStr),
433
425
  });
434
426
  return result.items[0] ?? null;
435
427
  },
436
428
  });
437
429
  /**
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.
430
+ * Query entities by category classification.
431
+ * Requires the model to register `fabricIndex("category")`.
443
432
  */
444
433
  async function queryByCategory({ archived = false, ascending = false, category, deleted = false, limit, model, scope, startKey, }) {
434
+ const index = requireIndex(model, ["model", "category"]);
445
435
  const suffix = calculateSuffix({ archived, deleted });
446
- const keyValue = buildIndexCategory(scope, model, category) + suffix;
447
- return executeQuery(INDEX_CATEGORY, keyValue, {
436
+ const pkValue = buildCompositeKey({ model, category }, ["model", "category"], suffix);
437
+ return executeQuery(index, pkValue, {
448
438
  ascending,
449
439
  limit,
440
+ skPrefix: scopePrefix(scope),
450
441
  startKey,
451
442
  });
452
443
  }
453
444
  /**
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.
445
+ * Query entities by type classification.
446
+ * Requires the model to register `fabricIndex("type")`.
459
447
  */
460
448
  async function queryByType({ archived = false, ascending = false, deleted = false, limit, model, scope, startKey, type, }) {
449
+ const index = requireIndex(model, ["model", "type"]);
461
450
  const suffix = calculateSuffix({ archived, deleted });
462
- const keyValue = buildIndexType(scope, model, type) + suffix;
463
- return executeQuery(INDEX_TYPE, keyValue, {
451
+ const pkValue = buildCompositeKey({ model, type }, ["model", "type"], suffix);
452
+ return executeQuery(index, pkValue, {
464
453
  ascending,
465
454
  limit,
455
+ skPrefix: scopePrefix(scope),
466
456
  startKey,
467
457
  });
468
458
  }
469
459
  /**
470
- * Query a single entity by external ID
471
- * Uses indexXid GSI
460
+ * Query a single entity by external ID.
461
+ * Requires the model to register `fabricIndex("xid")`.
472
462
  */
473
463
  const queryByXid = fabric.fabricService({
474
464
  alias: "queryByXid",
@@ -487,7 +477,11 @@ const queryByXid = fabric.fabricService({
487
477
  description: "Query deleted entities instead of active ones",
488
478
  },
489
479
  model: { type: String, description: "Entity model name" },
490
- scope: { type: String, description: "Scope (@ for root)" },
480
+ scope: {
481
+ type: String,
482
+ required: false,
483
+ description: "Optional scope narrower (begins_with on sk)",
484
+ },
491
485
  xid: { type: String, description: "External ID" },
492
486
  },
493
487
  service: async ({ archived, deleted, model, scope, xid, }) => {
@@ -496,13 +490,15 @@ const queryByXid = fabric.fabricService({
496
490
  const modelStr = model;
497
491
  const scopeStr = scope;
498
492
  const xidStr = xid;
493
+ const index = requireIndex(modelStr, ["model", "xid"]);
499
494
  const suffix = calculateSuffix({
500
495
  archived: archivedBool,
501
496
  deleted: deletedBool,
502
497
  });
503
- const keyValue = buildIndexXid(scopeStr, modelStr, xidStr) + suffix;
504
- const result = await executeQuery(INDEX_XID, keyValue, {
498
+ const pkValue = buildCompositeKey({ model: modelStr, xid: xidStr }, ["model", "xid"], suffix);
499
+ const result = await executeQuery(index, pkValue, {
505
500
  limit: 1,
501
+ skPrefix: scopePrefix(scopeStr),
506
502
  });
507
503
  return result.items[0] ?? null;
508
504
  },
@@ -512,53 +508,26 @@ const DEFAULT_ENDPOINT = "http://127.0.0.1:8000";
512
508
  const DEFAULT_REGION$1 = "us-east-1";
513
509
  const DEFAULT_TABLE_NAME$1 = "jaypie-local";
514
510
  // =============================================================================
515
- // Index to GSI Conversion
511
+ // Index GSI Conversion
516
512
  // =============================================================================
517
513
  /**
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
514
+ * Build attribute definitions from registered indexes.
515
+ * Primary key is `id` (STRING) only; GSI pk and composite sk attributes
516
+ * are all STRING. A single-field sk (e.g., raw `updatedAt`) is also STRING.
541
517
  */
542
518
  function buildAttributeDefinitions(indexes) {
543
519
  const attrs = new Map();
544
- // Primary key attributes
545
- attrs.set("model", "S");
520
+ // Primary key: id only
546
521
  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
522
  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
- }
523
+ const { pk, sk } = fabric.getGsiAttributeNames(index);
524
+ // All pk attributes are composite strings
525
+ if (!attrs.has(pk))
526
+ attrs.set(pk, "S");
527
+ if (sk && !attrs.has(sk)) {
528
+ // Single-field `sequence` remains NUMBER for back-compat callers;
529
+ // every other sk attribute (composite or not) is STRING.
530
+ attrs.set(sk, sk === "sequence" ? "N" : "S");
562
531
  }
563
532
  }
564
533
  return Array.from(attrs.entries())
@@ -569,22 +538,19 @@ function buildAttributeDefinitions(indexes) {
569
538
  }));
570
539
  }
571
540
  /**
572
- * Build GSI definitions from indexes
541
+ * Build GSI definitions from registered indexes
573
542
  */
574
543
  function buildGSIs(indexes) {
575
544
  const gsiProjection = { ProjectionType: "ALL" };
576
545
  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")
546
+ const { pk, sk } = fabric.getGsiAttributeNames(index);
547
+ const keySchema = [{ AttributeName: pk, KeyType: "HASH" }];
548
+ if (sk) {
549
+ keySchema.push({ AttributeName: sk, KeyType: "RANGE" });
550
+ }
581
551
  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
- ],
552
+ IndexName: pk,
553
+ KeySchema: keySchema,
588
554
  Projection: gsiProjection,
589
555
  };
590
556
  });
@@ -593,20 +559,17 @@ function buildGSIs(indexes) {
593
559
  // Table Creation
594
560
  // =============================================================================
595
561
  /**
596
- * DynamoDB table schema with Jaypie GSI pattern
597
- *
598
- * Collects indexes from models registered via registerModel()
562
+ * DynamoDB table schema with Jaypie GSI pattern.
563
+ * Primary key is `id` only. GSIs come from models registered via
564
+ * `registerModel()`, shaped by `fabricIndex()`.
599
565
  */
600
566
  function createTableParams(tableName, billingMode) {
601
- const allIndexes = collectAllIndexes();
567
+ const allIndexes = fabric.getAllRegisteredIndexes();
602
568
  return {
603
569
  AttributeDefinitions: buildAttributeDefinitions(allIndexes),
604
570
  BillingMode: billingMode,
605
571
  GlobalSecondaryIndexes: buildGSIs(allIndexes),
606
- KeySchema: [
607
- { AttributeName: "model", KeyType: "HASH" },
608
- { AttributeName: "id", KeyType: "RANGE" },
609
- ],
572
+ KeySchema: [{ AttributeName: "id", KeyType: "HASH" }],
610
573
  TableName: tableName,
611
574
  };
612
575
  }
@@ -646,7 +609,6 @@ const createTableHandler = fabric.fabricService({
646
609
  region: DEFAULT_REGION$1,
647
610
  });
648
611
  try {
649
- // Check if table already exists
650
612
  await client.send(new clientDynamodb.DescribeTableCommand({ TableName: tableNameStr }));
651
613
  return {
652
614
  message: `Table "${tableNameStr}" already exists`,
@@ -659,7 +621,6 @@ const createTableHandler = fabric.fabricService({
659
621
  throw error;
660
622
  }
661
623
  }
662
- // Create the table
663
624
  const tableParams = createTableParams(tableNameStr, billingModeStr);
664
625
  await client.send(new clientDynamodb.CreateTableCommand(tableParams));
665
626
  return {
@@ -809,12 +770,12 @@ function wrapWithInit(handler) {
809
770
  // MCP-specific serviceHandler wrappers for functions with complex inputs
810
771
  // Note: These wrap the regular async functions to make them work with fabricMcp
811
772
  /**
812
- * MCP wrapper for putEntity
773
+ * MCP wrapper for createEntity
813
774
  * Accepts entity JSON directly from LLM
814
775
  */
815
- const mcpPutEntity = fabric.fabricService({
816
- alias: "dynamodb_put",
817
- description: "Create or replace an entity in DynamoDB (auto-indexes GSI keys)",
776
+ const mcpCreateEntity = fabric.fabricService({
777
+ alias: "dynamodb_create",
778
+ description: "Create an entity in DynamoDB (auto-indexes GSI keys; returns null if id exists)",
818
779
  input: {
819
780
  // Required entity fields
820
781
  id: { type: String, description: "Entity ID (sort key)" },
@@ -850,7 +811,7 @@ const mcpPutEntity = fabric.fabricService({
850
811
  updatedAt: now,
851
812
  xid: input.xid,
852
813
  };
853
- return putEntity({ entity });
814
+ return createEntity({ entity });
854
815
  },
855
816
  });
856
817
  /**
@@ -1057,11 +1018,11 @@ function registerDynamoDbTools(config) {
1057
1018
  });
1058
1019
  tools.push("dynamodb_get");
1059
1020
  mcp.fabricMcp({
1060
- service: wrapWithInit(mcpPutEntity),
1061
- name: "dynamodb_put",
1021
+ service: wrapWithInit(mcpCreateEntity),
1022
+ name: "dynamodb_create",
1062
1023
  server,
1063
1024
  });
1064
- tools.push("dynamodb_put");
1025
+ tools.push("dynamodb_create");
1065
1026
  mcp.fabricMcp({
1066
1027
  service: wrapWithInit(mcpUpdateEntity),
1067
1028
  name: "dynamodb_update",