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