dynamo-query-engine 1.0.0 → 1.0.1

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.
package/README.md ADDED
@@ -0,0 +1,392 @@
1
+ # DynamoDB Query Engine
2
+
3
+ Type-safe DynamoDB query builder for GraphQL with support for filtering, sorting, pagination, and relation expansion. Built on top of Dynamoose for seamless integration with AWS Lambda and serverless applications.
4
+
5
+ ## Features
6
+
7
+ - **DynamoDB-native**: Only keys can be filtered/sorted (following DynamoDB best practices)
8
+ - **Cursor-based pagination**: Efficient pagination with query hash validation
9
+ - **Type-safe**: JSDoc type definitions for excellent IntelliSense support
10
+ - **Expand support**: Declarative relation expansion with configurable limits
11
+ - **Zero external config**: All metadata defined in your Dynamoose schemas
12
+ - **AWS Lambda ready**: Optimized for serverless environments
13
+ - **GraphQL integration**: Works seamlessly with GraphQL resolvers
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install dynamo-query-engine
19
+ ```
20
+
21
+ ### Peer Dependencies
22
+
23
+ ```bash
24
+ npm install dynamoose graphql graphql-parse-resolve-info
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ### 1. Define Models with Grid Configuration
30
+
31
+ ```javascript
32
+ import dynamoose from "dynamoose";
33
+
34
+ const UserSchema = new dynamoose.Schema({
35
+ pk: {
36
+ type: String,
37
+ hashKey: true,
38
+ },
39
+ createdAt: {
40
+ type: String,
41
+ rangeKey: true,
42
+ grid: {
43
+ sort: { type: "key" },
44
+ filter: {
45
+ type: "key",
46
+ operators: ["between", "gte", "lte"],
47
+ },
48
+ },
49
+ },
50
+ status: {
51
+ type: String,
52
+ index: {
53
+ name: "status-createdAt-index",
54
+ global: true,
55
+ rangeKey: "createdAt",
56
+ },
57
+ grid: {
58
+ filter: {
59
+ type: "key",
60
+ operators: ["eq"],
61
+ index: "status-createdAt-index",
62
+ },
63
+ },
64
+ },
65
+ teamIds: {
66
+ type: Array,
67
+ schema: [String],
68
+ grid: {
69
+ expand: {
70
+ type: "Team",
71
+ relation: "MANY",
72
+ defaultLimit: 5,
73
+ maxLimit: 20,
74
+ },
75
+ },
76
+ },
77
+ });
78
+
79
+ export const UserModel = dynamoose.model("Users", UserSchema);
80
+ ```
81
+
82
+ ### 2. Register Models
83
+
84
+ ```javascript
85
+ import { modelRegistry } from "dynamo-query-engine";
86
+ import { UserModel } from "./models/user.model.js";
87
+ import { TeamModel } from "./models/team.model.js";
88
+
89
+ modelRegistry.register("User", UserModel);
90
+ modelRegistry.register("Team", TeamModel);
91
+ ```
92
+
93
+ ### 3. Use in Lambda Resolver
94
+
95
+ ```javascript
96
+ import {
97
+ buildGridQuery,
98
+ resolveExpands,
99
+ encodeCursor
100
+ } from "dynamo-query-engine";
101
+ import { parseResolveInfo } from "graphql-parse-resolve-info";
102
+
103
+ export const handler = async (event) => {
104
+ const args = event.arguments;
105
+
106
+ // Build query
107
+ const { query, queryHash } = buildGridQuery({
108
+ model: UserModel,
109
+ partitionKeyValue: "ORG#123",
110
+ filterModel: args.filterModel,
111
+ sortModel: args.sortModel,
112
+ paginationModel: args.paginationModel,
113
+ cursor: args.cursor,
114
+ });
115
+
116
+ // Execute
117
+ const users = await query.exec();
118
+
119
+ // Resolve relations
120
+ const parsed = parseResolveInfo(event.info);
121
+ await resolveExpands(users, UserModel, parsed?.fieldsByTypeName);
122
+
123
+ // Return with cursor
124
+ return {
125
+ rows: users,
126
+ nextCursor: users.lastKey
127
+ ? encodeCursor(users.lastKey, queryHash)
128
+ : null,
129
+ };
130
+ };
131
+ ```
132
+
133
+ ### 4. Query from Client
134
+
135
+ ```graphql
136
+ query UsersGrid(
137
+ $paginationModel: PaginationInput!
138
+ $sortModel: [SortInput!]
139
+ $filterModel: FilterModelInput
140
+ $cursor: String
141
+ ) {
142
+ usersGrid(
143
+ paginationModel: $paginationModel
144
+ sortModel: $sortModel
145
+ filterModel: $filterModel
146
+ cursor: $cursor
147
+ ) {
148
+ rows {
149
+ firstName
150
+ lastName
151
+ email
152
+ teamIds {
153
+ name
154
+ status
155
+ }
156
+ }
157
+ nextCursor
158
+ }
159
+ }
160
+ ```
161
+
162
+ ```javascript
163
+ {
164
+ paginationModel: { pageSize: 20 },
165
+ sortModel: [{ field: "createdAt", sort: "desc" }],
166
+ filterModel: {
167
+ items: [
168
+ { field: "status", operator: "eq", value: "active" }
169
+ ]
170
+ }
171
+ }
172
+ ```
173
+
174
+ ## Grid Configuration Options
175
+
176
+ ### Filter Configuration
177
+
178
+ ```javascript
179
+ grid: {
180
+ filter: {
181
+ type: "key", // Only "key" supported
182
+ operators: ["eq", "gte", "lte"], // Allowed operators
183
+ index: "status-index" // Optional GSI
184
+ }
185
+ }
186
+ ```
187
+
188
+ **Supported Operators**: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `beginsWith`, `contains`
189
+
190
+ ### Sort Configuration
191
+
192
+ ```javascript
193
+ grid: {
194
+ sort: { type: "key" } // Only range keys can be sorted
195
+ }
196
+ ```
197
+
198
+ > **Note**: DynamoDB only supports sorting by one field (the range key).
199
+
200
+ ### Expand Configuration
201
+
202
+ ```javascript
203
+ grid: {
204
+ expand: {
205
+ type: "Team", // Model name (must be registered)
206
+ relation: "MANY", // "ONE" or "MANY"
207
+ defaultLimit: 5, // Default fetch limit
208
+ maxLimit: 20 // Maximum allowed limit
209
+ }
210
+ }
211
+ ```
212
+
213
+ ## API Reference
214
+
215
+ ### Core Functions
216
+
217
+ #### `buildGridQuery(options)`
218
+
219
+ Builds a DynamoDB query from grid parameters.
220
+
221
+ **Parameters:**
222
+ - `model` - Dynamoose model
223
+ - `partitionKeyValue` - Partition key value
224
+ - `filterModel` - Filter configuration (optional)
225
+ - `sortModel` - Sort configuration (optional)
226
+ - `paginationModel` - Pagination configuration (required)
227
+ - `cursor` - Pagination cursor (optional)
228
+
229
+ **Returns:** `{ query, queryHash }`
230
+
231
+ #### `resolveExpand(options)`
232
+
233
+ Resolves a single relation expansion.
234
+
235
+ **Parameters:**
236
+ - `parentItems` - Array of parent items
237
+ - `parentModel` - Parent Dynamoose model
238
+ - `field` - Field name to expand
239
+ - `args` - Expansion arguments (filter, sort, pagination)
240
+
241
+ #### `resolveExpands(parentItems, parentModel, fieldsByTypeName)`
242
+
243
+ Resolves multiple expansions from GraphQL resolve info.
244
+
245
+ **Parameters:**
246
+ - `parentItems` - Array of parent items
247
+ - `parentModel` - Parent Dynamoose model
248
+ - `fieldsByTypeName` - Parsed GraphQL resolve info fields
249
+
250
+ ### Utilities
251
+
252
+ #### `encodeCursor(lastKey, queryHash)`
253
+
254
+ Encodes pagination cursor to Base64.
255
+
256
+ #### `decodeCursor(cursor)`
257
+
258
+ Decodes Base64 cursor.
259
+
260
+ #### `modelRegistry.register(name, model)`
261
+
262
+ Registers a Dynamoose model.
263
+
264
+ #### `modelRegistry.get(name)`
265
+
266
+ Retrieves a registered model.
267
+
268
+ ## Architecture
269
+
270
+ ```
271
+ ┌─────────────┐
272
+ │ GraphQL │
273
+ │ Query │
274
+ └──────┬──────┘
275
+
276
+
277
+ ┌─────────────┐
278
+ │ Lambda │
279
+ │ Resolver │
280
+ └──────┬──────┘
281
+
282
+ ├──────► buildGridQuery() ──► DynamoDB
283
+ │ │
284
+ │ ▼
285
+ │ ┌──────┐
286
+ │ │ Items│
287
+ │ └───┬──┘
288
+ │ │
289
+ └──────► resolveExpands() ◄────┘
290
+
291
+ ├──► ModelRegistry
292
+
293
+ └──► buildGridQuery() ──► DynamoDB (nested)
294
+ ```
295
+
296
+ ## Examples
297
+
298
+ Check out the [examples directory](./examples) for complete working examples:
299
+
300
+ - **[User Model](./examples/models/user.model.js)** - Model with expand configuration
301
+ - **[Team Model](./examples/models/team.model.js)** - Model with filter/sort configuration
302
+ - **[Lambda Resolver](./examples/resolvers/usersGrid.resolver.js)** - Complete resolver implementation
303
+ - **[GraphQL Queries](./examples/queries/graphql.js)** - Query examples and variables
304
+
305
+ See the [examples README](./examples/README.md) for detailed documentation.
306
+
307
+ ## Best Practices
308
+
309
+ ### 1. Use Global Secondary Indexes for Filters
310
+
311
+ ```javascript
312
+ status: {
313
+ type: String,
314
+ index: {
315
+ name: "status-createdAt-index",
316
+ global: true,
317
+ rangeKey: "createdAt",
318
+ },
319
+ grid: {
320
+ filter: {
321
+ type: "key",
322
+ operators: ["eq"],
323
+ index: "status-createdAt-index", // Use the GSI
324
+ },
325
+ },
326
+ }
327
+ ```
328
+
329
+ ### 2. Validate User Input
330
+
331
+ ```javascript
332
+ if (args.paginationModel.pageSize > 100) {
333
+ throw new Error("Page size cannot exceed 100");
334
+ }
335
+ ```
336
+
337
+ ### 3. Handle Errors Gracefully
338
+
339
+ ```javascript
340
+ try {
341
+ const { query } = buildGridQuery({...});
342
+ return await query.exec();
343
+ } catch (error) {
344
+ console.error("Query failed:", error);
345
+ throw new Error(`Failed to fetch data: ${error.message}`);
346
+ }
347
+ ```
348
+
349
+ ### 4. Limit Expand Depth
350
+
351
+ Don't allow deeply nested expansions as they can cause performance issues and high DynamoDB costs.
352
+
353
+ ### 5. Monitor DynamoDB Costs
354
+
355
+ Each expand creates additional queries. Use CloudWatch to monitor your read capacity units.
356
+
357
+ ## DynamoDB Limitations
358
+
359
+ This library respects DynamoDB's constraints:
360
+
361
+ - **Only key attributes** (hash key, range key, or GSI keys) can be filtered
362
+ - **Only range keys** can be sorted
363
+ - **Only one sort field** is allowed per query
364
+ - **No attribute-level filters** (use scan sparingly for those cases)
365
+
366
+ These limitations ensure efficient queries and predictable performance.
367
+
368
+ ## Error Handling
369
+
370
+ The library provides descriptive errors:
371
+
372
+ - `"Filtering not allowed on field 'X'"` - Field doesn't have filter config
373
+ - `"Sorting not allowed on field 'X'"` - Field doesn't have sort config
374
+ - `"Operator 'X' not allowed for field 'Y'"` - Operator not in allowed list
375
+ - `"Only one sort field supported"` - Multiple sort fields specified
376
+ - `"Cursor does not match query"` - Query params changed between pages
377
+ - `"Model 'X' not found in registry"` - Model not registered
378
+
379
+ ## Contributing
380
+
381
+ Contributions are welcome! Please open an issue or pull request.
382
+
383
+ ## License
384
+
385
+ MIT © Sascha Eckstein
386
+
387
+ ## Links
388
+
389
+ - [GitHub Repository](https://github.com/saschaeckstein/dynamo-query-engine)
390
+ - [Examples](./examples)
391
+ - [Dynamoose Documentation](https://dynamoosejs.com/)
392
+ - [AWS DynamoDB Best Practices](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/best-practices.html)
package/index.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @fileoverview DynamoDB Query Engine - Main entry point
3
+ * Type-safe DynamoDB query builder for GraphQL with support for
4
+ * filtering, sorting, pagination, and relation expansion
5
+ */
6
+
7
+ // Core functionality
8
+ export { modelRegistry, ModelRegistry } from "./src/core/modelRegistry.js";
9
+ export {
10
+ buildGridQuery,
11
+ extractGridConfig,
12
+ } from "./src/core/gridQueryBuilder.js";
13
+ export {
14
+ resolveExpand,
15
+ resolveExpands,
16
+ extractExpandPolicy,
17
+ } from "./src/core/expandResolver.js";
18
+
19
+ // Utilities
20
+ export { encodeCursor, decodeCursor, validateCursor } from "./src/utils/cursor.js";
21
+ export {
22
+ validateSingleSort,
23
+ validateFilterField,
24
+ validateSortField,
25
+ validateFilterOperator,
26
+ validateExpandField,
27
+ validatePaginationModel,
28
+ } from "./src/utils/validation.js";
29
+
30
+ // Types (for JSDoc imports)
31
+ export {} from "./src/types/jsdoc.js";
package/package.json CHANGED
@@ -1,12 +1,45 @@
1
1
  {
2
2
  "name": "dynamo-query-engine",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
+ "description": "Type-safe DynamoDB query builder for GraphQL with support for filtering, sorting, pagination, and relation expansion",
4
5
  "type": "module",
5
6
  "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./core": "./src/core/index.js",
10
+ "./utils": "./src/utils/index.js"
11
+ },
6
12
  "scripts": {
7
13
  "test": "echo \"Error: no test specified\" && exit 1"
8
14
  },
9
- "author": "",
15
+ "keywords": [
16
+ "dynamodb",
17
+ "graphql",
18
+ "query-builder",
19
+ "dynamoose",
20
+ "pagination",
21
+ "filter",
22
+ "sort",
23
+ "expand",
24
+ "relations",
25
+ "aws",
26
+ "serverless",
27
+ "lambda"
28
+ ],
29
+ "author": "Sascha Eckstein",
10
30
  "license": "MIT",
11
- "description": ""
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/saschaeckstein/dynamo-query-engine"
34
+ },
35
+ "peerDependencies": {
36
+ "dynamoose": "^4.0.0",
37
+ "graphql": "^16.0.0"
38
+ },
39
+ "dependencies": {
40
+ "graphql-parse-resolve-info": "^4.13.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=18.0.0"
44
+ }
12
45
  }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @fileoverview Expand resolver for handling relations/expansions
3
+ */
4
+
5
+ import "../types/jsdoc.js";
6
+ import { modelRegistry } from "./modelRegistry.js";
7
+ import { buildGridQuery } from "./gridQueryBuilder.js";
8
+ import { validateExpandField } from "../utils/validation.js";
9
+ import { extractGridConfig } from "./gridQueryBuilder.js";
10
+
11
+ /**
12
+ * Extract expand policy from schema
13
+ * @param {*} schema - Dynamoose schema
14
+ * @returns {Object.<string, ExpandConfig>} Expand policies by field name
15
+ */
16
+ export function extractExpandPolicy(schema) {
17
+ const gridConfig = extractGridConfig(schema);
18
+ const expandPolicy = {};
19
+
20
+ for (const [field, config] of Object.entries(gridConfig)) {
21
+ if (config.expand) {
22
+ expandPolicy[field] = config.expand;
23
+ }
24
+ }
25
+
26
+ return expandPolicy;
27
+ }
28
+
29
+ /**
30
+ * Resolve expand/relations for a field
31
+ * @param {ResolveExpandOptions} options - Resolve expand options
32
+ * @returns {Promise<void>}
33
+ */
34
+ export async function resolveExpand({
35
+ parentItems,
36
+ parentModel,
37
+ field,
38
+ args = {},
39
+ }) {
40
+ if (!parentItems || parentItems.length === 0) {
41
+ return;
42
+ }
43
+
44
+ if (!parentModel) {
45
+ throw new Error("parentModel is required");
46
+ }
47
+
48
+ if (!field) {
49
+ throw new Error("field is required");
50
+ }
51
+
52
+ // Extract expand policy from parent schema
53
+ const expandPolicy = extractExpandPolicy(parentModel.schema);
54
+ const policy = expandPolicy[field];
55
+
56
+ if (!policy) {
57
+ throw new Error(
58
+ `Field '${field}' does not have expand configuration in parent model`
59
+ );
60
+ }
61
+
62
+ // Get target model from registry
63
+ const targetModel = modelRegistry.get(policy.type);
64
+
65
+ // Determine limit based on args and policy
66
+ const requestedLimit = args.pagination?.pageSize ?? policy.defaultLimit;
67
+ const limit = Math.min(requestedLimit, policy.maxLimit);
68
+
69
+ // Resolve relations for each parent item
70
+ const expandPromises = parentItems.map(async (parent) => {
71
+ try {
72
+ // For MANY relations, we need a partition key value
73
+ // This should be either parent.pk or a specific field based on the relation
74
+ const partitionKeyValue = parent.pk || parent[Object.keys(parent)[0]];
75
+
76
+ if (!partitionKeyValue) {
77
+ console.warn(
78
+ `Cannot expand '${field}' for parent: missing partition key value`
79
+ );
80
+ parent[field] = [];
81
+ return;
82
+ }
83
+
84
+ // Build query for expanded items
85
+ const { query } = buildGridQuery({
86
+ model: targetModel,
87
+ partitionKeyValue,
88
+ filterModel: args.filter,
89
+ sortModel: args.sort,
90
+ paginationModel: { pageSize: limit },
91
+ // No cursor for nested expands
92
+ });
93
+
94
+ // Execute query
95
+ const results = await query.exec();
96
+
97
+ // Assign results to parent
98
+ if (policy.relation === "ONE") {
99
+ parent[field] = results.length > 0 ? results[0] : null;
100
+ } else {
101
+ parent[field] = results;
102
+ }
103
+ } catch (error) {
104
+ console.error(`Error expanding '${field}' for parent:`, error);
105
+ // Gracefully handle errors - set empty result
106
+ parent[field] = policy.relation === "ONE" ? null : [];
107
+ }
108
+ });
109
+
110
+ // Execute all expands in parallel for better performance
111
+ await Promise.all(expandPromises);
112
+ }
113
+
114
+ /**
115
+ * Resolve multiple expands from GraphQL resolve info
116
+ * @param {Array} parentItems - Parent items to expand
117
+ * @param {*} parentModel - Parent Dynamoose model
118
+ * @param {Object} fieldsByTypeName - Fields by type name from graphql-parse-resolve-info
119
+ * @returns {Promise<void>}
120
+ */
121
+ export async function resolveExpands(
122
+ parentItems,
123
+ parentModel,
124
+ fieldsByTypeName
125
+ ) {
126
+ if (!fieldsByTypeName || Object.keys(fieldsByTypeName).length === 0) {
127
+ return;
128
+ }
129
+
130
+ // Get fields for the parent type
131
+ const typeName = Object.keys(fieldsByTypeName)[0];
132
+ const fields = fieldsByTypeName[typeName];
133
+
134
+ if (!fields) {
135
+ return;
136
+ }
137
+
138
+ // Get expand policy to filter only expandable fields
139
+ const expandPolicy = extractExpandPolicy(parentModel.schema);
140
+
141
+ // Resolve all expands in parallel
142
+ const expandPromises = Object.entries(fields)
143
+ .filter(([fieldName]) => expandPolicy[fieldName])
144
+ .map(([fieldName, fieldInfo]) =>
145
+ resolveExpand({
146
+ parentItems,
147
+ parentModel,
148
+ field: fieldName,
149
+ args: fieldInfo.args || {},
150
+ })
151
+ );
152
+
153
+ await Promise.all(expandPromises);
154
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @fileoverview Grid query builder for DynamoDB queries with filtering, sorting, and pagination
3
+ */
4
+
5
+ import crypto from "crypto";
6
+ import "../types/jsdoc.js";
7
+ import {
8
+ validateSingleSort,
9
+ validateFilterField,
10
+ validateSortField,
11
+ validateFilterOperator,
12
+ validatePaginationModel,
13
+ } from "../utils/validation.js";
14
+ import { decodeCursor, validateCursor } from "../utils/cursor.js";
15
+
16
+ /**
17
+ * Generate a hash for query parameters to validate cursor consistency
18
+ * @param {Object} params - Query parameters
19
+ * @param {FilterModel} params.filterModel - Filter model
20
+ * @param {SortModel} params.sortModel - Sort model
21
+ * @param {number} params.pageSize - Page size
22
+ * @returns {string} SHA256 hash of the query parameters
23
+ */
24
+ function hashQuery({ filterModel, sortModel, pageSize }) {
25
+ return crypto
26
+ .createHash("sha256")
27
+ .update(JSON.stringify({ filterModel, sortModel, pageSize }))
28
+ .digest("hex");
29
+ }
30
+
31
+ /**
32
+ * Extract grid configuration from Dynamoose schema
33
+ * @param {*} schema - Dynamoose schema
34
+ * @returns {ExtractedGridConfig} Extracted grid configuration
35
+ */
36
+ export function extractGridConfig(schema) {
37
+ const attributes = schema.getAttributes();
38
+ const gridConfig = {};
39
+
40
+ for (const [field, definition] of Object.entries(attributes)) {
41
+ if (definition.grid) {
42
+ gridConfig[field] = definition.grid;
43
+ }
44
+ }
45
+
46
+ return gridConfig;
47
+ }
48
+
49
+ /**
50
+ * Determine which GSI to use based on filter model
51
+ * @param {FilterModel} filterModel - Filter model
52
+ * @param {ExtractedGridConfig} gridConfig - Grid configuration
53
+ * @returns {string|null} GSI name or null
54
+ */
55
+ function determineIndex(filterModel, gridConfig) {
56
+ if (!filterModel || !filterModel.items || filterModel.items.length === 0) {
57
+ return null;
58
+ }
59
+
60
+ // Find the first filter with an index
61
+ for (const filterItem of filterModel.items) {
62
+ const fieldConfig = gridConfig[filterItem.field]?.filter;
63
+ if (fieldConfig && fieldConfig.index) {
64
+ return fieldConfig.index;
65
+ }
66
+ }
67
+
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Apply filters to the query
73
+ * @param {*} query - Dynamoose query object
74
+ * @param {FilterModel} filterModel - Filter model
75
+ * @param {ExtractedGridConfig} gridConfig - Grid configuration
76
+ * @returns {*} Updated query object
77
+ */
78
+ function applyFilters(query, filterModel, gridConfig) {
79
+ if (!filterModel || !filterModel.items || filterModel.items.length === 0) {
80
+ return query;
81
+ }
82
+
83
+ for (const filterItem of filterModel.items) {
84
+ validateFilterField(filterItem.field, gridConfig);
85
+ validateFilterOperator(filterItem.field, filterItem.operator, gridConfig);
86
+
87
+ // Apply the filter using Dynamoose query syntax
88
+ query = query.where(filterItem.field);
89
+
90
+ // Map operators to Dynamoose methods
91
+ switch (filterItem.operator) {
92
+ case "eq":
93
+ query = query.eq(filterItem.value);
94
+ break;
95
+ case "ne":
96
+ query = query.ne(filterItem.value);
97
+ break;
98
+ case "gt":
99
+ query = query.gt(filterItem.value);
100
+ break;
101
+ case "gte":
102
+ query = query.ge(filterItem.value);
103
+ break;
104
+ case "lt":
105
+ query = query.lt(filterItem.value);
106
+ break;
107
+ case "lte":
108
+ query = query.le(filterItem.value);
109
+ break;
110
+ case "between":
111
+ if (!Array.isArray(filterItem.value) || filterItem.value.length !== 2) {
112
+ throw new Error(
113
+ `'between' operator requires an array of two values for field '${filterItem.field}'`
114
+ );
115
+ }
116
+ query = query.between(filterItem.value[0], filterItem.value[1]);
117
+ break;
118
+ case "beginsWith":
119
+ query = query.beginsWith(filterItem.value);
120
+ break;
121
+ case "contains":
122
+ query = query.contains(filterItem.value);
123
+ break;
124
+ default:
125
+ throw new Error(
126
+ `Unsupported operator '${filterItem.operator}' for field '${filterItem.field}'`
127
+ );
128
+ }
129
+ }
130
+
131
+ return query;
132
+ }
133
+
134
+ /**
135
+ * Apply sorting to the query
136
+ * @param {*} query - Dynamoose query object
137
+ * @param {SortModel} sortModel - Sort model
138
+ * @param {ExtractedGridConfig} gridConfig - Grid configuration
139
+ * @returns {*} Updated query object
140
+ */
141
+ function applySort(query, sortModel, gridConfig) {
142
+ if (!sortModel || sortModel.length === 0) {
143
+ return query;
144
+ }
145
+
146
+ const sortItem = sortModel[0];
147
+ validateSortField(sortItem.field, gridConfig);
148
+
149
+ // Apply descending sort if specified
150
+ if (sortItem.sort === "desc") {
151
+ query = query.sort("descending");
152
+ }
153
+ // Default is ascending, no need to explicitly set
154
+
155
+ return query;
156
+ }
157
+
158
+ /**
159
+ * Build a DynamoDB query from grid parameters
160
+ * @param {BuildGridQueryOptions} options - Query build options
161
+ * @returns {QueryBuildResult} Query build result with query and hash
162
+ */
163
+ export function buildGridQuery({
164
+ model,
165
+ partitionKeyValue,
166
+ filterModel,
167
+ sortModel,
168
+ paginationModel,
169
+ cursor,
170
+ }) {
171
+ // Validate inputs
172
+ if (!model) {
173
+ throw new Error("model is required");
174
+ }
175
+ if (!partitionKeyValue) {
176
+ throw new Error("partitionKeyValue is required");
177
+ }
178
+ validatePaginationModel(paginationModel);
179
+ validateSingleSort(sortModel);
180
+
181
+ // Extract grid config from schema
182
+ const gridConfig = extractGridConfig(model.schema);
183
+
184
+ // Determine which index to use
185
+ const index = determineIndex(filterModel, gridConfig);
186
+
187
+ // Generate query hash for cursor validation
188
+ const queryHash = hashQuery({
189
+ filterModel,
190
+ sortModel,
191
+ pageSize: paginationModel.pageSize,
192
+ });
193
+
194
+ // Initialize query with partition key
195
+ let query = model.query("pk").eq(partitionKeyValue);
196
+
197
+ // Use GSI if needed
198
+ if (index) {
199
+ query = query.using(index);
200
+ }
201
+
202
+ // Apply filters
203
+ query = applyFilters(query, filterModel, gridConfig);
204
+
205
+ // Apply sort
206
+ query = applySort(query, sortModel, gridConfig);
207
+
208
+ // Apply pagination limit
209
+ query = query.limit(paginationModel.pageSize);
210
+
211
+ // Handle cursor for pagination
212
+ if (cursor) {
213
+ const decoded = decodeCursor(cursor);
214
+ validateCursor(decoded.queryHash, queryHash);
215
+ query = query.startAt(decoded.lastKey);
216
+ }
217
+
218
+ return { query, queryHash };
219
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @fileoverview Core module exports
3
+ */
4
+
5
+ export { modelRegistry, ModelRegistry } from "./modelRegistry.js";
6
+ export { buildGridQuery, extractGridConfig } from "./gridQueryBuilder.js";
7
+ export {
8
+ resolveExpand,
9
+ resolveExpands,
10
+ extractExpandPolicy,
11
+ } from "./expandResolver.js";
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @fileoverview Model registry for managing Dynamoose models
3
+ */
4
+
5
+ /**
6
+ * Singleton registry for Dynamoose models
7
+ */
8
+ class ModelRegistry {
9
+ constructor() {
10
+ /** @type {Map<string, *>} */
11
+ this.models = new Map();
12
+ }
13
+
14
+ /**
15
+ * Register a model in the registry
16
+ * @param {string} name - Model name (should match the type in expand config)
17
+ * @param {*} model - Dynamoose model instance
18
+ * @throws {Error} If model name or model is invalid
19
+ */
20
+ register(name, model) {
21
+ if (!name || typeof name !== "string") {
22
+ throw new Error("Model name must be a non-empty string");
23
+ }
24
+
25
+ if (!model) {
26
+ throw new Error("Model is required");
27
+ }
28
+
29
+ if (!model.schema) {
30
+ throw new Error(
31
+ "Invalid model: must be a Dynamoose model with a schema property"
32
+ );
33
+ }
34
+
35
+ if (this.models.has(name)) {
36
+ throw new Error(`Model '${name}' is already registered`);
37
+ }
38
+
39
+ this.models.set(name, model);
40
+ }
41
+
42
+ /**
43
+ * Get a model from the registry
44
+ * @param {string} name - Model name
45
+ * @returns {*} Dynamoose model instance
46
+ * @throws {Error} If model is not found
47
+ */
48
+ get(name) {
49
+ if (!this.models.has(name)) {
50
+ throw new Error(
51
+ `Model '${name}' not found in registry. Did you forget to register it?`
52
+ );
53
+ }
54
+
55
+ return this.models.get(name);
56
+ }
57
+
58
+ /**
59
+ * Check if a model is registered
60
+ * @param {string} name - Model name
61
+ * @returns {boolean} True if model is registered
62
+ */
63
+ has(name) {
64
+ return this.models.has(name);
65
+ }
66
+
67
+ /**
68
+ * Get all registered model names
69
+ * @returns {string[]} Array of model names
70
+ */
71
+ getRegisteredModels() {
72
+ return Array.from(this.models.keys());
73
+ }
74
+
75
+ /**
76
+ * Clear all registered models (useful for testing)
77
+ */
78
+ clear() {
79
+ this.models.clear();
80
+ }
81
+ }
82
+
83
+ // Export singleton instance
84
+ export const modelRegistry = new ModelRegistry();
85
+
86
+ // Also export the class for custom registries if needed
87
+ export { ModelRegistry };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @fileoverview Type definitions for DynamoDB Query Engine
3
+ * These JSDoc types provide IntelliSense support throughout the library
4
+ */
5
+
6
+ /**
7
+ * Filter configuration for a field
8
+ * @typedef {Object} FilterConfig
9
+ * @property {"key"|"attribute"} type - Filter type (only "key" is supported for DynamoDB queries)
10
+ * @property {string[]} operators - Allowed comparison operators (e.g., ["eq", "between", "gte", "lte"])
11
+ * @property {string} [index] - Optional GSI name to use for this filter
12
+ */
13
+
14
+ /**
15
+ * Sort configuration for a field
16
+ * @typedef {Object} SortConfig
17
+ * @property {"key"} type - Sort type (only "key" is supported for DynamoDB)
18
+ */
19
+
20
+ /**
21
+ * Expand/relation configuration for a field
22
+ * @typedef {Object} ExpandConfig
23
+ * @property {string} type - Target model name to expand (must be registered in ModelRegistry)
24
+ * @property {"ONE"|"MANY"} relation - Relation type
25
+ * @property {number} defaultLimit - Default number of items to fetch
26
+ * @property {number} maxLimit - Maximum allowed items to fetch
27
+ */
28
+
29
+ /**
30
+ * Grid configuration for a field
31
+ * @typedef {Object} GridConfig
32
+ * @property {FilterConfig} [filter] - Filter configuration
33
+ * @property {SortConfig} [sort] - Sort configuration
34
+ * @property {ExpandConfig} [expand] - Expand/relation configuration
35
+ */
36
+
37
+ /**
38
+ * Complete grid configuration extracted from schema
39
+ * @typedef {Object.<string, GridConfig>} ExtractedGridConfig
40
+ */
41
+
42
+ /**
43
+ * Single filter item
44
+ * @typedef {Object} FilterItem
45
+ * @property {string} field - Field name to filter on
46
+ * @property {string} operator - Comparison operator (eq, ne, gt, gte, lt, lte, between, contains, etc.)
47
+ * @property {*} value - Filter value
48
+ */
49
+
50
+ /**
51
+ * Filter model following AG Grid format
52
+ * @typedef {Object} FilterModel
53
+ * @property {"AND"|"OR"} [logicOperator] - Logic operator for combining filters
54
+ * @property {FilterItem[]} items - Array of filter items
55
+ */
56
+
57
+ /**
58
+ * Single sort item
59
+ * @typedef {Object} SortItem
60
+ * @property {string} field - Field name to sort by
61
+ * @property {"asc"|"desc"} sort - Sort direction
62
+ */
63
+
64
+ /**
65
+ * Sort model (array of sort items)
66
+ * @typedef {SortItem[]} SortModel
67
+ */
68
+
69
+ /**
70
+ * Pagination model
71
+ * @typedef {Object} PaginationModel
72
+ * @property {number} pageSize - Number of items per page
73
+ * @property {number} [page] - Current page number (not used with cursor pagination)
74
+ */
75
+
76
+ /**
77
+ * Decoded cursor data
78
+ * @typedef {Object} CursorData
79
+ * @property {Object} lastKey - DynamoDB lastKey for pagination
80
+ * @property {string} queryHash - Hash of the query parameters for validation
81
+ */
82
+
83
+ /**
84
+ * Query build result
85
+ * @typedef {Object} QueryBuildResult
86
+ * @property {*} query - Dynamoose query object
87
+ * @property {string} queryHash - Hash of the query parameters
88
+ */
89
+
90
+ /**
91
+ * Expand arguments from GraphQL
92
+ * @typedef {Object} ExpandArgs
93
+ * @property {FilterModel} [filter] - Filter model for expanded items
94
+ * @property {SortModel} [sort] - Sort model for expanded items
95
+ * @property {PaginationModel} [pagination] - Pagination for expanded items
96
+ */
97
+
98
+ /**
99
+ * Query result with pagination
100
+ * @typedef {Object} QueryResult
101
+ * @property {Array} rows - Query result items
102
+ * @property {string|null} nextCursor - Base64 encoded cursor for next page
103
+ */
104
+
105
+ /**
106
+ * Build grid query options
107
+ * @typedef {Object} BuildGridQueryOptions
108
+ * @property {*} model - Dynamoose model
109
+ * @property {string} partitionKeyValue - Value for the partition key
110
+ * @property {FilterModel} [filterModel] - Filter model
111
+ * @property {SortModel} [sortModel] - Sort model
112
+ * @property {PaginationModel} paginationModel - Pagination model
113
+ * @property {string} [cursor] - Cursor for pagination
114
+ */
115
+
116
+ /**
117
+ * Resolve expand options
118
+ * @typedef {Object} ResolveExpandOptions
119
+ * @property {Array} parentItems - Parent items to expand relations for
120
+ * @property {*} parentModel - Dynamoose parent model
121
+ * @property {string} field - Field name to expand
122
+ * @property {ExpandArgs} [args] - Expand arguments from GraphQL
123
+ */
124
+
125
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @fileoverview Cursor encoding/decoding utilities for pagination
3
+ */
4
+
5
+ import "../types/jsdoc.js";
6
+
7
+ /**
8
+ * Encodes cursor data to Base64 string
9
+ * @param {Object} lastKey - DynamoDB lastKey object
10
+ * @param {string} queryHash - Hash of the query parameters
11
+ * @returns {string} Base64 encoded cursor
12
+ */
13
+ export function encodeCursor(lastKey, queryHash) {
14
+ if (!lastKey) {
15
+ throw new Error("lastKey is required to encode cursor");
16
+ }
17
+ if (!queryHash) {
18
+ throw new Error("queryHash is required to encode cursor");
19
+ }
20
+
21
+ const cursorData = {
22
+ lastKey,
23
+ queryHash,
24
+ };
25
+
26
+ return Buffer.from(JSON.stringify(cursorData)).toString("base64");
27
+ }
28
+
29
+ /**
30
+ * Decodes Base64 cursor string to cursor data
31
+ * @param {string} cursor - Base64 encoded cursor
32
+ * @returns {CursorData} Decoded cursor data
33
+ */
34
+ export function decodeCursor(cursor) {
35
+ if (!cursor || typeof cursor !== "string") {
36
+ throw new Error("Invalid cursor: must be a non-empty string");
37
+ }
38
+
39
+ try {
40
+ const decoded = Buffer.from(cursor, "base64").toString("utf-8");
41
+ const cursorData = JSON.parse(decoded);
42
+
43
+ if (!cursorData.lastKey || !cursorData.queryHash) {
44
+ throw new Error("Invalid cursor structure");
45
+ }
46
+
47
+ return cursorData;
48
+ } catch (error) {
49
+ throw new Error(`Failed to decode cursor: ${error.message}`);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Validates that cursor matches the current query
55
+ * @param {string} decodedQueryHash - Query hash from decoded cursor
56
+ * @param {string} currentQueryHash - Hash of current query
57
+ * @throws {Error} If cursor does not match query
58
+ */
59
+ export function validateCursor(decodedQueryHash, currentQueryHash) {
60
+ if (decodedQueryHash !== currentQueryHash) {
61
+ throw new Error(
62
+ "Cursor does not match current query. Filters, sort, or page size have changed."
63
+ );
64
+ }
65
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @fileoverview Utilities module exports
3
+ */
4
+
5
+ export { encodeCursor, decodeCursor, validateCursor } from "./cursor.js";
6
+ export {
7
+ validateSingleSort,
8
+ validateFilterField,
9
+ validateSortField,
10
+ validateFilterOperator,
11
+ validateExpandField,
12
+ validatePaginationModel,
13
+ } from "./validation.js";
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @fileoverview Validation utilities for grid configurations
3
+ */
4
+
5
+ import "../types/jsdoc.js";
6
+
7
+ /**
8
+ * Validates that only one sort field is specified
9
+ * @param {SortModel} sortModel - Sort model to validate
10
+ * @throws {Error} If more than one sort field is specified
11
+ */
12
+ export function validateSingleSort(sortModel) {
13
+ if (sortModel && sortModel.length > 1) {
14
+ throw new Error(
15
+ "DynamoDB only supports sorting by one field (range key). Multiple sort fields are not allowed."
16
+ );
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Validates that a field can be filtered based on grid config
22
+ * @param {string} field - Field name to filter
23
+ * @param {ExtractedGridConfig} gridConfig - Extracted grid configuration
24
+ * @throws {Error} If filtering is not allowed on this field
25
+ */
26
+ export function validateFilterField(field, gridConfig) {
27
+ const config = gridConfig[field];
28
+ if (!config || !config.filter) {
29
+ throw new Error(
30
+ `Filtering not allowed on field '${field}'. Only fields with grid.filter config can be filtered.`
31
+ );
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Validates that a field can be sorted based on grid config
37
+ * @param {string} field - Field name to sort
38
+ * @param {ExtractedGridConfig} gridConfig - Extracted grid configuration
39
+ * @throws {Error} If sorting is not allowed on this field
40
+ */
41
+ export function validateSortField(field, gridConfig) {
42
+ const config = gridConfig[field];
43
+ if (!config || !config.sort) {
44
+ throw new Error(
45
+ `Sorting not allowed on field '${field}'. Only fields with grid.sort config can be sorted.`
46
+ );
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Validates that operator is allowed for a filter field
52
+ * @param {string} field - Field name
53
+ * @param {string} operator - Operator to validate
54
+ * @param {ExtractedGridConfig} gridConfig - Extracted grid configuration
55
+ * @throws {Error} If operator is not allowed for this field
56
+ */
57
+ export function validateFilterOperator(field, operator, gridConfig) {
58
+ const config = gridConfig[field]?.filter;
59
+ if (!config) {
60
+ throw new Error(`Field '${field}' does not have filter configuration`);
61
+ }
62
+
63
+ if (config.operators && !config.operators.includes(operator)) {
64
+ throw new Error(
65
+ `Operator '${operator}' not allowed for field '${field}'. Allowed operators: ${config.operators.join(", ")}`
66
+ );
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Validates that a field can be expanded based on grid config
72
+ * @param {string} field - Field name to expand
73
+ * @param {ExtractedGridConfig} gridConfig - Extracted grid configuration
74
+ * @throws {Error} If expand is not allowed on this field
75
+ */
76
+ export function validateExpandField(field, gridConfig) {
77
+ const config = gridConfig[field];
78
+ if (!config || !config.expand) {
79
+ throw new Error(
80
+ `Expand not allowed on field '${field}'. Only fields with grid.expand config can be expanded.`
81
+ );
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Validates pagination model
87
+ * @param {PaginationModel} paginationModel - Pagination model to validate
88
+ * @throws {Error} If pagination model is invalid
89
+ */
90
+ export function validatePaginationModel(paginationModel) {
91
+ if (!paginationModel) {
92
+ throw new Error("paginationModel is required");
93
+ }
94
+
95
+ if (!paginationModel.pageSize || paginationModel.pageSize < 1) {
96
+ throw new Error("paginationModel.pageSize must be a positive number");
97
+ }
98
+
99
+ if (paginationModel.pageSize > 1000) {
100
+ throw new Error(
101
+ "paginationModel.pageSize cannot exceed 1000 (DynamoDB limit)"
102
+ );
103
+ }
104
+ }