@ftschopp/dynatable-core 1.2.5 → 1.4.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 (40) hide show
  1. package/CHANGELOG.md +14 -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 +56 -10
  5. package/dist/builders/query/types.d.ts +16 -0
  6. package/dist/builders/query/types.d.ts.map +1 -1
  7. package/dist/builders/scan/create-scan-builder.d.ts.map +1 -1
  8. package/dist/builders/scan/create-scan-builder.js +32 -0
  9. package/dist/builders/scan/types.d.ts +35 -0
  10. package/dist/builders/scan/types.d.ts.map +1 -1
  11. package/dist/builders/shared/operators.d.ts.map +1 -1
  12. package/dist/builders/shared/operators.js +8 -0
  13. package/dist/builders/shared/types.d.ts +1 -0
  14. package/dist/builders/shared/types.d.ts.map +1 -1
  15. package/dist/builders/update/create-update-builder.d.ts +8 -2
  16. package/dist/builders/update/create-update-builder.d.ts.map +1 -1
  17. package/dist/builders/update/create-update-builder.js +45 -16
  18. package/dist/builders/update/types.d.ts +13 -0
  19. package/dist/builders/update/types.d.ts.map +1 -1
  20. package/dist/entity/create-entity-api.d.ts.map +1 -1
  21. package/dist/entity/create-entity-api.js +8 -4
  22. package/dist/utils/model-utils.d.ts +28 -0
  23. package/dist/utils/model-utils.d.ts.map +1 -1
  24. package/dist/utils/model-utils.js +34 -1
  25. package/package.json +1 -1
  26. package/src/builders/query/create-query-builder.test.ts +324 -0
  27. package/src/builders/query/create-query-builder.ts +73 -10
  28. package/src/builders/query/types.ts +17 -0
  29. package/src/builders/scan/create-scan-builder.test.ts +121 -0
  30. package/src/builders/scan/create-scan-builder.ts +35 -1
  31. package/src/builders/scan/types.ts +39 -0
  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/entity/create-entity-api.ts +8 -4
  38. package/src/utils/model-utils.test.ts +91 -1
  39. package/src/utils/model-utils.ts +54 -0
  40. package/tests/integration/instagram-clone.integration.test.ts +23 -3
@@ -14,6 +14,34 @@ export declare const resolveTemplate: (template: string, data: Record<string, an
14
14
  * Resolves all keys or indexes for a model
15
15
  */
16
16
  export declare const resolveKeys: <M extends ModelDefinition>(model: M, input: Record<string, any>, type?: "key" | "index" | "both") => Record<string, string>;
17
+ /**
18
+ * Result of analyzing how a partial update affects a model's secondary index keys.
19
+ *
20
+ * `actions` maps index-attribute name → freshly resolved template value, ready to SET.
21
+ * `missing` lists indexes whose template references at least one updated field but
22
+ * cannot be fully resolved from `keyVars + updates`; those entries identify the
23
+ * variables the caller must additionally provide.
24
+ */
25
+ export type IndexUpdateAnalysis = {
26
+ actions: Record<string, string>;
27
+ missing: {
28
+ index: string;
29
+ template: string;
30
+ missing: string[];
31
+ }[];
32
+ };
33
+ /**
34
+ * Determines which secondary-index keys must be recomputed given a partial update.
35
+ *
36
+ * An index is "affected" iff at least one of its template variables appears in
37
+ * `updates`. For each affected index we attempt to resolve the template from the
38
+ * union of `keyVars` (template vars carried in the primary key, e.g. `id`) and
39
+ * `updates` (the user's `.set()` payload). If any template variable is missing
40
+ * from that union, the index is reported in `missing` instead of `actions` —
41
+ * recomputing it would require reading the existing item from DynamoDB, which
42
+ * the builder intentionally does not do.
43
+ */
44
+ export declare const computeIndexUpdates: <M extends ModelDefinition>(model: M, keyVars: Record<string, any>, updates: Record<string, any>) => IndexUpdateAnalysis;
17
45
  /**
18
46
  * Applies default and generated values to validated input
19
47
  */
@@ -1 +1 @@
1
- {"version":3,"file":"model-utils.d.ts","sourceRoot":"","sources":["../../src/utils/model-utils.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAiB,eAAe,EAAE,MAAM,cAAc,CAAC;AAG1E;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,GAAI,UAAU,MAAM,KAAG,MAAM,EAG5D,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,eAAe,GAAI,UAAU,MAAM,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAG,MAoB7E,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,eAAe,EACnD,OAAO,CAAC,EACR,OAAO,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC1B,OAAM,KAAK,GAAG,OAAO,GAAG,MAAc,KACrC,MAAM,CAAC,MAAM,EAAE,MAAM,CAWvB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,iBAAiB,GAAI,CAAC,SAAS,eAAe,EACzD,OAAO,CAAC,EACR,eAAe,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAClC,UAAU;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,KACrD,UAAU,CAAC,CAAC,CAiCd,CAAC;AAOF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,SAAS,KAAG,CAAC,GAAG,CAAC,EAAE,GAAG,SAoB1E,CAAC"}
1
+ {"version":3,"file":"model-utils.d.ts","sourceRoot":"","sources":["../../src/utils/model-utils.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAiB,eAAe,EAAE,MAAM,cAAc,CAAC;AAG1E;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,GAAI,UAAU,MAAM,KAAG,MAAM,EAG5D,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,eAAe,GAAI,UAAU,MAAM,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAG,MAoB7E,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,eAAe,EACnD,OAAO,CAAC,EACR,OAAO,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC1B,OAAM,KAAK,GAAG,OAAO,GAAG,MAAc,KACrC,MAAM,CAAC,MAAM,EAAE,MAAM,CAWvB,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,EAAE,CAAC;CACnE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,GAAI,CAAC,SAAS,eAAe,EAC3D,OAAO,CAAC,EACR,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC5B,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAC3B,mBAwBF,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,iBAAiB,GAAI,CAAC,SAAS,eAAe,EACzD,OAAO,CAAC,EACR,eAAe,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAClC,UAAU;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,KACrD,UAAU,CAAC,CAAC,CAiCd,CAAC;AAOF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,SAAS,KAAG,CAAC,GAAG,CAAC,EAAE,GAAG,SAoB1E,CAAC"}
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.stripInternalKeys = exports.applyPostDefaults = exports.resolveKeys = exports.resolveTemplate = exports.extractTemplateVars = void 0;
4
+ exports.stripInternalKeys = exports.applyPostDefaults = exports.computeIndexUpdates = exports.resolveKeys = exports.resolveTemplate = exports.extractTemplateVars = void 0;
5
5
  const ulid_1 = require("ulid");
6
6
  /**
7
7
  * Extracts all variable names from a template string
@@ -53,6 +53,39 @@ const resolveKeys = (model, input, type = 'key') => {
53
53
  return out;
54
54
  };
55
55
  exports.resolveKeys = resolveKeys;
56
+ /**
57
+ * Determines which secondary-index keys must be recomputed given a partial update.
58
+ *
59
+ * An index is "affected" iff at least one of its template variables appears in
60
+ * `updates`. For each affected index we attempt to resolve the template from the
61
+ * union of `keyVars` (template vars carried in the primary key, e.g. `id`) and
62
+ * `updates` (the user's `.set()` payload). If any template variable is missing
63
+ * from that union, the index is reported in `missing` instead of `actions` —
64
+ * recomputing it would require reading the existing item from DynamoDB, which
65
+ * the builder intentionally does not do.
66
+ */
67
+ const computeIndexUpdates = (model, keyVars, updates) => {
68
+ const actions = {};
69
+ const missing = [];
70
+ if (!model.index)
71
+ return { actions, missing };
72
+ const updateKeys = new Set(Object.keys(updates));
73
+ const combined = { ...keyVars, ...updates };
74
+ for (const [indexName, indexDef] of Object.entries(model.index)) {
75
+ const templateVars = (0, exports.extractTemplateVars)(indexDef.value);
76
+ const isAffected = templateVars.some((v) => updateKeys.has(v));
77
+ if (!isAffected)
78
+ continue;
79
+ const missingVars = templateVars.filter((v) => combined[v] === undefined);
80
+ if (missingVars.length > 0) {
81
+ missing.push({ index: indexName, template: indexDef.value, missing: missingVars });
82
+ continue;
83
+ }
84
+ actions[indexName] = (0, exports.resolveTemplate)(indexDef.value, combined);
85
+ }
86
+ return { actions, missing };
87
+ };
88
+ exports.computeIndexUpdates = computeIndexUpdates;
56
89
  /**
57
90
  * Applies default and generated values to validated input
58
91
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ftschopp/dynatable-core",
3
- "version": "1.2.5",
3
+ "version": "1.4.0",
4
4
  "description": "Core library for DynamoDB single table design",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -227,6 +227,126 @@ describe('QueryBuilder - Pagination', () => {
227
227
  expect(builder2.dbParams().ExclusiveStartKey).toEqual(startKey);
228
228
  });
229
229
  });
230
+
231
+ describe('iterate method', () => {
232
+ test('walks every page transparently and yields items in order', async () => {
233
+ const page1 = [
234
+ { pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
235
+ { pk: 'USER#2', sk: 'USER#2', name: 'User 2' },
236
+ ];
237
+ const page2 = [
238
+ { pk: 'USER#3', sk: 'USER#3', name: 'User 3' },
239
+ { pk: 'USER#4', sk: 'USER#4', name: 'User 4' },
240
+ ];
241
+ const page3 = [{ pk: 'USER#5', sk: 'USER#5', name: 'User 5' }];
242
+
243
+ ddbMock
244
+ .on(QueryCommand)
245
+ .resolvesOnce({ Items: page1, LastEvaluatedKey: { pk: 'USER#2', sk: 'USER#2' } })
246
+ .resolvesOnce({ Items: page2, LastEvaluatedKey: { pk: 'USER#4', sk: 'USER#4' } })
247
+ .resolvesOnce({ Items: page3 });
248
+
249
+ const collected: TestModel[] = [];
250
+ for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
251
+ .where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
252
+ .iterate()) {
253
+ collected.push(item);
254
+ }
255
+
256
+ expect(collected).toHaveLength(5);
257
+ expect(collected.map((u) => u.name)).toEqual([
258
+ 'User 1',
259
+ 'User 2',
260
+ 'User 3',
261
+ 'User 4',
262
+ 'User 5',
263
+ ]);
264
+ expect(ddbMock.calls()).toHaveLength(3);
265
+ });
266
+
267
+ test('forwards ExclusiveStartKey from each response into the next call', async () => {
268
+ const cursor1 = { pk: 'USER#2', sk: 'USER#2' };
269
+ const cursor2 = { pk: 'USER#4', sk: 'USER#4' };
270
+
271
+ ddbMock
272
+ .on(QueryCommand)
273
+ .resolvesOnce({ Items: [{ pk: 'USER#1', sk: 'USER#1' }], LastEvaluatedKey: cursor1 })
274
+ .resolvesOnce({ Items: [{ pk: 'USER#3', sk: 'USER#3' }], LastEvaluatedKey: cursor2 })
275
+ .resolvesOnce({ Items: [{ pk: 'USER#5', sk: 'USER#5' }] });
276
+
277
+ const iterator = createQueryBuilder<TestModel>(tableName, client, testModel)
278
+ .where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
279
+ .iterate();
280
+
281
+ // Consume the iterator
282
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
283
+ for await (const _ of iterator) {
284
+ // drain
285
+ }
286
+
287
+ const calls = ddbMock.calls();
288
+ expect(calls).toHaveLength(3);
289
+ expect((calls[0]!.args[0].input as any).ExclusiveStartKey).toBeUndefined();
290
+ expect((calls[1]!.args[0].input as any).ExclusiveStartKey).toEqual(cursor1);
291
+ expect((calls[2]!.args[0].input as any).ExclusiveStartKey).toEqual(cursor2);
292
+ });
293
+
294
+ test('starts from the user-provided cursor on the first call', async () => {
295
+ const startKey = { pk: 'USER#10', sk: 'USER#10' };
296
+
297
+ ddbMock.on(QueryCommand).resolves({ Items: [{ pk: 'USER#11', sk: 'USER#11' }] });
298
+
299
+ const iterator = createQueryBuilder<TestModel>(tableName, client, testModel)
300
+ .where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
301
+ .startFrom(startKey)
302
+ .iterate();
303
+
304
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
305
+ for await (const _ of iterator) {
306
+ // drain
307
+ }
308
+
309
+ const calls = ddbMock.calls();
310
+ expect((calls[0]!.args[0].input as any).ExclusiveStartKey).toEqual(startKey);
311
+ });
312
+
313
+ test('break out of the loop stops further DynamoDB calls', async () => {
314
+ ddbMock
315
+ .on(QueryCommand)
316
+ .resolvesOnce({
317
+ Items: [
318
+ { pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
319
+ { pk: 'USER#2', sk: 'USER#2', name: 'User 2' },
320
+ ],
321
+ LastEvaluatedKey: { pk: 'USER#2', sk: 'USER#2' },
322
+ });
323
+
324
+ const collected: TestModel[] = [];
325
+ for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
326
+ .where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
327
+ .iterate()) {
328
+ collected.push(item);
329
+ if (collected.length >= 1) break;
330
+ }
331
+
332
+ expect(collected).toHaveLength(1);
333
+ expect(ddbMock.calls()).toHaveLength(1);
334
+ });
335
+
336
+ test('handles an empty result without making extra calls', async () => {
337
+ ddbMock.on(QueryCommand).resolves({ Items: [] });
338
+
339
+ const collected: TestModel[] = [];
340
+ for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
341
+ .where((attr, op) => op.beginsWith(attr.pk, 'NONE#'))
342
+ .iterate()) {
343
+ collected.push(item);
344
+ }
345
+
346
+ expect(collected).toEqual([]);
347
+ expect(ddbMock.calls()).toHaveLength(1);
348
+ });
349
+ });
230
350
  });
231
351
 
232
352
  describe('QueryBuilder - GSI key recognition', () => {
@@ -298,3 +418,207 @@ describe('QueryBuilder - GSI key recognition', () => {
298
418
  expect(params.FilterExpression).toBeUndefined();
299
419
  });
300
420
  });
421
+
422
+ describe('QueryBuilder - multi-variable key templates', () => {
423
+ const client = new DynamoDBClient({});
424
+ const tableName = 'TestTable';
425
+
426
+ interface AirportResource {
427
+ id: string;
428
+ airport: string;
429
+ category: string;
430
+ code: string;
431
+ status: string;
432
+ }
433
+
434
+ const airportResourceModel: ModelDefinition = {
435
+ key: {
436
+ PK: { type: String, value: 'AIRPORT_RESOURCE#${id}' },
437
+ SK: { type: String, value: 'AIRPORT_RESOURCE#${id}' },
438
+ },
439
+ index: {
440
+ GSI1PK: { type: String, value: 'AIRPORT#${airport}' },
441
+ GSI1SK: { type: String, value: 'RES#${category}#${code}' },
442
+ },
443
+ attributes: {
444
+ id: { type: String, required: true },
445
+ airport: { type: String, required: true },
446
+ category: { type: String, required: true },
447
+ code: { type: String, required: true },
448
+ status: { type: String, required: true },
449
+ },
450
+ };
451
+
452
+ test('beginsWith on attribute that is part of multi-var template should truncate at the first unfilled variable', () => {
453
+ const params = createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
454
+ .where((attr, op) =>
455
+ op.and(op.eq(attr.airport, 'EZE'), op.beginsWith(attr.category, 'GPU'))
456
+ )
457
+ .useIndex('GSI1')
458
+ .dbParams();
459
+
460
+ expect(params.KeyConditionExpression).toBeDefined();
461
+ expect(params.KeyConditionExpression).toContain('#GSI1PK');
462
+ expect(params.KeyConditionExpression).toContain('begins_with(#GSI1SK');
463
+ expect(params.IndexName).toBe('GSI1');
464
+
465
+ const values = params.ExpressionAttributeValues ?? {};
466
+ expect(Object.values(values)).toContain('AIRPORT#EZE');
467
+ // Should be the prefix up to the next unfilled var, NOT 'RES#GPU#${code}'
468
+ expect(Object.values(values)).toContain('RES#GPU#');
469
+ expect(Object.values(values).every((v) => !String(v).includes('${'))).toBe(true);
470
+ });
471
+
472
+ test('eq on attribute with multi-var template should throw a clear error', () => {
473
+ expect(() =>
474
+ createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
475
+ .where((attr, op) => op.eq(attr.category, 'GPU'))
476
+ .useIndex('GSI1')
477
+ .dbParams()
478
+ ).toThrow(/template/i);
479
+ });
480
+
481
+ test('beginsWith on a single-var template still works (no truncation needed)', () => {
482
+ const params = createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
483
+ .where((attr, op) => op.beginsWith(attr.airport, 'EZ'))
484
+ .useIndex('GSI1')
485
+ .dbParams();
486
+
487
+ const values = params.ExpressionAttributeValues ?? {};
488
+ expect(Object.values(values)).toContain('AIRPORT#EZ');
489
+ });
490
+ });
491
+
492
+ describe('QueryBuilder - entity type auto filter', () => {
493
+ const client = new DynamoDBClient({});
494
+ const tableName = 'TestTable';
495
+
496
+ interface AirportPersonnel {
497
+ id: string;
498
+ airport: string;
499
+ role: string;
500
+ }
501
+
502
+ // Mirrors the reported scenario: PK is entity-specific, GSI1 is shared
503
+ // across multiple entities (Airport, AirportPersonnel, etc.)
504
+ const airportPersonnelModel: ModelDefinition = {
505
+ key: {
506
+ PK: { type: String, value: 'AIRPORT_PERSONNEL#${id}' },
507
+ SK: { type: String, value: 'AIRPORT_PERSONNEL#${id}' },
508
+ },
509
+ index: {
510
+ GSI1PK: { type: String, value: 'AIRPORT#${airport}' },
511
+ GSI1SK: { type: String, value: 'PERSONNEL#${role}' },
512
+ },
513
+ attributes: {
514
+ id: { type: String, required: true },
515
+ airport: { type: String, required: true },
516
+ role: { type: String, required: true },
517
+ },
518
+ };
519
+
520
+ test('does NOT add _type filter when entityType is not provided (back-compat)', () => {
521
+ const params = createQueryBuilder<AirportPersonnel>(
522
+ tableName,
523
+ client,
524
+ airportPersonnelModel
525
+ )
526
+ .where((attr, op) => op.eq(attr.airport, 'EZE'))
527
+ .useIndex('GSI1')
528
+ .dbParams();
529
+
530
+ expect(params.FilterExpression).toBeUndefined();
531
+ const names = params.ExpressionAttributeNames ?? {};
532
+ expect(names['#_type']).toBeUndefined();
533
+ });
534
+
535
+ test('adds _type filter when entityType is provided and there are no other filters', () => {
536
+ const params = createQueryBuilder<AirportPersonnel>(
537
+ tableName,
538
+ client,
539
+ airportPersonnelModel,
540
+ undefined,
541
+ 'AirportPersonnel'
542
+ )
543
+ .where((attr, op) => op.eq(attr.airport, 'EZE'))
544
+ .useIndex('GSI1')
545
+ .dbParams();
546
+
547
+ // Key cond goes to KeyConditionExpression, _type goes to FilterExpression
548
+ expect(params.KeyConditionExpression).toBeDefined();
549
+ expect(params.KeyConditionExpression).toContain('#GSI1PK');
550
+ expect(params.FilterExpression).toBe('#_type = :_type');
551
+ expect(params.ExpressionAttributeNames?.['#_type']).toBe('_type');
552
+ expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
553
+ });
554
+
555
+ test('combines _type filter with a user-provided non-key filter via AND', () => {
556
+ const params = createQueryBuilder<AirportPersonnel & { status?: string }>(
557
+ tableName,
558
+ client,
559
+ airportPersonnelModel,
560
+ undefined,
561
+ 'AirportPersonnel'
562
+ )
563
+ .where((attr, op) => op.and(op.eq(attr.airport, 'EZE'), op.eq(attr.status, 'active')))
564
+ .useIndex('GSI1')
565
+ .dbParams();
566
+
567
+ expect(params.KeyConditionExpression).toContain('#GSI1PK');
568
+ expect(params.FilterExpression).toBeDefined();
569
+ expect(params.FilterExpression).toContain('#status');
570
+ expect(params.FilterExpression).toContain('#_type = :_type');
571
+ expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
572
+ });
573
+
574
+ test('repro of reported bug: GSI1 query with conditional beginsWith still filters by _type', () => {
575
+ // The exact shape from the user report:
576
+ // .where((attr, op) =>
577
+ // role
578
+ // ? op.and(op.eq(attr.airport, airport), op.beginsWith(attr.role, role))
579
+ // : op.eq(attr.airport, airport)
580
+ // )
581
+ const airport = 'EZE';
582
+ const role = 'PILOT';
583
+
584
+ const params = createQueryBuilder<AirportPersonnel>(
585
+ tableName,
586
+ client,
587
+ airportPersonnelModel,
588
+ undefined,
589
+ 'AirportPersonnel'
590
+ )
591
+ .where((attr, op) =>
592
+ role
593
+ ? op.and(op.eq(attr.airport, airport), op.beginsWith(attr.role, role))
594
+ : op.eq(attr.airport, airport)
595
+ )
596
+ .useIndex('GSI1')
597
+ .dbParams();
598
+
599
+ // Both airport (GSI1PK) and role (GSI1SK) become key conditions
600
+ expect(params.KeyConditionExpression).toContain('#GSI1PK');
601
+ expect(params.KeyConditionExpression).toContain('begins_with(#GSI1SK');
602
+ // _type still gets enforced as a filter — this is the fix
603
+ expect(params.FilterExpression).toBe('#_type = :_type');
604
+ expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
605
+ });
606
+
607
+ test('does not collide with user attribute placeholders', () => {
608
+ // Confirm the auto-injected names/values don't clash with user-supplied ones
609
+ const params = createQueryBuilder<AirportPersonnel & { status?: string }>(
610
+ tableName,
611
+ client,
612
+ airportPersonnelModel,
613
+ undefined,
614
+ 'AirportPersonnel'
615
+ )
616
+ .where((attr, op) => op.and(op.eq(attr.airport, 'EZE'), op.eq(attr.status, 'active')))
617
+ .useIndex('GSI1')
618
+ .dbParams();
619
+
620
+ expect(params.ExpressionAttributeNames?.['#_type']).toBe('_type');
621
+ expect(params.ExpressionAttributeNames?.['#status']).toBe('status');
622
+ expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
623
+ });
624
+ });
@@ -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
  }
@@ -294,6 +333,14 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
294
333
  };
295
334
  },
296
335
 
336
+ /**
337
+ * ⚠️ Returns only the FIRST page of results. DynamoDB caps each Query
338
+ * response at ~1MB (or `Limit` if set). If the matching set is larger,
339
+ * the remaining items are silently dropped.
340
+ *
341
+ * Use {@link executeWithPagination} when you need to drive pagination
342
+ * yourself, or {@link iterate} to walk every matching item lazily.
343
+ */
297
344
  async execute(): Promise<Model[]> {
298
345
  const params = this.dbParams();
299
346
  const result = await state.client.send(new QueryCommand(params));
@@ -312,6 +359,20 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
312
359
  scannedCount: result.ScannedCount,
313
360
  };
314
361
  },
362
+
363
+ async *iterate(): AsyncIterableIterator<Model> {
364
+ const baseParams = this.dbParams();
365
+ let cursor: Record<string, any> | undefined = baseParams.ExclusiveStartKey;
366
+ do {
367
+ const params = { ...baseParams, ExclusiveStartKey: cursor };
368
+ const result = await state.client.send(new QueryCommand(params));
369
+ state.logger?.log('QueryCommand', params, result);
370
+ for (const item of (result.Items ?? []) as unknown as Model[]) {
371
+ yield item;
372
+ }
373
+ cursor = result.LastEvaluatedKey;
374
+ } while (cursor);
375
+ },
315
376
  };
316
377
  }
317
378
 
@@ -322,7 +383,8 @@ export function createQueryBuilder<Model>(
322
383
  tableName: string,
323
384
  client: DynamoDBClient,
324
385
  model?: ModelDefinition,
325
- logger?: DynamoDBLogger
386
+ logger?: DynamoDBLogger,
387
+ entityType?: string
326
388
  ): QueryBuilder<Model> {
327
389
  return {
328
390
  where(fn) {
@@ -346,6 +408,7 @@ export function createQueryBuilder<Model>(
346
408
  model,
347
409
  condition,
348
410
  logger,
411
+ entityType,
349
412
  };
350
413
 
351
414
  return createQueryExecutor(initialState);
@@ -74,6 +74,23 @@ export interface QueryExecutor<Model> extends ExecutableBuilder<Model[]> {
74
74
  * Executes the query and returns result with pagination metadata
75
75
  */
76
76
  executeWithPagination(): Promise<QueryResult<Model>>;
77
+
78
+ /**
79
+ * Returns an async iterator that paginates internally and yields one item at
80
+ * a time. Memory stays at one page; you can `break` out of the loop early.
81
+ *
82
+ * `Limit` set via `.limit()` is forwarded to DynamoDB as a *per-request*
83
+ * cap, not a total cap. To cap the total, count yourself inside the loop:
84
+ *
85
+ * ```ts
86
+ * let n = 0;
87
+ * for await (const item of builder.iterate()) {
88
+ * if (n++ >= 1000) break;
89
+ * process(item);
90
+ * }
91
+ * ```
92
+ */
93
+ iterate(): AsyncIterableIterator<Model>;
77
94
  }
78
95
 
79
96
  /**