@ftschopp/dynatable-core 1.2.5 → 1.3.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.
Files changed (30) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/builders/query/create-query-builder.d.ts +1 -1
  3. package/dist/builders/query/create-query-builder.d.ts.map +1 -1
  4. package/dist/builders/query/create-query-builder.js +35 -10
  5. package/dist/builders/shared/operators.d.ts.map +1 -1
  6. package/dist/builders/shared/operators.js +8 -0
  7. package/dist/builders/shared/types.d.ts +1 -0
  8. package/dist/builders/shared/types.d.ts.map +1 -1
  9. package/dist/builders/update/create-update-builder.d.ts +8 -2
  10. package/dist/builders/update/create-update-builder.d.ts.map +1 -1
  11. package/dist/builders/update/create-update-builder.js +45 -16
  12. package/dist/builders/update/types.d.ts +13 -0
  13. package/dist/builders/update/types.d.ts.map +1 -1
  14. package/dist/entity/create-entity-api.d.ts.map +1 -1
  15. package/dist/entity/create-entity-api.js +8 -4
  16. package/dist/utils/model-utils.d.ts +28 -0
  17. package/dist/utils/model-utils.d.ts.map +1 -1
  18. package/dist/utils/model-utils.js +34 -1
  19. package/package.json +1 -1
  20. package/src/builders/query/create-query-builder.test.ts +204 -0
  21. package/src/builders/query/create-query-builder.ts +51 -10
  22. package/src/builders/shared/operators.ts +8 -0
  23. package/src/builders/shared/types.ts +1 -0
  24. package/src/builders/update/create-update-builder.test.ts +197 -0
  25. package/src/builders/update/create-update-builder.ts +72 -19
  26. package/src/builders/update/types.ts +14 -0
  27. package/src/entity/create-entity-api.ts +8 -4
  28. package/src/utils/model-utils.test.ts +91 -1
  29. package/src/utils/model-utils.ts +54 -0
  30. package/tests/integration/instagram-clone.integration.test.ts +23 -3
@@ -298,3 +298,207 @@ describe('QueryBuilder - GSI key recognition', () => {
298
298
  expect(params.FilterExpression).toBeUndefined();
299
299
  });
300
300
  });
301
+
302
+ describe('QueryBuilder - multi-variable key templates', () => {
303
+ const client = new DynamoDBClient({});
304
+ const tableName = 'TestTable';
305
+
306
+ interface AirportResource {
307
+ id: string;
308
+ airport: string;
309
+ category: string;
310
+ code: string;
311
+ status: string;
312
+ }
313
+
314
+ const airportResourceModel: ModelDefinition = {
315
+ key: {
316
+ PK: { type: String, value: 'AIRPORT_RESOURCE#${id}' },
317
+ SK: { type: String, value: 'AIRPORT_RESOURCE#${id}' },
318
+ },
319
+ index: {
320
+ GSI1PK: { type: String, value: 'AIRPORT#${airport}' },
321
+ GSI1SK: { type: String, value: 'RES#${category}#${code}' },
322
+ },
323
+ attributes: {
324
+ id: { type: String, required: true },
325
+ airport: { type: String, required: true },
326
+ category: { type: String, required: true },
327
+ code: { type: String, required: true },
328
+ status: { type: String, required: true },
329
+ },
330
+ };
331
+
332
+ test('beginsWith on attribute that is part of multi-var template should truncate at the first unfilled variable', () => {
333
+ const params = createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
334
+ .where((attr, op) =>
335
+ op.and(op.eq(attr.airport, 'EZE'), op.beginsWith(attr.category, 'GPU'))
336
+ )
337
+ .useIndex('GSI1')
338
+ .dbParams();
339
+
340
+ expect(params.KeyConditionExpression).toBeDefined();
341
+ expect(params.KeyConditionExpression).toContain('#GSI1PK');
342
+ expect(params.KeyConditionExpression).toContain('begins_with(#GSI1SK');
343
+ expect(params.IndexName).toBe('GSI1');
344
+
345
+ const values = params.ExpressionAttributeValues ?? {};
346
+ expect(Object.values(values)).toContain('AIRPORT#EZE');
347
+ // Should be the prefix up to the next unfilled var, NOT 'RES#GPU#${code}'
348
+ expect(Object.values(values)).toContain('RES#GPU#');
349
+ expect(Object.values(values).every((v) => !String(v).includes('${'))).toBe(true);
350
+ });
351
+
352
+ test('eq on attribute with multi-var template should throw a clear error', () => {
353
+ expect(() =>
354
+ createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
355
+ .where((attr, op) => op.eq(attr.category, 'GPU'))
356
+ .useIndex('GSI1')
357
+ .dbParams()
358
+ ).toThrow(/template/i);
359
+ });
360
+
361
+ test('beginsWith on a single-var template still works (no truncation needed)', () => {
362
+ const params = createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
363
+ .where((attr, op) => op.beginsWith(attr.airport, 'EZ'))
364
+ .useIndex('GSI1')
365
+ .dbParams();
366
+
367
+ const values = params.ExpressionAttributeValues ?? {};
368
+ expect(Object.values(values)).toContain('AIRPORT#EZ');
369
+ });
370
+ });
371
+
372
+ describe('QueryBuilder - entity type auto filter', () => {
373
+ const client = new DynamoDBClient({});
374
+ const tableName = 'TestTable';
375
+
376
+ interface AirportPersonnel {
377
+ id: string;
378
+ airport: string;
379
+ role: string;
380
+ }
381
+
382
+ // Mirrors the reported scenario: PK is entity-specific, GSI1 is shared
383
+ // across multiple entities (Airport, AirportPersonnel, etc.)
384
+ const airportPersonnelModel: ModelDefinition = {
385
+ key: {
386
+ PK: { type: String, value: 'AIRPORT_PERSONNEL#${id}' },
387
+ SK: { type: String, value: 'AIRPORT_PERSONNEL#${id}' },
388
+ },
389
+ index: {
390
+ GSI1PK: { type: String, value: 'AIRPORT#${airport}' },
391
+ GSI1SK: { type: String, value: 'PERSONNEL#${role}' },
392
+ },
393
+ attributes: {
394
+ id: { type: String, required: true },
395
+ airport: { type: String, required: true },
396
+ role: { type: String, required: true },
397
+ },
398
+ };
399
+
400
+ test('does NOT add _type filter when entityType is not provided (back-compat)', () => {
401
+ const params = createQueryBuilder<AirportPersonnel>(
402
+ tableName,
403
+ client,
404
+ airportPersonnelModel
405
+ )
406
+ .where((attr, op) => op.eq(attr.airport, 'EZE'))
407
+ .useIndex('GSI1')
408
+ .dbParams();
409
+
410
+ expect(params.FilterExpression).toBeUndefined();
411
+ const names = params.ExpressionAttributeNames ?? {};
412
+ expect(names['#_type']).toBeUndefined();
413
+ });
414
+
415
+ test('adds _type filter when entityType is provided and there are no other filters', () => {
416
+ const params = createQueryBuilder<AirportPersonnel>(
417
+ tableName,
418
+ client,
419
+ airportPersonnelModel,
420
+ undefined,
421
+ 'AirportPersonnel'
422
+ )
423
+ .where((attr, op) => op.eq(attr.airport, 'EZE'))
424
+ .useIndex('GSI1')
425
+ .dbParams();
426
+
427
+ // Key cond goes to KeyConditionExpression, _type goes to FilterExpression
428
+ expect(params.KeyConditionExpression).toBeDefined();
429
+ expect(params.KeyConditionExpression).toContain('#GSI1PK');
430
+ expect(params.FilterExpression).toBe('#_type = :_type');
431
+ expect(params.ExpressionAttributeNames?.['#_type']).toBe('_type');
432
+ expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
433
+ });
434
+
435
+ test('combines _type filter with a user-provided non-key filter via AND', () => {
436
+ const params = createQueryBuilder<AirportPersonnel & { status?: string }>(
437
+ tableName,
438
+ client,
439
+ airportPersonnelModel,
440
+ undefined,
441
+ 'AirportPersonnel'
442
+ )
443
+ .where((attr, op) => op.and(op.eq(attr.airport, 'EZE'), op.eq(attr.status, 'active')))
444
+ .useIndex('GSI1')
445
+ .dbParams();
446
+
447
+ expect(params.KeyConditionExpression).toContain('#GSI1PK');
448
+ expect(params.FilterExpression).toBeDefined();
449
+ expect(params.FilterExpression).toContain('#status');
450
+ expect(params.FilterExpression).toContain('#_type = :_type');
451
+ expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
452
+ });
453
+
454
+ test('repro of reported bug: GSI1 query with conditional beginsWith still filters by _type', () => {
455
+ // The exact shape from the user report:
456
+ // .where((attr, op) =>
457
+ // role
458
+ // ? op.and(op.eq(attr.airport, airport), op.beginsWith(attr.role, role))
459
+ // : op.eq(attr.airport, airport)
460
+ // )
461
+ const airport = 'EZE';
462
+ const role = 'PILOT';
463
+
464
+ const params = createQueryBuilder<AirportPersonnel>(
465
+ tableName,
466
+ client,
467
+ airportPersonnelModel,
468
+ undefined,
469
+ 'AirportPersonnel'
470
+ )
471
+ .where((attr, op) =>
472
+ role
473
+ ? op.and(op.eq(attr.airport, airport), op.beginsWith(attr.role, role))
474
+ : op.eq(attr.airport, airport)
475
+ )
476
+ .useIndex('GSI1')
477
+ .dbParams();
478
+
479
+ // Both airport (GSI1PK) and role (GSI1SK) become key conditions
480
+ expect(params.KeyConditionExpression).toContain('#GSI1PK');
481
+ expect(params.KeyConditionExpression).toContain('begins_with(#GSI1SK');
482
+ // _type still gets enforced as a filter — this is the fix
483
+ expect(params.FilterExpression).toBe('#_type = :_type');
484
+ expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
485
+ });
486
+
487
+ test('does not collide with user attribute placeholders', () => {
488
+ // Confirm the auto-injected names/values don't clash with user-supplied ones
489
+ const params = createQueryBuilder<AirportPersonnel & { status?: string }>(
490
+ tableName,
491
+ client,
492
+ airportPersonnelModel,
493
+ undefined,
494
+ 'AirportPersonnel'
495
+ )
496
+ .where((attr, op) => op.and(op.eq(attr.airport, 'EZE'), op.eq(attr.status, 'active')))
497
+ .useIndex('GSI1')
498
+ .dbParams();
499
+
500
+ expect(params.ExpressionAttributeNames?.['#_type']).toBe('_type');
501
+ expect(params.ExpressionAttributeNames?.['#status']).toBe('status');
502
+ expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
503
+ });
504
+ });
@@ -24,6 +24,7 @@ type QueryState<Model> = {
24
24
  consistentReadEnabled?: boolean;
25
25
  exclusiveStartKey?: Record<string, any>;
26
26
  logger?: DynamoDBLogger;
27
+ entityType?: string;
27
28
  };
28
29
 
29
30
  /**
@@ -60,11 +61,32 @@ function getKeyNameForAttribute(
60
61
  }
61
62
 
62
63
  /**
63
- * Apply key template to transform attribute value to key value
64
- * e.g., applyKeyTemplate("UP#${username}", "username", "johndoe") -> "UP#johndoe"
64
+ * Apply key template to transform an attribute value to its key value.
65
+ *
66
+ * For multi-variable templates (e.g. "RES#${category}#${code}") we can only fill
67
+ * the variable matching `fieldName`. For `beginsWith` queries we truncate the
68
+ * result at the first remaining `${...}` placeholder so the prefix is usable.
69
+ * For other operators an exact match is required, so we throw a clear error.
65
70
  */
66
- function applyKeyTemplate(template: string, fieldName: string, fieldValue: any): string {
67
- return template.replace(`\${${fieldName}}`, fieldValue);
71
+ function applyKeyTemplate(
72
+ template: string,
73
+ fieldName: string,
74
+ fieldValue: any,
75
+ keyOperator?: string
76
+ ): string {
77
+ const substituted = template.replace(`\${${fieldName}}`, String(fieldValue));
78
+ const nextPlaceholder = substituted.indexOf('${');
79
+ if (nextPlaceholder === -1) return substituted;
80
+
81
+ if (keyOperator === 'beginsWith') {
82
+ return substituted.slice(0, nextPlaceholder);
83
+ }
84
+
85
+ throw new Error(
86
+ `Cannot use operator "${keyOperator ?? 'unknown'}" on key template "${template}" ` +
87
+ `with only "${fieldName}" provided — the template still contains unfilled ` +
88
+ `variable(s). Use beginsWith for prefix queries on composite keys.`
89
+ );
68
90
  }
69
91
 
70
92
  /**
@@ -116,7 +138,12 @@ function separateConditions(
116
138
  if (cond.values) {
117
139
  for (const [valuePlaceholder, value] of Object.entries(cond.values)) {
118
140
  // Apply the key template to transform the value
119
- const transformedValue = applyKeyTemplate(keyInfo.template, fieldName, value);
141
+ const transformedValue = applyKeyTemplate(
142
+ keyInfo.template,
143
+ fieldName,
144
+ value,
145
+ cond.keyOperator
146
+ );
120
147
  newValues[valuePlaceholder] = transformedValue;
121
148
  }
122
149
  }
@@ -239,17 +266,29 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
239
266
  // Build KeyConditionExpression (simple AND)
240
267
  const keyExpr = buildSimpleAndExpression(keyConditions);
241
268
 
269
+ // Auto-inject an entity-type filter so query() only returns items
270
+ // belonging to this entity (single-table designs share PKs across
271
+ // entities, especially on GSIs).
272
+ const allFilters = [...filterConditions];
273
+ if (state.entityType) {
274
+ allFilters.push({
275
+ expression: '#_type = :_type',
276
+ names: { '#_type': '_type' },
277
+ values: { ':_type': state.entityType },
278
+ });
279
+ }
280
+
242
281
  // Build FilterExpression (can be complex with OR, NOT, etc.)
243
282
  let filterExpr = { expression: '', names: {}, values: {} };
244
- if (filterConditions.length > 0) {
245
- if (filterConditions.length === 1 && filterConditions[0]) {
246
- filterExpr = buildExpression(filterConditions[0]);
283
+ if (allFilters.length > 0) {
284
+ if (allFilters.length === 1 && allFilters[0]) {
285
+ filterExpr = buildExpression(allFilters[0]);
247
286
  } else {
248
287
  // Multiple filter conditions - combine with AND
249
288
  filterExpr = buildExpression({
250
289
  expression: '',
251
290
  operator: 'AND',
252
- children: filterConditions,
291
+ children: allFilters,
253
292
  });
254
293
  }
255
294
  }
@@ -322,7 +361,8 @@ export function createQueryBuilder<Model>(
322
361
  tableName: string,
323
362
  client: DynamoDBClient,
324
363
  model?: ModelDefinition,
325
- logger?: DynamoDBLogger
364
+ logger?: DynamoDBLogger,
365
+ entityType?: string
326
366
  ): QueryBuilder<Model> {
327
367
  return {
328
368
  where(fn) {
@@ -346,6 +386,7 @@ export function createQueryBuilder<Model>(
346
386
  model,
347
387
  condition,
348
388
  logger,
389
+ entityType,
349
390
  };
350
391
 
351
392
  return createQueryExecutor(initialState);
@@ -21,6 +21,7 @@ export function createOpBuilder(): OpBuilder {
21
21
  expression: `#${attr.name} = :${valueName}`,
22
22
  names: { [`#${attr.name}`]: attr.name },
23
23
  values: { [`:${valueName}`]: value },
24
+ keyOperator: 'eq',
24
25
  };
25
26
  },
26
27
  ne: (attr, value) => {
@@ -29,6 +30,7 @@ export function createOpBuilder(): OpBuilder {
29
30
  expression: `#${attr.name} <> :${valueName}`,
30
31
  names: { [`#${attr.name}`]: attr.name },
31
32
  values: { [`:${valueName}`]: value },
33
+ keyOperator: 'ne',
32
34
  };
33
35
  },
34
36
  lt: (attr, value) => {
@@ -37,6 +39,7 @@ export function createOpBuilder(): OpBuilder {
37
39
  expression: `#${attr.name} < :${valueName}`,
38
40
  names: { [`#${attr.name}`]: attr.name },
39
41
  values: { [`:${valueName}`]: value },
42
+ keyOperator: 'lt',
40
43
  };
41
44
  },
42
45
  lte: (attr, value) => {
@@ -45,6 +48,7 @@ export function createOpBuilder(): OpBuilder {
45
48
  expression: `#${attr.name} <= :${valueName}`,
46
49
  names: { [`#${attr.name}`]: attr.name },
47
50
  values: { [`:${valueName}`]: value },
51
+ keyOperator: 'lte',
48
52
  };
49
53
  },
50
54
  gt: (attr, value) => {
@@ -53,6 +57,7 @@ export function createOpBuilder(): OpBuilder {
53
57
  expression: `#${attr.name} > :${valueName}`,
54
58
  names: { [`#${attr.name}`]: attr.name },
55
59
  values: { [`:${valueName}`]: value },
60
+ keyOperator: 'gt',
56
61
  };
57
62
  },
58
63
  gte: (attr, value) => {
@@ -61,6 +66,7 @@ export function createOpBuilder(): OpBuilder {
61
66
  expression: `#${attr.name} >= :${valueName}`,
62
67
  names: { [`#${attr.name}`]: attr.name },
63
68
  values: { [`:${valueName}`]: value },
69
+ keyOperator: 'gte',
64
70
  };
65
71
  },
66
72
  between: (attr, low, high) => {
@@ -73,6 +79,7 @@ export function createOpBuilder(): OpBuilder {
73
79
  [`:${lowName}`]: low,
74
80
  [`:${highName}`]: high,
75
81
  },
82
+ keyOperator: 'between',
76
83
  };
77
84
  },
78
85
  beginsWith: (attr, value) => {
@@ -81,6 +88,7 @@ export function createOpBuilder(): OpBuilder {
81
88
  expression: `begins_with(#${attr.name}, :${valueName})`,
82
89
  names: { [`#${attr.name}`]: attr.name },
83
90
  values: { [`:${valueName}`]: value },
91
+ keyOperator: 'beginsWith',
84
92
  };
85
93
  },
86
94
  contains: (attr, value) => {
@@ -11,6 +11,7 @@ export type Condition = {
11
11
  names?: Record<string, string>;
12
12
  values?: Record<string, any>;
13
13
  operator?: 'AND' | 'OR';
14
+ keyOperator?: 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'between' | 'beginsWith';
14
15
  isNegated?: boolean;
15
16
  children?: Condition[];
16
17
  };
@@ -76,6 +76,30 @@ describe('UpdateBuilder', () => {
76
76
  });
77
77
  });
78
78
 
79
+ test('should build params with single-property object whose key is "name"', () => {
80
+ const key: Partial<TestModel> = { pk: 'USER#1', sk: 'USER#1' };
81
+ const params = createUpdateBuilder<TestModel>(tableName, key, client)
82
+ .set({ name: 'Ministro Pistarini' })
83
+ .dbParams();
84
+
85
+ expect(params.UpdateExpression).toBe('SET #name = :name_0');
86
+ expect(params.ExpressionAttributeNames).toEqual({ '#name': 'name' });
87
+ expect(params.ExpressionAttributeValues).toEqual({
88
+ ':name_0': 'Ministro Pistarini',
89
+ });
90
+ });
91
+
92
+ test('should build params with single-property object whose key is not "name"', () => {
93
+ const key: Partial<TestModel> = { pk: 'USER#1', sk: 'USER#1' };
94
+ const params = createUpdateBuilder<TestModel>(tableName, key, client)
95
+ .set({ age: 30 })
96
+ .dbParams();
97
+
98
+ expect(params.UpdateExpression).toBe('SET #age = :age_0');
99
+ expect(params.ExpressionAttributeNames).toEqual({ '#age': 'age' });
100
+ expect(params.ExpressionAttributeValues).toEqual({ ':age_0': 30 });
101
+ });
102
+
79
103
  test('should combine single and multiple SET operations', () => {
80
104
  const key: Partial<TestModel> = { pk: 'USER#1', sk: 'USER#1' };
81
105
  const params = createUpdateBuilder<TestModel>(tableName, key, client)
@@ -344,6 +368,179 @@ describe('UpdateBuilder', () => {
344
368
  });
345
369
  });
346
370
 
371
+ describe('Secondary-index recomputation (indexContext)', () => {
372
+ interface PersonnelModel {
373
+ id: string;
374
+ airportId: string;
375
+ firstName: string;
376
+ lastName: string;
377
+ role: string;
378
+ }
379
+
380
+ const personnelModel = {
381
+ key: {
382
+ PK: { type: String, value: 'PERSON#${id}' },
383
+ SK: { type: String, value: 'PROFILE' },
384
+ },
385
+ index: {
386
+ GSI1PK: { type: String, value: 'AIRPORT#${airportId}' },
387
+ GSI1SK: { type: String, value: 'PERSON#${lastName}#${firstName}' },
388
+ },
389
+ attributes: {
390
+ id: { type: String, required: true },
391
+ airportId: { type: String, required: true },
392
+ firstName: { type: String, required: true },
393
+ lastName: { type: String, required: true },
394
+ role: { type: String },
395
+ },
396
+ } as const;
397
+
398
+ test('does nothing when no .set() field appears in any index template', () => {
399
+ const params = createUpdateBuilder<PersonnelModel>(
400
+ tableName,
401
+ { id: '1' } as Partial<PersonnelModel>,
402
+ client,
403
+ [],
404
+ { set: [], remove: [], add: [], delete: [] },
405
+ 'NONE',
406
+ 0,
407
+ false,
408
+ undefined,
409
+ { model: personnelModel as any, keyVars: { id: '1' } }
410
+ )
411
+ .set('role', 'pilot')
412
+ .dbParams();
413
+
414
+ expect(params.UpdateExpression).toBe('SET #role = :role_0');
415
+ expect(params.ExpressionAttributeNames).toEqual({ '#role': 'role' });
416
+ });
417
+
418
+ test('recomputes affected index when all template vars are present in updates', () => {
419
+ const params = createUpdateBuilder<PersonnelModel>(
420
+ tableName,
421
+ { id: '1' } as Partial<PersonnelModel>,
422
+ client,
423
+ [],
424
+ { set: [], remove: [], add: [], delete: [] },
425
+ 'NONE',
426
+ 0,
427
+ false,
428
+ undefined,
429
+ { model: personnelModel as any, keyVars: { id: '1' } }
430
+ )
431
+ .set({ firstName: 'Ada', lastName: 'Lovelace' })
432
+ .dbParams();
433
+
434
+ // GSI1SK depends on lastName + firstName — both supplied → recomputed.
435
+ // GSI1PK depends on airportId — untouched, no recompute.
436
+ expect(params.UpdateExpression).toBe(
437
+ 'SET #firstName = :firstName_0, #lastName = :lastName_1, #GSI1SK = :GSI1SK_2'
438
+ );
439
+ expect(params.ExpressionAttributeValues).toMatchObject({
440
+ ':firstName_0': 'Ada',
441
+ ':lastName_1': 'Lovelace',
442
+ ':GSI1SK_2': 'PERSON#Lovelace#Ada',
443
+ });
444
+ });
445
+
446
+ test('throws when an affected index template references a field not in updates or key', () => {
447
+ const builder = createUpdateBuilder<PersonnelModel>(
448
+ tableName,
449
+ { id: '1' } as Partial<PersonnelModel>,
450
+ client,
451
+ [],
452
+ { set: [], remove: [], add: [], delete: [] },
453
+ 'NONE',
454
+ 0,
455
+ false,
456
+ undefined,
457
+ { model: personnelModel as any, keyVars: { id: '1' } }
458
+ ).set({ lastName: 'Lovelace' });
459
+
460
+ // GSI1SK template = "PERSON#${lastName}#${firstName}" — firstName is missing.
461
+ expect(() => builder.dbParams()).toThrow(/firstName/);
462
+ expect(() => builder.dbParams()).toThrow(/GSI1SK/);
463
+ });
464
+
465
+ test('uses primary-key template vars when resolving index templates', () => {
466
+ const userModel = {
467
+ key: {
468
+ PK: { type: String, value: 'USER#${username}' },
469
+ SK: { type: String, value: 'USER#${username}' },
470
+ },
471
+ index: {
472
+ GSI1PK: { type: String, value: 'USER#${username}#STATUS#${status}' },
473
+ },
474
+ attributes: {
475
+ username: { type: String, required: true },
476
+ status: { type: String, required: true },
477
+ },
478
+ } as const;
479
+
480
+ const params = createUpdateBuilder<{ username: string; status: string }>(
481
+ tableName,
482
+ { username: 'jane' } as any,
483
+ client,
484
+ [],
485
+ { set: [], remove: [], add: [], delete: [] },
486
+ 'NONE',
487
+ 0,
488
+ false,
489
+ undefined,
490
+ { model: userModel as any, keyVars: { username: 'jane' } }
491
+ )
492
+ .set('status', 'active')
493
+ .dbParams();
494
+
495
+ expect(params.UpdateExpression).toBe(
496
+ 'SET #status = :status_0, #GSI1PK = :GSI1PK_1'
497
+ );
498
+ expect(params.ExpressionAttributeValues).toMatchObject({
499
+ ':status_0': 'active',
500
+ ':GSI1PK_1': 'USER#jane#STATUS#active',
501
+ });
502
+ });
503
+
504
+ test('accumulates set inputs across chained calls before resolving', () => {
505
+ const params = createUpdateBuilder<PersonnelModel>(
506
+ tableName,
507
+ { id: '1' } as Partial<PersonnelModel>,
508
+ client,
509
+ [],
510
+ { set: [], remove: [], add: [], delete: [] },
511
+ 'NONE',
512
+ 0,
513
+ false,
514
+ undefined,
515
+ { model: personnelModel as any, keyVars: { id: '1' } }
516
+ )
517
+ .set('lastName', 'Lovelace')
518
+ .set('firstName', 'Ada')
519
+ .dbParams();
520
+
521
+ // Each individual .set() was missing one var, but the accumulator has both
522
+ // by the time dbParams() runs.
523
+ expect(params.UpdateExpression).toBe(
524
+ 'SET #lastName = :lastName_0, #firstName = :firstName_1, #GSI1SK = :GSI1SK_2'
525
+ );
526
+ expect(params.ExpressionAttributeValues).toMatchObject({
527
+ ':GSI1SK_2': 'PERSON#Lovelace#Ada',
528
+ });
529
+ });
530
+
531
+ test('does nothing when indexContext is omitted (backwards-compatible default)', () => {
532
+ const params = createUpdateBuilder<PersonnelModel>(
533
+ tableName,
534
+ { id: '1' } as Partial<PersonnelModel>,
535
+ client
536
+ )
537
+ .set({ lastName: 'Lovelace' })
538
+ .dbParams();
539
+
540
+ expect(params.UpdateExpression).toBe('SET #lastName = :lastName_0');
541
+ });
542
+ });
543
+
347
544
  describe('Complex scenarios', () => {
348
545
  test('should handle complex update with all features', () => {
349
546
  const key: Partial<TestModel> = { pk: 'USER#1', sk: 'USER#1' };