@ftschopp/dynatable-core 1.4.2 → 1.4.3

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 (81) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/builders/query/create-query-builder.d.ts.map +1 -1
  3. package/dist/builders/query/create-query-builder.js +19 -2
  4. package/dist/core/types.d.ts +5 -0
  5. package/dist/core/types.d.ts.map +1 -1
  6. package/package.json +11 -2
  7. package/eslint.config.mjs +0 -4
  8. package/jest.config.js +0 -11
  9. package/src/builders/README.md +0 -339
  10. package/src/builders/batch-get/README.md +0 -43
  11. package/src/builders/batch-get/create-batch-get-builder.test.ts +0 -193
  12. package/src/builders/batch-get/create-batch-get-builder.ts +0 -108
  13. package/src/builders/batch-get/index.ts +0 -2
  14. package/src/builders/batch-get/types.ts +0 -28
  15. package/src/builders/batch-write/README.md +0 -204
  16. package/src/builders/batch-write/create-batch-write-builder.test.ts +0 -173
  17. package/src/builders/batch-write/create-batch-write-builder.ts +0 -49
  18. package/src/builders/batch-write/index.ts +0 -2
  19. package/src/builders/batch-write/types.ts +0 -33
  20. package/src/builders/delete/create-delete-builder.test.ts +0 -294
  21. package/src/builders/delete/create-delete-builder.ts +0 -100
  22. package/src/builders/delete/index.ts +0 -2
  23. package/src/builders/delete/types.ts +0 -17
  24. package/src/builders/get/create-get-builder.test.ts +0 -272
  25. package/src/builders/get/create-get-builder.ts +0 -117
  26. package/src/builders/get/index.ts +0 -2
  27. package/src/builders/get/types.ts +0 -39
  28. package/src/builders/index.ts +0 -14
  29. package/src/builders/put/create-put-builder.test.ts +0 -213
  30. package/src/builders/put/create-put-builder.ts +0 -160
  31. package/src/builders/put/index.ts +0 -2
  32. package/src/builders/put/types.ts +0 -24
  33. package/src/builders/query/create-query-builder.test.ts +0 -770
  34. package/src/builders/query/create-query-builder.ts +0 -455
  35. package/src/builders/query/index.ts +0 -2
  36. package/src/builders/query/types.ts +0 -109
  37. package/src/builders/scan/create-scan-builder.test.ts +0 -416
  38. package/src/builders/scan/create-scan-builder.ts +0 -265
  39. package/src/builders/scan/index.ts +0 -2
  40. package/src/builders/scan/types.ts +0 -88
  41. package/src/builders/shared/conditions.ts +0 -58
  42. package/src/builders/shared/index.ts +0 -4
  43. package/src/builders/shared/operators.test.ts +0 -270
  44. package/src/builders/shared/operators.ts +0 -208
  45. package/src/builders/shared/projection.ts +0 -28
  46. package/src/builders/shared/types.ts +0 -101
  47. package/src/builders/transact-get/README.md +0 -167
  48. package/src/builders/transact-get/create-transact-get-builder.test.ts +0 -239
  49. package/src/builders/transact-get/create-transact-get-builder.ts +0 -67
  50. package/src/builders/transact-get/index.ts +0 -2
  51. package/src/builders/transact-get/types.ts +0 -31
  52. package/src/builders/transact-write/README.md +0 -202
  53. package/src/builders/transact-write/create-transact-write-builder.test.ts +0 -288
  54. package/src/builders/transact-write/create-transact-write-builder.ts +0 -124
  55. package/src/builders/transact-write/index.ts +0 -2
  56. package/src/builders/transact-write/types.ts +0 -71
  57. package/src/builders/update/create-update-builder.test.ts +0 -573
  58. package/src/builders/update/create-update-builder.ts +0 -375
  59. package/src/builders/update/index.ts +0 -2
  60. package/src/builders/update/types.ts +0 -63
  61. package/src/core/types.test.ts +0 -641
  62. package/src/core/types.ts +0 -366
  63. package/src/entity/create-entity-api.ts +0 -224
  64. package/src/entity/index.ts +0 -19
  65. package/src/entity/middleware/factories.ts +0 -15
  66. package/src/entity/middleware/types.ts +0 -7
  67. package/src/entity/middleware/with-middleware.ts +0 -37
  68. package/src/entity/types.ts +0 -79
  69. package/src/entity/validation/key-validation.ts +0 -34
  70. package/src/index.ts +0 -23
  71. package/src/table.ts +0 -116
  72. package/src/utils/dynamodb-logger.test.ts +0 -246
  73. package/src/utils/dynamodb-logger.ts +0 -175
  74. package/src/utils/model-utils.test.ts +0 -452
  75. package/src/utils/model-utils.ts +0 -188
  76. package/src/utils/zod-utils.test.ts +0 -363
  77. package/src/utils/zod-utils.ts +0 -56
  78. package/tests/integration/instagram-clone.integration.test.ts +0 -1019
  79. package/tests/integration/pagination-timestamps.integration.test.ts +0 -374
  80. package/tests/integration/transactions.integration.test.ts +0 -528
  81. package/tsconfig.json +0 -12
@@ -1,455 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
3
- import { QueryCommand, QueryCommandInput } from '@aws-sdk/lib-dynamodb';
4
- import { Condition, AttrBuilder, AttrRef } from '../shared';
5
- import { createOpBuilder } from '../shared/operators';
6
- import { buildExpression } from '../shared/conditions';
7
- import { buildProjectionExpression } from '../shared/projection';
8
- import { QueryBuilder, QueryExecutor, QueryResult } from './types';
9
- import { ModelDefinition } from '../../core/types';
10
- import { extractTemplateVars } from '../../utils/model-utils';
11
- import { DynamoDBLogger } from '../../utils/dynamodb-logger';
12
-
13
- /**
14
- * Internal state for the query builder
15
- */
16
- type QueryState<Model> = {
17
- tableName: string;
18
- client: DynamoDBClient;
19
- model?: ModelDefinition;
20
- condition?: Condition;
21
- indexName?: string;
22
- limitValue?: number;
23
- scanForward?: boolean;
24
- projection?: (keyof Model)[];
25
- consistentReadEnabled?: boolean;
26
- exclusiveStartKey?: Record<string, any>;
27
- logger?: DynamoDBLogger;
28
- entityType?: string;
29
- };
30
-
31
- /**
32
- * Maps a model attribute name to its corresponding DynamoDB key name (PK/SK)
33
- * Also returns the key template for value transformation
34
- */
35
- function getKeyNameForAttribute(
36
- fieldName: string,
37
- model?: ModelDefinition,
38
- indexName?: string
39
- ): { keyName: string; template: string } | null {
40
- if (!model?.key) return null;
41
-
42
- // When an index is specified, check model.index first for key templates
43
- if (indexName && model.index) {
44
- for (const [keyName, keyDef] of Object.entries(model.index)) {
45
- if (!keyName.startsWith(indexName)) {
46
- continue;
47
- }
48
- const templateVars = extractTemplateVars(keyDef.value);
49
- if (templateVars.includes(fieldName)) {
50
- return { keyName, template: keyDef.value };
51
- }
52
- }
53
- }
54
-
55
- for (const [keyName, keyDef] of Object.entries(model.key)) {
56
- const templateVars = extractTemplateVars(keyDef.value);
57
- if (templateVars.includes(fieldName)) {
58
- return { keyName, template: keyDef.value };
59
- }
60
- }
61
- return null;
62
- }
63
-
64
- /**
65
- * Apply key template to transform an attribute value to its key value.
66
- *
67
- * For multi-variable templates (e.g. "RES#${category}#${code}") we can only fill
68
- * the variable matching `fieldName`. For `beginsWith` queries we truncate the
69
- * result at the first remaining `${...}` placeholder so the prefix is usable.
70
- * For other operators an exact match is required, so we throw a clear error.
71
- */
72
- function applyKeyTemplate(
73
- template: string,
74
- fieldName: string,
75
- fieldValue: any,
76
- keyOperator?: string
77
- ): string {
78
- const substituted = template.replace(`\${${fieldName}}`, String(fieldValue));
79
- const nextPlaceholder = substituted.indexOf('${');
80
- if (nextPlaceholder === -1) return substituted;
81
-
82
- if (keyOperator === 'beginsWith') {
83
- return substituted.slice(0, nextPlaceholder);
84
- }
85
-
86
- throw new Error(
87
- `Cannot use operator "${keyOperator ?? 'unknown'}" on key template "${template}" ` +
88
- `with only "${fieldName}" provided — the template still contains unfilled ` +
89
- `variable(s). Use beginsWith for prefix queries on composite keys.`
90
- );
91
- }
92
-
93
- /**
94
- * Separates a condition tree into key conditions and filter conditions
95
- * Key conditions are rewritten to use actual DynamoDB key names (PK/SK)
96
- */
97
- function separateConditions(
98
- condition: Condition,
99
- model?: ModelDefinition,
100
- indexName?: string
101
- ): { keyConditions: Condition[]; filterConditions: Condition[] } {
102
- const keyConditions: Condition[] = [];
103
- const filterConditions: Condition[] = [];
104
-
105
- function traverse(cond: Condition) {
106
- // If it's a combinator
107
- if (cond.operator && cond.children) {
108
- // For KeyConditionExpression, we can only use AND at the top level
109
- if (cond.operator === 'AND') {
110
- cond.children.forEach((child) => {
111
- // Check if this is a leaf condition or another combinator
112
- if (child.children) {
113
- // Nested combinator - goes to filter
114
- filterConditions.push(child);
115
- } else {
116
- traverse(child);
117
- }
118
- });
119
- } else {
120
- // OR at any level must go to filter
121
- filterConditions.push(cond);
122
- }
123
- return;
124
- }
125
-
126
- // It's a leaf condition - check if it's a key field
127
- // Extract field name from the expression (e.g., "#username" -> "username")
128
- const fieldMatch = cond.expression.match(/#(\w+)/);
129
- if (fieldMatch && fieldMatch[1]) {
130
- const fieldName = fieldMatch[1];
131
- const keyInfo = getKeyNameForAttribute(fieldName, model, indexName);
132
-
133
- if (keyInfo) {
134
- // This is a key field - rewrite the condition to use the actual key name
135
- // and apply the template to the value
136
-
137
- // Find the value placeholder in the condition and transform it
138
- const newValues: Record<string, any> = {};
139
- if (cond.values) {
140
- for (const [valuePlaceholder, value] of Object.entries(cond.values)) {
141
- // Apply the key template to transform the value
142
- const transformedValue = applyKeyTemplate(
143
- keyInfo.template,
144
- fieldName,
145
- value,
146
- cond.keyOperator
147
- );
148
- newValues[valuePlaceholder] = transformedValue;
149
- }
150
- }
151
-
152
- const rewrittenCond = {
153
- ...cond,
154
- expression: cond.expression.replace(
155
- new RegExp(`#${fieldName}\\b`, 'g'),
156
- `#${keyInfo.keyName}`
157
- ),
158
- names: {
159
- [`#${keyInfo.keyName}`]: keyInfo.keyName,
160
- },
161
- values: newValues,
162
- };
163
-
164
- keyConditions.push(rewrittenCond);
165
- } else {
166
- filterConditions.push(cond);
167
- }
168
- } else {
169
- // Can't determine field, put in filter to be safe
170
- filterConditions.push(cond);
171
- }
172
- }
173
-
174
- traverse(condition);
175
-
176
- return { keyConditions, filterConditions };
177
- }
178
-
179
- /**
180
- * Builds a simple AND expression from multiple conditions
181
- */
182
- function buildSimpleAndExpression(conditions: Condition[]): {
183
- expression: string;
184
- names: Record<string, string>;
185
- values: Record<string, any>;
186
- } {
187
- if (conditions.length === 0) {
188
- return { expression: '', names: {}, values: {} };
189
- }
190
-
191
- const expressions: string[] = [];
192
- const names: Record<string, string> = {};
193
- const values: Record<string, any> = {};
194
-
195
- conditions.forEach((cond) => {
196
- expressions.push(cond.expression);
197
- Object.assign(names, cond.names || {});
198
- Object.assign(values, cond.values || {});
199
- });
200
-
201
- return {
202
- expression: expressions.join(' AND '),
203
- names,
204
- values,
205
- };
206
- }
207
-
208
- /**
209
- * Creates the query executor with all query methods
210
- */
211
- function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Model> {
212
- return {
213
- limit(count) {
214
- return createQueryExecutor({
215
- ...state,
216
- limitValue: count,
217
- });
218
- },
219
-
220
- scanIndexForward(forward) {
221
- return createQueryExecutor({
222
- ...state,
223
- scanForward: forward,
224
- });
225
- },
226
-
227
- useIndex(indexName) {
228
- return createQueryExecutor({
229
- ...state,
230
- indexName,
231
- });
232
- },
233
-
234
- select(attrs) {
235
- return createQueryExecutor({
236
- ...state,
237
- projection: attrs,
238
- });
239
- },
240
-
241
- consistentRead() {
242
- return createQueryExecutor({
243
- ...state,
244
- consistentReadEnabled: true,
245
- });
246
- },
247
-
248
- startFrom(key) {
249
- return createQueryExecutor({
250
- ...state,
251
- exclusiveStartKey: key,
252
- });
253
- },
254
-
255
- dbParams(): QueryCommandInput {
256
- if (!state.condition) {
257
- throw new Error('No where condition specified');
258
- }
259
-
260
- // Separate key conditions from filter conditions
261
- const { keyConditions, filterConditions } = separateConditions(
262
- state.condition,
263
- state.model,
264
- state.indexName
265
- );
266
-
267
- // DynamoDB Query requires at least a partition-key condition. If
268
- // separation didn't pick anything as a key condition, the caller is
269
- // either filtering on non-key attributes (which means they want
270
- // scan()) or referencing the wrong attribute name. Fail loudly here
271
- // instead of letting the SDK reject the request at execute time.
272
- if (keyConditions.length === 0) {
273
- const keyTemplates = state.indexName
274
- ? Object.entries(state.model?.index ?? {})
275
- .filter(([keyName]) => keyName.startsWith(state.indexName!))
276
- .map(([, def]) => def.value)
277
- : Object.values(state.model?.key ?? {}).map((def) => def.value);
278
- const templateVars = Array.from(
279
- new Set(keyTemplates.flatMap((tpl) => extractTemplateVars(tpl)))
280
- );
281
- const indexHint = state.indexName ? ` on index "${state.indexName}"` : '';
282
- const fieldsHint =
283
- templateVars.length > 0
284
- ? ` Expected a condition on one of: ${templateVars.join(', ')}.`
285
- : '';
286
- throw new Error(
287
- `Query requires a condition on the partition key${indexHint}.${fieldsHint} ` +
288
- `For non-key filtering use scan() instead.`
289
- );
290
- }
291
-
292
- // Build KeyConditionExpression (simple AND)
293
- const keyExpr = buildSimpleAndExpression(keyConditions);
294
-
295
- // Auto-inject an entity-type filter so query() only returns items
296
- // belonging to this entity (single-table designs share PKs across
297
- // entities, especially on GSIs).
298
- const allFilters = [...filterConditions];
299
- if (state.entityType) {
300
- allFilters.push({
301
- expression: '#_type = :_type',
302
- names: { '#_type': '_type' },
303
- values: { ':_type': state.entityType },
304
- });
305
- }
306
-
307
- // Build FilterExpression (can be complex with OR, NOT, etc.)
308
- let filterExpr = { expression: '', names: {}, values: {} };
309
- if (allFilters.length > 0) {
310
- if (allFilters.length === 1 && allFilters[0]) {
311
- filterExpr = buildExpression(allFilters[0]);
312
- } else {
313
- // Multiple filter conditions - combine with AND
314
- filterExpr = buildExpression({
315
- expression: '',
316
- operator: 'AND',
317
- children: allFilters,
318
- });
319
- }
320
- }
321
-
322
- // Merge attribute names and values from both expressions
323
- const allNames: Record<string, string> = {
324
- ...(keyExpr.names ?? {}),
325
- ...(filterExpr.names ?? {}),
326
- };
327
- const allValues = {
328
- ...(keyExpr.values ?? {}),
329
- ...(filterExpr.values ?? {}),
330
- };
331
-
332
- // Build ProjectionExpression with placeholders so reserved words
333
- // (name, date, status, type, …) don't blow up at DynamoDB.
334
- // Idempotent merge: filter/key and projection placeholders both map
335
- // `#name → name`, so collisions resolve to the same value.
336
- let projectionExpression: string | undefined;
337
- if (state.projection && state.projection.length > 0) {
338
- const proj = buildProjectionExpression(
339
- (state.projection as (keyof Model)[]).map((a) => String(a))
340
- );
341
- projectionExpression = proj.ProjectionExpression;
342
- Object.assign(allNames, proj.ExpressionAttributeNames);
343
- }
344
-
345
- return {
346
- TableName: state.tableName,
347
- ...(keyExpr.expression && {
348
- KeyConditionExpression: keyExpr.expression,
349
- }),
350
- ...(filterExpr.expression && {
351
- FilterExpression: filterExpr.expression,
352
- }),
353
- ...(Object.keys(allNames).length && {
354
- ExpressionAttributeNames: allNames,
355
- }),
356
- ...(Object.keys(allValues).length && {
357
- ExpressionAttributeValues: allValues,
358
- }),
359
- ...(state.indexName && { IndexName: state.indexName }),
360
- ...(state.limitValue && { Limit: state.limitValue }),
361
- ...(state.scanForward !== undefined && {
362
- ScanIndexForward: state.scanForward,
363
- }),
364
- ...(projectionExpression && {
365
- ProjectionExpression: projectionExpression,
366
- }),
367
- ...(state.consistentReadEnabled && { ConsistentRead: true }),
368
- ...(state.exclusiveStartKey && {
369
- ExclusiveStartKey: state.exclusiveStartKey,
370
- }),
371
- };
372
- },
373
-
374
- /**
375
- * ⚠️ Returns only the FIRST page of results. DynamoDB caps each Query
376
- * response at ~1MB (or `Limit` if set). If the matching set is larger,
377
- * the remaining items are silently dropped.
378
- *
379
- * Use {@link executeWithPagination} when you need to drive pagination
380
- * yourself, or {@link iterate} to walk every matching item lazily.
381
- */
382
- async execute(): Promise<Model[]> {
383
- const params = this.dbParams();
384
- const result = await state.client.send(new QueryCommand(params));
385
- state.logger?.log('QueryCommand', params, result);
386
- return (result.Items ?? []) as unknown as Model[];
387
- },
388
-
389
- async executeWithPagination(): Promise<QueryResult<Model>> {
390
- const params = this.dbParams();
391
- const result = await state.client.send(new QueryCommand(params));
392
- state.logger?.log('QueryCommand', params, result);
393
- return {
394
- items: (result.Items ?? []) as unknown as Model[],
395
- lastEvaluatedKey: result.LastEvaluatedKey,
396
- count: result.Count,
397
- scannedCount: result.ScannedCount,
398
- };
399
- },
400
-
401
- async *iterate(): AsyncIterableIterator<Model> {
402
- const baseParams = this.dbParams();
403
- let cursor: Record<string, any> | undefined = baseParams.ExclusiveStartKey;
404
- do {
405
- const params = { ...baseParams, ExclusiveStartKey: cursor };
406
- const result = await state.client.send(new QueryCommand(params));
407
- state.logger?.log('QueryCommand', params, result);
408
- for (const item of (result.Items ?? []) as unknown as Model[]) {
409
- yield item;
410
- }
411
- cursor = result.LastEvaluatedKey;
412
- } while (cursor);
413
- },
414
- };
415
- }
416
-
417
- /**
418
- * Creates a QueryBuilder for a table
419
- */
420
- export function createQueryBuilder<Model>(
421
- tableName: string,
422
- client: DynamoDBClient,
423
- model?: ModelDefinition,
424
- logger?: DynamoDBLogger,
425
- entityType?: string
426
- ): QueryBuilder<Model> {
427
- return {
428
- where(fn) {
429
- // Create attribute builder proxy
430
- const attrProxy = new Proxy({} as AttrBuilder<Model>, {
431
- get(_, prop: string): AttrRef {
432
- return { name: prop };
433
- },
434
- });
435
-
436
- // Create a scoped opBuilder for this query to avoid global state
437
- const opBuilder = createOpBuilder();
438
-
439
- // Call the user's function to get the condition tree
440
- const condition = fn(attrProxy, opBuilder);
441
-
442
- // Create initial state with the condition
443
- const initialState: QueryState<Model> = {
444
- tableName,
445
- client,
446
- model,
447
- condition,
448
- logger,
449
- entityType,
450
- };
451
-
452
- return createQueryExecutor(initialState);
453
- },
454
- };
455
- }
@@ -1,2 +0,0 @@
1
- export * from './types';
2
- export * from './create-query-builder';
@@ -1,109 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { ExecutableBuilder, AttrBuilder, OpBuilder, Condition } from '../shared';
3
-
4
- /**
5
- * Query result with pagination support
6
- */
7
- export interface QueryResult<Model> {
8
- /**
9
- * The items returned by the query
10
- */
11
- items: Model[];
12
-
13
- /**
14
- * The LastEvaluatedKey for pagination (if more results available)
15
- */
16
- lastEvaluatedKey?: Record<string, any>;
17
-
18
- /**
19
- * Number of items examined before applying filters
20
- */
21
- count?: number;
22
-
23
- /**
24
- * Number of items returned after applying filters
25
- */
26
- scannedCount?: number;
27
- }
28
-
29
- /**
30
- * Query executor with additional query-specific options
31
- */
32
- export interface QueryExecutor<Model> extends ExecutableBuilder<Model[]> {
33
- /**
34
- * Limit the number of items to return
35
- */
36
- limit(count: number): QueryExecutor<Model>;
37
-
38
- /**
39
- * Scan index forward (ascending) or backward (descending)
40
- */
41
- scanIndexForward(forward: boolean): QueryExecutor<Model>;
42
-
43
- /**
44
- * Use a secondary index
45
- */
46
- useIndex(indexName: string): QueryExecutor<Model>;
47
-
48
- /**
49
- * Select specific attributes to return
50
- */
51
- select(attrs: (keyof Model)[]): QueryExecutor<Model>;
52
-
53
- /**
54
- * Use consistent read
55
- */
56
- consistentRead(): QueryExecutor<Model>;
57
-
58
- /**
59
- * Start query from a specific key (for pagination)
60
- */
61
- startFrom(key: Record<string, any>): QueryExecutor<Model>;
62
-
63
- /**
64
- * Returns the raw DynamoDB query parameters
65
- */
66
- dbParams(): any;
67
-
68
- /**
69
- * Executes the query and returns matching items
70
- */
71
- execute(): Promise<Model[]>;
72
-
73
- /**
74
- * Executes the query and returns result with pagination metadata
75
- */
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>;
94
- }
95
-
96
- /**
97
- * Main Query Builder interface with type-safe where clause
98
- */
99
- export interface QueryBuilder<Model> {
100
- /**
101
- * Build a condition expression using attributes and operators
102
- * Usage: .where((attr, op) => op.eq(attr.username, 'juanca'))
103
- * Usage: .where((attr, op) => op.and(
104
- * op.eq(attr.username, 'juanca'),
105
- * op.gt(attr.age, 18)
106
- * ))
107
- */
108
- where(fn: (attr: AttrBuilder<Model>, op: OpBuilder) => Condition): QueryExecutor<Model>;
109
- }