@ftschopp/dynatable-core 1.2.4 → 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 (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -3
  3. package/dist/builders/batch-get/create-batch-get-builder.d.ts.map +1 -1
  4. package/dist/builders/batch-get/create-batch-get-builder.js +4 -8
  5. package/dist/builders/batch-get/types.d.ts +2 -3
  6. package/dist/builders/batch-get/types.d.ts.map +1 -1
  7. package/dist/builders/query/create-query-builder.d.ts +1 -1
  8. package/dist/builders/query/create-query-builder.d.ts.map +1 -1
  9. package/dist/builders/query/create-query-builder.js +35 -10
  10. package/dist/builders/shared/operators.d.ts.map +1 -1
  11. package/dist/builders/shared/operators.js +8 -0
  12. package/dist/builders/shared/types.d.ts +1 -0
  13. package/dist/builders/shared/types.d.ts.map +1 -1
  14. package/dist/builders/update/create-update-builder.d.ts +8 -2
  15. package/dist/builders/update/create-update-builder.d.ts.map +1 -1
  16. package/dist/builders/update/create-update-builder.js +45 -16
  17. package/dist/builders/update/types.d.ts +13 -0
  18. package/dist/builders/update/types.d.ts.map +1 -1
  19. package/dist/core/types.d.ts +0 -1
  20. package/dist/core/types.d.ts.map +1 -1
  21. package/dist/entity/create-entity-api.d.ts.map +1 -1
  22. package/dist/entity/create-entity-api.js +8 -4
  23. package/dist/utils/model-utils.d.ts +28 -0
  24. package/dist/utils/model-utils.d.ts.map +1 -1
  25. package/dist/utils/model-utils.js +34 -1
  26. package/package.json +1 -1
  27. package/src/builders/batch-get/README.md +9 -64
  28. package/src/builders/batch-get/create-batch-get-builder.ts +5 -10
  29. package/src/builders/batch-get/types.ts +2 -3
  30. package/src/builders/query/create-query-builder.test.ts +204 -0
  31. package/src/builders/query/create-query-builder.ts +51 -10
  32. package/src/builders/shared/operators.ts +8 -0
  33. package/src/builders/shared/types.ts +1 -0
  34. package/src/builders/update/create-update-builder.test.ts +197 -0
  35. package/src/builders/update/create-update-builder.ts +72 -19
  36. package/src/builders/update/types.ts +14 -0
  37. package/src/core/types.test.ts +0 -2
  38. package/src/core/types.ts +0 -1
  39. package/src/entity/create-entity-api.ts +8 -4
  40. package/src/utils/model-utils.test.ts +91 -1
  41. package/src/utils/model-utils.ts +54 -0
  42. package/tests/integration/instagram-clone.integration.test.ts +23 -4
  43. package/tests/integration/pagination-timestamps.integration.test.ts +1 -2
  44. package/tests/integration/transactions.integration.test.ts +0 -1
@@ -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' };
@@ -2,11 +2,18 @@
2
2
  import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
3
3
  import { UpdateCommand } from '@aws-sdk/lib-dynamodb';
4
4
  import { buildExpression, AttrBuilder, Condition, createOpBuilder, AttrRef } from '../shared';
5
- import { UpdateBuilder, UpdateAction } from './types';
5
+ import { UpdateBuilder, UpdateAction, IndexContext } from './types';
6
6
  import { DynamoDBLogger } from '../../utils/dynamodb-logger';
7
+ import { computeIndexUpdates } from '../../utils/model-utils';
7
8
 
8
9
  /**
9
10
  * Creates an UpdateBuilder for an item key and table.
11
+ *
12
+ * When `indexContext` is provided, fields written via `.set()` that participate
13
+ * in any secondary-index template are detected and the affected index keys are
14
+ * recomputed automatically and included in the SET expression. If a template
15
+ * cannot be fully resolved from the primary-key vars plus the updates, building
16
+ * the params throws — the caller must include the missing fields in `.set()`.
10
17
  */
11
18
  export function createUpdateBuilder<Model>(
12
19
  tableName: string,
@@ -22,7 +29,9 @@ export function createUpdateBuilder<Model>(
22
29
  returnMode: 'NONE' | 'ALL_OLD' | 'ALL_NEW' | 'UPDATED_OLD' | 'UPDATED_NEW' = 'NONE',
23
30
  valueCounter = 0,
24
31
  enableTimestamps = false,
25
- logger?: DynamoDBLogger
32
+ logger?: DynamoDBLogger,
33
+ indexContext?: IndexContext,
34
+ setInputs: Record<string, any> = {}
26
35
  ): UpdateBuilder<Model> {
27
36
  const conditions = [...prevConditions];
28
37
 
@@ -55,27 +64,25 @@ export function createUpdateBuilder<Model>(
55
64
  returnMode,
56
65
  valueCounter,
57
66
  enableTimestamps,
58
- logger
67
+ logger,
68
+ indexContext,
69
+ setInputs
59
70
  );
60
71
  },
61
72
 
62
73
  set(attrOrUpdates: keyof Model | AttrRef | Partial<Model>, value?: any) {
63
- // Check if this is a multiple updates object
64
- // An AttrRef has only 'name' property and is a string value
65
- // Multiple updates is an object with no value parameter
74
+ // When no value is provided and the first arg is a plain object,
75
+ // treat it as a Partial<Model>. The AttrRef overload always passes
76
+ // a value, so it's handled by the single-update path below.
66
77
  if (
67
78
  value === undefined &&
68
79
  typeof attrOrUpdates === 'object' &&
69
- attrOrUpdates !== null &&
70
- !(
71
- Object.keys(attrOrUpdates).length === 1 &&
72
- 'name' in attrOrUpdates &&
73
- typeof (attrOrUpdates as any).name === 'string'
74
- )
80
+ attrOrUpdates !== null
75
81
  ) {
76
82
  // Multiple updates case
77
83
  const updates = attrOrUpdates as Partial<Model>;
78
84
  const newActions: UpdateAction[] = [];
85
+ const newSetInputs = { ...setInputs };
79
86
 
80
87
  for (const [attr, val] of Object.entries(updates)) {
81
88
  const attrName = attr;
@@ -85,6 +92,7 @@ export function createUpdateBuilder<Model>(
85
92
  names: { [`#${attrName}`]: attrName },
86
93
  values: { [`:${valueName}`]: val },
87
94
  });
95
+ newSetInputs[attrName] = val;
88
96
  }
89
97
 
90
98
  return createUpdateBuilder(
@@ -96,7 +104,9 @@ export function createUpdateBuilder<Model>(
96
104
  returnMode,
97
105
  valueCounter,
98
106
  enableTimestamps,
99
- logger
107
+ logger,
108
+ indexContext,
109
+ newSetInputs
100
110
  );
101
111
  }
102
112
 
@@ -117,7 +127,9 @@ export function createUpdateBuilder<Model>(
117
127
  returnMode,
118
128
  valueCounter,
119
129
  enableTimestamps,
120
- logger
130
+ logger,
131
+ indexContext,
132
+ { ...setInputs, [attrName]: value }
121
133
  );
122
134
  },
123
135
 
@@ -136,7 +148,9 @@ export function createUpdateBuilder<Model>(
136
148
  returnMode,
137
149
  valueCounter,
138
150
  enableTimestamps,
139
- logger
151
+ logger,
152
+ indexContext,
153
+ setInputs
140
154
  );
141
155
  },
142
156
 
@@ -157,7 +171,9 @@ export function createUpdateBuilder<Model>(
157
171
  returnMode,
158
172
  valueCounter,
159
173
  enableTimestamps,
160
- logger
174
+ logger,
175
+ indexContext,
176
+ setInputs
161
177
  );
162
178
  },
163
179
 
@@ -178,7 +194,9 @@ export function createUpdateBuilder<Model>(
178
194
  returnMode,
179
195
  valueCounter,
180
196
  enableTimestamps,
181
- logger
197
+ logger,
198
+ indexContext,
199
+ setInputs
182
200
  );
183
201
  },
184
202
 
@@ -192,7 +210,9 @@ export function createUpdateBuilder<Model>(
192
210
  mode,
193
211
  valueCounter,
194
212
  enableTimestamps,
195
- logger
213
+ logger,
214
+ indexContext,
215
+ setInputs
196
216
  );
197
217
  },
198
218
 
@@ -203,7 +223,40 @@ export function createUpdateBuilder<Model>(
203
223
  const allValues: Record<string, any> = {};
204
224
 
205
225
  // Clone updateActions to avoid mutation
206
- const actionsToProcess = { ...updateActions };
226
+ const actionsToProcess = { ...updateActions, set: [...updateActions.set] };
227
+
228
+ // Auto-recompute secondary-index keys whose templates depend on any
229
+ // updated field. If a template can't be fully resolved from the primary
230
+ // key vars + the .set() payload, we throw — recomputing from the
231
+ // existing item would require an extra read the builder won't do.
232
+ if (indexContext) {
233
+ const { actions: idxActions, missing } = computeIndexUpdates(
234
+ indexContext.model,
235
+ indexContext.keyVars,
236
+ setInputs
237
+ );
238
+ if (missing.length > 0) {
239
+ const details = missing
240
+ .map(
241
+ (m) =>
242
+ ` - ${m.index} ("${m.template}"): missing ${m.missing.join(', ')}`
243
+ )
244
+ .join('\n');
245
+ throw new Error(
246
+ `Update touches fields that participate in secondary index templates, ` +
247
+ `but the templates cannot be fully resolved from the update payload. ` +
248
+ `Include the missing fields in .set():\n${details}`
249
+ );
250
+ }
251
+ for (const [indexName, resolved] of Object.entries(idxActions)) {
252
+ const valueName = getUniqueValueName(indexName);
253
+ actionsToProcess.set.push({
254
+ expression: `#${indexName} = :${valueName}`,
255
+ names: { [`#${indexName}`]: indexName },
256
+ values: { [`:${valueName}`]: resolved },
257
+ });
258
+ }
259
+ }
207
260
 
208
261
  // Add updatedAt timestamp if enabled
209
262
  if (enableTimestamps) {
@@ -1,4 +1,5 @@
1
1
  import type { UpdateCommandInput } from '@aws-sdk/lib-dynamodb';
2
+ import { ModelDefinition } from '@/core/types';
2
3
  import { OperationBuilder, AttrRef } from '../shared';
3
4
 
4
5
  /**
@@ -10,6 +11,19 @@ export type UpdateAction = {
10
11
  values?: Record<string, any>;
11
12
  };
12
13
 
14
+ /**
15
+ * Context that lets the update builder auto-recompute secondary-index keys
16
+ * when a `.set()` touches a field that participates in their templates.
17
+ *
18
+ * `keyVars` carries the original template-variable values used to build the
19
+ * primary key (e.g. `{ id: '123' }` from `update({ id: '123' })`) so they can
20
+ * be combined with the user's `.set()` payload when resolving index templates.
21
+ */
22
+ export type IndexContext = {
23
+ model: ModelDefinition;
24
+ keyVars: Record<string, any>;
25
+ };
26
+
13
27
  /**
14
28
  * Builder interface for DynamoDB UpdateItem operations
15
29
  */
@@ -287,12 +287,10 @@ describe('Schema Definition Validation', () => {
287
287
  },
288
288
  },
289
289
  params: {
290
- isoDates: true,
291
290
  timestamps: true,
292
291
  },
293
292
  } as const satisfies SchemaDefinition;
294
293
 
295
- expect(schemaWithParams.params?.isoDates).toBe(true);
296
294
  expect(schemaWithParams.params?.timestamps).toBe(true);
297
295
  });
298
296
 
package/src/core/types.ts CHANGED
@@ -141,7 +141,6 @@ export type ModelDefinition = {
141
141
  * Schema parameters configuration
142
142
  */
143
143
  export type SchemaParams = {
144
- isoDates?: boolean;
145
144
  timestamps?: boolean;
146
145
  cleanInternalKeys?: boolean;
147
146
  };
@@ -85,9 +85,10 @@ export const createEntityAPI = <Model extends ModelDefinition>(
85
85
  },
86
86
 
87
87
  query() {
88
- // Query builder doesn't have execute() until after .where() is called
89
- // The cleanInternalKeys will be handled in the builder itself
90
- return createQueryBuilder<InferModel<Model>>(tableName, client, model, logger);
88
+ // Auto-filter by entity type so query() only returns items for this
89
+ // entity. Without this, queries on shared GSIs return items from other
90
+ // entities that share the same partition key.
91
+ return createQueryBuilder<InferModel<Model>>(tableName, client, model, logger, modelName);
91
92
  },
92
93
 
93
94
  scan() {
@@ -130,7 +131,10 @@ export const createEntityAPI = <Model extends ModelDefinition>(
130
131
  'NONE',
131
132
  0,
132
133
  timestamps,
133
- logger
134
+ logger,
135
+ // Carry the original template-vars-bearing key (e.g. { username: 'jane' })
136
+ // so the builder can recompute index keys when .set() touches their template fields.
137
+ { model, keyVars: key as Record<string, unknown> }
134
138
  );
135
139
 
136
140
  return withMiddleware(builder, createCleanKeysMiddleware(cleanInternalKeys));
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { applyPostDefaults, stripInternalKeys } from './model-utils';
2
+ import { applyPostDefaults, computeIndexUpdates, stripInternalKeys } from './model-utils';
3
3
  import { ModelDefinition } from '../core/types';
4
4
 
5
5
  describe('applyPostDefaults - Timestamps', () => {
@@ -360,3 +360,93 @@ describe('stripInternalKeys', () => {
360
360
  });
361
361
  });
362
362
  });
363
+
364
+ describe('computeIndexUpdates', () => {
365
+ const personnelModel: ModelDefinition = {
366
+ key: {
367
+ PK: { type: String, value: 'PERSON#${id}' },
368
+ SK: { type: String, value: 'PROFILE' },
369
+ },
370
+ index: {
371
+ GSI1PK: { type: String, value: 'AIRPORT#${airportId}' },
372
+ GSI1SK: { type: String, value: 'PERSON#${lastName}#${firstName}' },
373
+ },
374
+ attributes: {
375
+ id: { type: String, required: true },
376
+ airportId: { type: String, required: true },
377
+ firstName: { type: String, required: true },
378
+ lastName: { type: String, required: true },
379
+ },
380
+ };
381
+
382
+ test('returns empty result when model has no indexes', () => {
383
+ const noIndexModel: ModelDefinition = {
384
+ key: {
385
+ PK: { type: String, value: 'USER#${id}' },
386
+ SK: { type: String, value: 'USER#${id}' },
387
+ },
388
+ attributes: { id: { type: String, required: true } },
389
+ };
390
+ const result = computeIndexUpdates(noIndexModel, { id: '1' }, { name: 'X' });
391
+ expect(result).toEqual({ actions: {}, missing: [] });
392
+ });
393
+
394
+ test('skips indexes whose template references no updated field', () => {
395
+ // Updating only "role" — none of the index templates mention role.
396
+ const result = computeIndexUpdates(
397
+ personnelModel,
398
+ { id: '1' },
399
+ { role: 'pilot' }
400
+ );
401
+ expect(result).toEqual({ actions: {}, missing: [] });
402
+ });
403
+
404
+ test('recomputes an affected index when all template vars resolve', () => {
405
+ const result = computeIndexUpdates(
406
+ personnelModel,
407
+ { id: '1' },
408
+ { firstName: 'Ada', lastName: 'Lovelace' }
409
+ );
410
+ expect(result.actions).toEqual({ GSI1SK: 'PERSON#Lovelace#Ada' });
411
+ expect(result.missing).toEqual([]);
412
+ });
413
+
414
+ test('reports missing template vars instead of throwing', () => {
415
+ const result = computeIndexUpdates(
416
+ personnelModel,
417
+ { id: '1' },
418
+ { lastName: 'Lovelace' }
419
+ );
420
+ expect(result.actions).toEqual({});
421
+ expect(result.missing).toEqual([
422
+ {
423
+ index: 'GSI1SK',
424
+ template: 'PERSON#${lastName}#${firstName}',
425
+ missing: ['firstName'],
426
+ },
427
+ ]);
428
+ });
429
+
430
+ test('uses keyVars to resolve fields not present in updates', () => {
431
+ // airportId is not in updates but in keyVars — GSI1PK should still resolve.
432
+ const result = computeIndexUpdates(
433
+ personnelModel,
434
+ { id: '1', airportId: 'EZE' },
435
+ { airportId: 'AEP' } // changing it
436
+ );
437
+ expect(result.actions).toEqual({ GSI1PK: 'AIRPORT#AEP' });
438
+ });
439
+
440
+ test('handles multiple affected indexes independently', () => {
441
+ const result = computeIndexUpdates(
442
+ personnelModel,
443
+ { id: '1' },
444
+ { airportId: 'AEP', firstName: 'Ada', lastName: 'Lovelace' }
445
+ );
446
+ expect(result.actions).toEqual({
447
+ GSI1PK: 'AIRPORT#AEP',
448
+ GSI1SK: 'PERSON#Lovelace#Ada',
449
+ });
450
+ expect(result.missing).toEqual([]);
451
+ });
452
+ });
@@ -59,6 +59,60 @@ export const resolveKeys = <M extends ModelDefinition>(
59
59
  return out;
60
60
  };
61
61
 
62
+ /**
63
+ * Result of analyzing how a partial update affects a model's secondary index keys.
64
+ *
65
+ * `actions` maps index-attribute name → freshly resolved template value, ready to SET.
66
+ * `missing` lists indexes whose template references at least one updated field but
67
+ * cannot be fully resolved from `keyVars + updates`; those entries identify the
68
+ * variables the caller must additionally provide.
69
+ */
70
+ export type IndexUpdateAnalysis = {
71
+ actions: Record<string, string>;
72
+ missing: { index: string; template: string; missing: string[] }[];
73
+ };
74
+
75
+ /**
76
+ * Determines which secondary-index keys must be recomputed given a partial update.
77
+ *
78
+ * An index is "affected" iff at least one of its template variables appears in
79
+ * `updates`. For each affected index we attempt to resolve the template from the
80
+ * union of `keyVars` (template vars carried in the primary key, e.g. `id`) and
81
+ * `updates` (the user's `.set()` payload). If any template variable is missing
82
+ * from that union, the index is reported in `missing` instead of `actions` —
83
+ * recomputing it would require reading the existing item from DynamoDB, which
84
+ * the builder intentionally does not do.
85
+ */
86
+ export const computeIndexUpdates = <M extends ModelDefinition>(
87
+ model: M,
88
+ keyVars: Record<string, any>,
89
+ updates: Record<string, any>
90
+ ): IndexUpdateAnalysis => {
91
+ const actions: Record<string, string> = {};
92
+ const missing: { index: string; template: string; missing: string[] }[] = [];
93
+
94
+ if (!model.index) return { actions, missing };
95
+
96
+ const updateKeys = new Set(Object.keys(updates));
97
+ const combined: Record<string, any> = { ...keyVars, ...updates };
98
+
99
+ for (const [indexName, indexDef] of Object.entries(model.index)) {
100
+ const templateVars = extractTemplateVars(indexDef.value);
101
+ const isAffected = templateVars.some((v) => updateKeys.has(v));
102
+ if (!isAffected) continue;
103
+
104
+ const missingVars = templateVars.filter((v) => combined[v] === undefined);
105
+ if (missingVars.length > 0) {
106
+ missing.push({ index: indexName, template: indexDef.value, missing: missingVars });
107
+ continue;
108
+ }
109
+
110
+ actions[indexName] = resolveTemplate(indexDef.value, combined);
111
+ }
112
+
113
+ return { actions, missing };
114
+ };
115
+
62
116
  /**
63
117
  * Applies default and generated values to validated input
64
118
  */