@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
@@ -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
  */
@@ -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
  */
@@ -250,15 +250,20 @@ describe('Should test dbParams function builder', () => {
250
250
  .where((attr, op) => op.eq(attr.username, 'juanca'))
251
251
  .dbParams();
252
252
 
253
- // After query builder fix: username -> pk, and value gets template applied
253
+ // After query builder fix: username -> pk, and value gets template applied.
254
+ // Entity API auto-injects a `_type = <modelName>` filter so that queries on
255
+ // shared partition keys don't return items of other entities.
254
256
  expect(params).toEqual({
255
257
  TableName: 'InstagramClone',
256
258
  KeyConditionExpression: '#PK = :username_0',
259
+ FilterExpression: '#_type = :_type',
257
260
  ExpressionAttributeNames: {
258
261
  '#PK': 'PK',
262
+ '#_type': '_type',
259
263
  },
260
264
  ExpressionAttributeValues: {
261
265
  ':username_0': 'UP#juanca', // Template applied: UP#${username}
266
+ ':_type': 'Photo',
262
267
  },
263
268
  });
264
269
 
@@ -266,6 +271,7 @@ describe('Should test dbParams function builder', () => {
266
271
  expect(params.KeyConditionExpression).toMatch(/#PK = :username_\d+/);
267
272
  expect(params.ExpressionAttributeNames).toEqual({
268
273
  '#PK': 'PK',
274
+ '#_type': '_type',
269
275
  });
270
276
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain('UP#juanca');
271
277
  });
@@ -278,23 +284,27 @@ describe('Should test dbParams function builder', () => {
278
284
  expect(params).toEqual({
279
285
  TableName: 'InstagramClone',
280
286
  KeyConditionExpression: '#PK = :username_0',
281
- FilterExpression: '#likesCount > :likesCount_1',
287
+ FilterExpression: '(#likesCount > :likesCount_1) AND (#_type = :_type)',
282
288
  ExpressionAttributeNames: {
283
289
  '#likesCount': 'likesCount',
284
290
  '#PK': 'PK',
291
+ '#_type': '_type',
285
292
  },
286
293
  ExpressionAttributeValues: {
287
294
  ':likesCount_1': 0,
288
295
  ':username_0': 'UP#juanca',
296
+ ':_type': 'Photo',
289
297
  },
290
298
  });
291
299
 
292
300
  expect(params.TableName).toBe('InstagramClone');
293
301
  expect(params.KeyConditionExpression).toMatch(/#PK = :username_\d+/);
294
302
  expect(params.FilterExpression).toMatch(/#likesCount > :likesCount_\d+/);
303
+ expect(params.FilterExpression).toMatch(/#_type = :_type/);
295
304
  expect(params.ExpressionAttributeNames).toEqual({
296
305
  '#PK': 'PK',
297
306
  '#likesCount': 'likesCount',
307
+ '#_type': '_type',
298
308
  });
299
309
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain('UP#juanca');
300
310
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain(0);
@@ -310,11 +320,14 @@ describe('Should test dbParams function builder', () => {
310
320
  expect(params).toEqual({
311
321
  TableName: 'InstagramClone',
312
322
  KeyConditionExpression: '#PK = :username_0',
323
+ FilterExpression: '#_type = :_type',
313
324
  ExpressionAttributeNames: {
314
325
  '#PK': 'PK',
326
+ '#_type': '_type',
315
327
  },
316
328
  ExpressionAttributeValues: {
317
329
  ':username_0': 'UP#juanca',
330
+ ':_type': 'Photo',
318
331
  },
319
332
  Limit: 10,
320
333
  ScanIndexForward: false,
@@ -344,16 +357,19 @@ describe('Should test dbParams function builder', () => {
344
357
  expect(params).toEqual({
345
358
  TableName: 'InstagramClone',
346
359
  KeyConditionExpression: '#PK = :photoId_0',
360
+ FilterExpression: '#_type = :_type',
347
361
  ExpressionAttributeNames: {
348
362
  '#PK': 'PK',
363
+ '#_type': '_type',
349
364
  },
350
365
  ExpressionAttributeValues: {
351
366
  ':photoId_0': 'PL#photo123', // Template applied: PL#${photoId}
367
+ ':_type': 'Like',
352
368
  },
353
369
  });
354
370
  expect(params.TableName).toBe('InstagramClone');
355
371
  expect(params.KeyConditionExpression).toMatch(/#PK = :photoId_\d+/);
356
- expect(params.ExpressionAttributeNames).toEqual({ '#PK': 'PK' });
372
+ expect(params.ExpressionAttributeNames).toEqual({ '#PK': 'PK', '#_type': '_type' });
357
373
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain('PL#photo123');
358
374
  });
359
375
 
@@ -372,10 +388,12 @@ describe('Should test dbParams function builder', () => {
372
388
  expect(params.FilterExpression).toMatch(
373
389
  /\(#likesCount > :likesCount_\d+\) OR \(#commentCount > :commentCount_\d+\)/
374
390
  );
391
+ expect(params.FilterExpression).toMatch(/#_type = :_type/);
375
392
  expect(params.ExpressionAttributeNames).toEqual({
376
393
  '#PK': 'PK',
377
394
  '#likesCount': 'likesCount',
378
395
  '#commentCount': 'commentCount',
396
+ '#_type': '_type',
379
397
  });
380
398
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain('UP#juanca');
381
399
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain(100);
@@ -401,10 +419,12 @@ describe('Should test dbParams function builder', () => {
401
419
  /\(#likesCount > :likesCount_\d+\) AND \(#commentCount < :commentCount_\d+\)/
402
420
  );
403
421
  expect(params.FilterExpression).toMatch(/OR/);
422
+ expect(params.FilterExpression).toMatch(/#_type = :_type/);
404
423
  expect(params.ExpressionAttributeNames).toEqual({
405
424
  '#PK': 'PK',
406
425
  '#likesCount': 'likesCount',
407
426
  '#commentCount': 'commentCount',
427
+ '#_type': '_type',
408
428
  });
409
429
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain('UP#juanca');
410
430
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain(100);