dynamodb-reactive 0.1.21 → 0.2.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.
Files changed (46) hide show
  1. package/README.md +378 -153
  2. package/dist/client-r_tnzwqi.d.ts +224 -0
  3. package/dist/client.cjs +1023 -0
  4. package/dist/client.cjs.map +1 -0
  5. package/dist/client.d.cts +6 -0
  6. package/dist/client.d.ts +4 -70
  7. package/dist/client.js +1013 -1
  8. package/dist/client.js.map +1 -1
  9. package/dist/core.cjs +232 -0
  10. package/dist/core.cjs.map +1 -0
  11. package/dist/core.d.cts +4 -0
  12. package/dist/core.d.ts +3 -3
  13. package/dist/core.js +222 -1
  14. package/dist/core.js.map +1 -1
  15. package/dist/index.cjs +232 -0
  16. package/dist/index.cjs.map +1 -0
  17. package/dist/index.d.cts +157 -0
  18. package/dist/index.d.ts +103 -56
  19. package/dist/index.js +222 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/infra.cjs +623 -0
  22. package/dist/infra.cjs.map +1 -0
  23. package/dist/infra.d.cts +119 -0
  24. package/dist/infra.d.ts +50 -102
  25. package/dist/infra.js +371 -144
  26. package/dist/infra.js.map +1 -1
  27. package/dist/react.cjs +1250 -0
  28. package/dist/react.cjs.map +1 -0
  29. package/dist/react.d.cts +68 -0
  30. package/dist/react.d.ts +68 -3
  31. package/dist/react.js +1237 -1
  32. package/dist/react.js.map +1 -1
  33. package/dist/server.cjs +2351 -0
  34. package/dist/server.cjs.map +1 -0
  35. package/dist/server.d.cts +364 -0
  36. package/dist/server.d.ts +58 -313
  37. package/dist/server.js +1855 -1208
  38. package/dist/server.js.map +1 -1
  39. package/dist/{table-tN3D4Tm-.d.ts → table-BijJ73pX.d.ts} +2 -34
  40. package/dist/{types-ISMJfyIv.d.ts → types-LMLk8JVC.d.ts} +56 -30
  41. package/package.json +79 -41
  42. package/dist/chunk-2A7GJ2UO.js +0 -1088
  43. package/dist/chunk-2A7GJ2UO.js.map +0 -1
  44. package/dist/chunk-ZAJA4KBE.js +0 -150
  45. package/dist/chunk-ZAJA4KBE.js.map +0 -1
  46. package/dist/react-B2b5RIz_.d.ts +0 -549
package/dist/server.js CHANGED
@@ -1,10 +1,30 @@
1
- import { SystemTableNames } from './chunk-ZAJA4KBE.js';
2
1
  import { DynamoDBClient, ExecuteStatementCommand } from '@aws-sdk/client-dynamodb';
3
- import { UpdateCommand, DeleteCommand, PutCommand, GetCommand, DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';
2
+ import { GetCommand, PutCommand, DeleteCommand, DynamoDBDocumentClient, UpdateCommand, TransactWriteCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
3
+ import { createHash, randomUUID } from 'crypto';
4
+ import { z } from 'zod';
4
5
  import { unmarshall } from '@aws-sdk/util-dynamodb';
5
- import jsonpatch from 'fast-json-patch';
6
- import { debug, error, warn, info } from 'ag-common/dist/common/helpers/log';
6
+ import { isDeepStrictEqual } from 'util';
7
7
  import { ApiGatewayManagementApiClient, PostToConnectionCommand, GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
8
+ import jsonpatch from 'fast-json-patch';
9
+
10
+ // ../server/src/errors.ts
11
+ var SafeRequestError = class extends Error {
12
+ statusCode;
13
+ constructor(message, statusCode) {
14
+ super(message);
15
+ this.name = "SafeRequestError";
16
+ this.statusCode = statusCode;
17
+ }
18
+ };
19
+ var InvalidInputError = class extends SafeRequestError {
20
+ constructor() {
21
+ super("Invalid input", 400);
22
+ this.name = "InvalidInputError";
23
+ }
24
+ };
25
+ function isSafeRequestError(error2) {
26
+ return error2 instanceof SafeRequestError;
27
+ }
8
28
 
9
29
  // ../server/src/procedure.ts
10
30
  var ProcedureBuilder = class _ProcedureBuilder {
@@ -47,7 +67,7 @@ async function executeProcedure(procedure, ctx, rawInput) {
47
67
  if (procedure.inputSchema) {
48
68
  const parseResult = procedure.inputSchema.safeParse(rawInput);
49
69
  if (!parseResult.success) {
50
- throw new Error(`Invalid input: ${parseResult.error.message}`);
70
+ throw new InvalidInputError();
51
71
  }
52
72
  input = parseResult.data;
53
73
  } else {
@@ -89,50 +109,10 @@ var Router = class {
89
109
  }
90
110
  return executeProcedure(procedure, ctx, input);
91
111
  }
92
- /**
93
- * Get all procedure paths in the router
94
- */
95
- getProcedurePaths() {
96
- const paths = [];
97
- function traverse(obj, prefix) {
98
- if (typeof obj !== "object" || obj === null) return;
99
- for (const [key, value] of Object.entries(obj)) {
100
- const path = prefix ? `${prefix}.${key}` : key;
101
- if (isProcedure(value)) {
102
- paths.push(path);
103
- } else {
104
- traverse(value, path);
105
- }
106
- }
107
- }
108
- traverse(this.definition, "");
109
- return paths;
110
- }
111
- /**
112
- * Check if a path is a query procedure
113
- */
114
- isQuery(path) {
115
- const procedure = this.getProcedure(path);
116
- return procedure?.type === "query";
117
- }
118
- /**
119
- * Check if a path is a mutation procedure
120
- */
121
- isMutation(path) {
122
- const procedure = this.getProcedure(path);
123
- return procedure?.type === "mutation";
124
- }
125
112
  };
126
113
  function createRouter(definition) {
127
114
  return new Router(definition);
128
115
  }
129
- function mergeRouters(...routers) {
130
- const merged = {};
131
- for (const router of routers) {
132
- Object.assign(merged, router.definition);
133
- }
134
- return new Router(merged);
135
- }
136
116
 
137
117
  // ../server/src/reactive.ts
138
118
  function initReactive() {
@@ -143,155 +123,346 @@ function initReactive() {
143
123
  }
144
124
  };
145
125
  }
146
-
147
- // ../server/src/partiql-builder.ts
148
- function conditionToPartiQL(condition, parameters) {
126
+ var MAX_TRACKED_PREFIX_LENGTH = 64;
127
+ var TABLE_WILDCARD_FIELD = "*";
128
+ var DYNAMODB_PARTITION_KEY_MAX_BYTES = 2048;
129
+ var DEPENDENCY_KEY_VERSION = "d1";
130
+ var HASHED_DEPENDENCY_KEY_VERSION = "d1h";
131
+ var DEBUG_PREVIEW_BYTES = 96;
132
+ function isPlainMap(value) {
133
+ const prototype = Object.getPrototypeOf(value);
134
+ return prototype === Object.prototype || prototype === null;
135
+ }
136
+ function encodeStructuredValue(value, seen) {
137
+ if (value === null) return "null";
138
+ switch (typeof value) {
139
+ case "string":
140
+ return `string:${JSON.stringify(value)}`;
141
+ case "number":
142
+ return `number:${Object.is(value, -0) ? "-0" : String(value)}`;
143
+ case "boolean":
144
+ return `boolean:${String(value)}`;
145
+ case "bigint":
146
+ return `bigint:${String(value)}`;
147
+ case "undefined":
148
+ return "undefined";
149
+ case "symbol":
150
+ return `symbol:${String(value.description)}`;
151
+ case "function":
152
+ return `function:${String(value)}`;
153
+ }
154
+ if (ArrayBuffer.isView(value)) {
155
+ return `binary:${Buffer.from(
156
+ value.buffer,
157
+ value.byteOffset,
158
+ value.byteLength
159
+ ).toString("base64url")}`;
160
+ }
161
+ if (value instanceof ArrayBuffer) {
162
+ return `binary:${Buffer.from(value).toString("base64url")}`;
163
+ }
164
+ if (value instanceof Date) {
165
+ return `date:${Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString()}`;
166
+ }
167
+ if (seen.has(value)) return "circular";
168
+ seen.add(value);
169
+ let encoded;
170
+ if (Array.isArray(value)) {
171
+ encoded = `array:${JSON.stringify(
172
+ value.map((item) => encodeStructuredValue(item, seen))
173
+ )}`;
174
+ } else if (value instanceof Set) {
175
+ encoded = `set:${JSON.stringify(
176
+ [...value].map((item) => encodeStructuredValue(item, seen)).sort()
177
+ )}`;
178
+ } else if (isPlainMap(value)) {
179
+ encoded = `map:${JSON.stringify(
180
+ Object.keys(value).sort().map((key) => [key, encodeStructuredValue(value[key], seen)])
181
+ )}`;
182
+ } else {
183
+ encoded = `object:${Object.prototype.toString.call(value)}:${String(value)}`;
184
+ }
185
+ seen.delete(value);
186
+ return encoded;
187
+ }
188
+ function encodeDependencyValue(value) {
189
+ if (value === null || value === void 0) return null;
190
+ if (typeof value === "string") return value;
191
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
192
+ return String(value);
193
+ }
194
+ if (typeof value === "object" && isPlainMap(value)) return null;
195
+ return encodeStructuredValue(value, /* @__PURE__ */ new Set());
196
+ }
197
+ function wildcardDependency(tableName) {
198
+ return { tableName, fieldName: TABLE_WILDCARD_FIELD, fieldValue: "*" };
199
+ }
200
+ function extractFromCondition(tableName, condition) {
201
+ const dependencies = [];
149
202
  switch (condition.type) {
150
203
  case "comparison": {
151
- parameters.push(condition.value);
152
- return `"${condition.field}" ${condition.operator} ?`;
204
+ const fieldValue = encodeDependencyValue(condition.value);
205
+ if (condition.operator === "eq" && typeof condition.field === "string" && fieldValue !== null) {
206
+ dependencies.push({
207
+ tableName,
208
+ fieldName: condition.field,
209
+ fieldValue
210
+ });
211
+ }
212
+ return { dependencies, safe: dependencies.length === 1 };
153
213
  }
154
214
  case "function": {
155
- if (condition.operator === "BETWEEN") {
156
- parameters.push(condition.value);
157
- parameters.push(condition.value2);
158
- return `"${condition.field}" BETWEEN ? AND ?`;
159
- }
160
- if (condition.operator === "begins_with") {
161
- parameters.push(condition.value);
162
- return `begins_with("${condition.field}", ?)`;
163
- }
164
- if (condition.operator === "contains") {
165
- parameters.push(condition.value);
166
- return `contains("${condition.field}", ?)`;
215
+ if (condition.operator === "beginsWith" && typeof condition.field === "string" && typeof condition.value === "string" && condition.value.length > 0 && condition.value.length <= MAX_TRACKED_PREFIX_LENGTH) {
216
+ dependencies.push({
217
+ tableName,
218
+ fieldName: condition.field,
219
+ fieldValue: `prefix:${String(condition.value)}`
220
+ });
167
221
  }
168
- throw new Error(`Unknown function operator: ${condition.operator}`);
222
+ return { dependencies, safe: dependencies.length === 1 };
169
223
  }
170
224
  case "logical": {
171
- if (!condition.conditions || condition.conditions.length === 0) {
172
- throw new Error(
173
- "Logical condition requires at least one sub-condition"
174
- );
225
+ if (condition.operator === "not" || !condition.conditions || condition.conditions.length === 0) {
226
+ return { dependencies, safe: false };
175
227
  }
176
- if (condition.operator === "NOT") {
177
- const subClause = conditionToPartiQL(
178
- condition.conditions[0],
179
- parameters
228
+ let safe = true;
229
+ for (const subCondition of condition.conditions) {
230
+ const extracted = extractFromCondition(
231
+ tableName,
232
+ subCondition
180
233
  );
181
- return `NOT (${subClause})`;
234
+ dependencies.push(...extracted.dependencies);
235
+ safe &&= extracted.safe;
182
236
  }
183
- const subClauses = condition.conditions.map(
184
- (c) => conditionToPartiQL(c, parameters)
185
- );
186
- return `(${subClauses.join(` ${condition.operator} `)})`;
237
+ return { dependencies, safe };
187
238
  }
188
- default:
189
- throw new Error(`Unknown condition type: ${condition.type}`);
190
239
  }
240
+ return { dependencies, safe: false };
191
241
  }
192
- function buildSelectStatement(operation) {
193
- const parameters = [];
194
- let statement = `SELECT * FROM "${operation.tableName}"`;
195
- if (operation.indexName) {
196
- statement += `."${operation.indexName}"`;
242
+ function extractDependencies(operation) {
243
+ const dependencies = [];
244
+ let safe = operation.filters.length > 0;
245
+ for (const filter of operation.filters) {
246
+ const extracted = extractFromCondition(operation.tableName, filter);
247
+ dependencies.push(...extracted.dependencies);
248
+ safe &&= extracted.safe;
197
249
  }
198
- if (operation.filters.length > 0) {
199
- const whereClauses = operation.filters.map(
200
- (f) => conditionToPartiQL(f, parameters)
201
- );
202
- statement += ` WHERE ${whereClauses.join(" AND ")}`;
250
+ if (!safe) dependencies.push(wildcardDependency(operation.tableName));
251
+ const unique = /* @__PURE__ */ new Map();
252
+ for (const dependency of dependencies) {
253
+ unique.set(createDependencyKey(dependency), dependency);
203
254
  }
204
- return { statement, parameters };
255
+ return [...unique.values()];
205
256
  }
206
- function buildInsertStatement(tableName, item) {
207
- const parameters = [item];
208
- const statement = `INSERT INTO "${tableName}" VALUE ?`;
209
- return { statement, parameters };
257
+ function createDependencyKey(dependency) {
258
+ const components = [
259
+ dependency.tableName,
260
+ dependency.fieldName,
261
+ dependency.fieldValue
262
+ ];
263
+ const encoded = components.map(
264
+ (component) => Buffer.from(component, "utf8").toString("base64url")
265
+ );
266
+ const key = `${DEPENDENCY_KEY_VERSION}#${encoded.join("#")}`;
267
+ if (Buffer.byteLength(key, "utf8") <= DYNAMODB_PARTITION_KEY_MAX_BYTES) {
268
+ return key;
269
+ }
270
+ const digest = createHash("sha256").update(key, "utf8").digest("hex");
271
+ const previews = components.map(
272
+ (component) => Buffer.from(utf8Preview(component), "utf8").toString("base64url")
273
+ );
274
+ return `${HASHED_DEPENDENCY_KEY_VERSION}#${digest}#${previews.join("#")}`;
210
275
  }
211
- function buildUpdateStatement(tableName, key, updates) {
212
- const parameters = [];
213
- const setClauses = [];
214
- for (const [field, value] of Object.entries(updates)) {
215
- if (field in key) continue;
216
- setClauses.push(`"${field}" = ?`);
217
- parameters.push(value);
218
- }
219
- if (setClauses.length === 0) {
220
- throw new Error("No fields to update");
221
- }
222
- let statement = `UPDATE "${tableName}" SET ${setClauses.join(", ")}`;
223
- const whereClauses = Object.entries(key).map(([field, value]) => {
224
- parameters.push(value);
225
- return `"${field}" = ?`;
226
- });
227
- statement += ` WHERE ${whereClauses.join(" AND ")}`;
228
- return { statement, parameters };
276
+ function utf8Preview(value) {
277
+ if (Buffer.byteLength(value, "utf8") <= DEBUG_PREVIEW_BYTES) return value;
278
+ let preview = "";
279
+ for (const character of value) {
280
+ if (Buffer.byteLength(`${preview}${character}`, "utf8") > DEBUG_PREVIEW_BYTES) {
281
+ break;
282
+ }
283
+ preview += character;
284
+ }
285
+ return `${preview}...`;
229
286
  }
230
- function buildDeleteStatement(tableName, key) {
231
- const parameters = [];
232
- const whereClauses = Object.entries(key).map(([field, value]) => {
233
- parameters.push(value);
234
- return `"${field}" = ?`;
235
- });
236
- const statement = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`;
237
- return { statement, parameters };
287
+ function parseDependencyKey(key) {
288
+ const parts = key.split("#");
289
+ const decode = (encoded) => {
290
+ const buffer = Buffer.from(encoded, "base64url");
291
+ return buffer.toString("base64url") === encoded ? buffer.toString("utf8") : null;
292
+ };
293
+ if (parts[0] === DEPENDENCY_KEY_VERSION && parts.length === 4) {
294
+ const decoded = parts.slice(1).map(decode);
295
+ if (decoded.some((component) => component === null)) return null;
296
+ return {
297
+ tableName: decoded[0],
298
+ fieldName: decoded[1],
299
+ fieldValue: decoded[2]
300
+ };
301
+ }
302
+ if (parts[0] === HASHED_DEPENDENCY_KEY_VERSION && parts.length === 5 && /^[a-f0-9]{64}$/.test(parts[1])) {
303
+ const decoded = parts.slice(2).map(decode);
304
+ if (decoded.some((component) => component === null)) return null;
305
+ return {
306
+ tableName: decoded[0],
307
+ fieldName: decoded[1],
308
+ fieldValue: `${decoded[2]} [sha256:${parts[1]}]`
309
+ };
310
+ }
311
+ return null;
238
312
  }
239
- function buildGetStatement(tableName, key) {
240
- const parameters = [];
241
- const whereClauses = Object.entries(key).map(([field, value]) => {
242
- parameters.push(value);
243
- return `"${field}" = ?`;
244
- });
245
- const statement = `SELECT * FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`;
246
- return { statement, parameters };
313
+ function extractAffectedKeys(tableName, item) {
314
+ const keys = /* @__PURE__ */ new Set([
315
+ createDependencyKey(wildcardDependency(tableName))
316
+ ]);
317
+ const visit = (fieldName, fieldValue) => {
318
+ if (fieldValue !== null && fieldValue !== void 0) {
319
+ const encodedValue = encodeDependencyValue(fieldValue);
320
+ if (encodedValue !== null) {
321
+ keys.add(
322
+ createDependencyKey({
323
+ tableName,
324
+ fieldName,
325
+ fieldValue: encodedValue
326
+ })
327
+ );
328
+ }
329
+ if (typeof fieldValue === "string") {
330
+ const maxPrefixLen = Math.min(
331
+ fieldValue.length,
332
+ MAX_TRACKED_PREFIX_LENGTH
333
+ );
334
+ for (let i = 1; i <= maxPrefixLen; i++) {
335
+ keys.add(
336
+ createDependencyKey({
337
+ tableName,
338
+ fieldName,
339
+ fieldValue: `prefix:${fieldValue.substring(0, i)}`
340
+ })
341
+ );
342
+ }
343
+ }
344
+ if (typeof fieldValue === "object" && isPlainMap(fieldValue)) {
345
+ for (const [nestedName, nestedValue2] of Object.entries(fieldValue)) {
346
+ visit(`${fieldName}.${nestedName}`, nestedValue2);
347
+ }
348
+ }
349
+ }
350
+ };
351
+ for (const [fieldName, fieldValue] of Object.entries(item)) {
352
+ visit(fieldName, fieldValue);
353
+ }
354
+ return [...keys];
355
+ }
356
+ function operationToQueryMetadata(operation) {
357
+ return {
358
+ planVersion: 1,
359
+ tableName: operation.tableName,
360
+ indexName: operation.indexName,
361
+ indexPkField: operation.indexPkField,
362
+ indexSkField: operation.indexSkField,
363
+ filterConditions: operation.filters,
364
+ ...operation.sortField && operation.sortOrder ? { sortField: operation.sortField, sortOrder: operation.sortOrder } : {},
365
+ limit: operation.limit
366
+ };
247
367
  }
368
+ var DependencyTracker = class {
369
+ operations = [];
370
+ /**
371
+ * Track a query operation
372
+ */
373
+ track(operation, result) {
374
+ this.operations.push({
375
+ operation,
376
+ resultReference: result,
377
+ resultSnapshot: structuredClone(result)
378
+ });
379
+ }
380
+ /**
381
+ * Get all tracked operations
382
+ */
383
+ getOperations() {
384
+ return this.operations.map(({ operation }) => operation);
385
+ }
386
+ /**
387
+ * Extract all dependencies from tracked operations
388
+ */
389
+ extractAll() {
390
+ const dependencies = [];
391
+ for (const { operation } of this.operations) {
392
+ dependencies.push(...extractDependencies(operation));
393
+ }
394
+ return dependencies;
395
+ }
396
+ /**
397
+ * Get query metadata for the first tracked operation.
398
+ * Used for storing subscription state.
399
+ */
400
+ getQueryMetadata() {
401
+ if (this.operations.length === 0) return null;
402
+ return operationToQueryMetadata(this.operations[0].operation);
403
+ }
404
+ getCapturedResult() {
405
+ if (this.operations.length !== 1) return null;
406
+ return {
407
+ reference: this.operations[0].resultReference,
408
+ snapshot: structuredClone(this.operations[0].resultSnapshot)
409
+ };
410
+ }
411
+ };
248
412
 
249
413
  // ../server/src/query-builder.ts
250
- function createCondition(type, operator, field, value, value2) {
251
- return { type, operator, field, value, value2 };
252
- }
253
414
  var FilterBuilderImpl = class {
254
415
  eq(field, value) {
255
- return createCondition("comparison", "=", field.fieldName, value);
416
+ return { type: "comparison", operator: "eq", field: field.fieldName, value };
256
417
  }
257
418
  ne(field, value) {
258
- return createCondition("comparison", "<>", field.fieldName, value);
419
+ return { type: "comparison", operator: "ne", field: field.fieldName, value };
259
420
  }
260
421
  gt(field, value) {
261
- return createCondition("comparison", ">", field.fieldName, value);
422
+ return { type: "comparison", operator: "gt", field: field.fieldName, value };
262
423
  }
263
424
  gte(field, value) {
264
- return createCondition("comparison", ">=", field.fieldName, value);
425
+ return { type: "comparison", operator: "gte", field: field.fieldName, value };
265
426
  }
266
427
  lt(field, value) {
267
- return createCondition("comparison", "<", field.fieldName, value);
428
+ return { type: "comparison", operator: "lt", field: field.fieldName, value };
268
429
  }
269
430
  lte(field, value) {
270
- return createCondition("comparison", "<=", field.fieldName, value);
431
+ return { type: "comparison", operator: "lte", field: field.fieldName, value };
271
432
  }
272
433
  between(field, lower, upper) {
273
- return createCondition(
274
- "function",
275
- "BETWEEN",
276
- field.fieldName,
277
- lower,
278
- upper
279
- );
434
+ return {
435
+ type: "function",
436
+ operator: "between",
437
+ field: field.fieldName,
438
+ value: lower,
439
+ value2: upper
440
+ };
280
441
  }
281
442
  beginsWith(field, prefix) {
282
- return createCondition("function", "begins_with", field.fieldName, prefix);
443
+ return {
444
+ type: "function",
445
+ operator: "beginsWith",
446
+ field: field.fieldName,
447
+ value: prefix
448
+ };
283
449
  }
284
450
  contains(field, substring) {
285
- return createCondition("function", "contains", field.fieldName, substring);
451
+ return {
452
+ type: "function",
453
+ operator: "contains",
454
+ field: field.fieldName,
455
+ value: substring
456
+ };
286
457
  }
287
458
  and(...conditions) {
288
- return { type: "logical", operator: "AND", conditions };
459
+ return { type: "logical", operator: "and", conditions };
289
460
  }
290
461
  or(...conditions) {
291
- return { type: "logical", operator: "OR", conditions };
462
+ return { type: "logical", operator: "or", conditions };
292
463
  }
293
464
  not(condition) {
294
- return { type: "logical", operator: "NOT", conditions: [condition] };
465
+ return { type: "logical", operator: "not", conditions: [condition] };
295
466
  }
296
467
  };
297
468
  var QueryBuilderImpl = class {
@@ -299,8 +470,8 @@ var QueryBuilderImpl = class {
299
470
  filters = [];
300
471
  indexName;
301
472
  limit;
302
- startKey;
303
473
  ascending = true;
474
+ sortRequested = false;
304
475
  executor;
305
476
  operationTracker;
306
477
  constructor(table, executor, operationTracker) {
@@ -315,75 +486,422 @@ var QueryBuilderImpl = class {
315
486
  return this;
316
487
  }
317
488
  useIndex(indexName) {
489
+ if (!this.table.indexes[indexName]) {
490
+ throw new Error(`Unknown index: ${indexName}`);
491
+ }
318
492
  this.indexName = indexName;
319
493
  return this;
320
494
  }
321
495
  take(limit) {
496
+ if (!Number.isInteger(limit) || limit <= 0) {
497
+ throw new Error("take(limit) requires a positive integer");
498
+ }
322
499
  this.limit = limit;
323
500
  return this;
324
501
  }
325
- startFrom(key) {
326
- this.startKey = key;
327
- return this;
328
- }
329
502
  sortAscending() {
330
503
  this.ascending = true;
504
+ this.sortRequested = true;
331
505
  return this;
332
506
  }
333
507
  sortDescending() {
334
508
  this.ascending = false;
509
+ this.sortRequested = true;
335
510
  return this;
336
511
  }
337
512
  async execute() {
338
- const operation = {
339
- tableName: this.table.tableName,
340
- filters: this.filters,
341
- indexName: this.indexName,
342
- pkField: this.table.pk,
343
- skField: this.table.sk,
344
- sortField: this.table.sk,
345
- // Default sort by SK if available
346
- sortOrder: this.ascending ? "asc" : "desc",
347
- limit: this.limit
348
- };
349
- if (this.operationTracker) {
350
- this.operationTracker(operation);
351
- }
352
- const options = {
353
- limit: this.limit,
354
- startKey: this.startKey,
355
- ascending: this.ascending
356
- };
357
- return this.executor(operation, options);
513
+ const operation = this.getOperation();
514
+ const result = await this.executor(operation);
515
+ this.operationTracker?.(operation, result);
516
+ return result;
358
517
  }
359
518
  /**
360
519
  * Get the current operation without executing
361
520
  * Used for dependency extraction
362
521
  */
363
522
  getOperation() {
523
+ const index = this.indexName ? this.table.indexes[this.indexName] : void 0;
524
+ const selectedSortField = index ? index.sk : this.table.sk;
525
+ const sortField = this.sortRequested ? selectedSortField : void 0;
526
+ if (this.sortRequested && !sortField) {
527
+ throw new Error("Sorting requires a table or index sort key");
528
+ }
364
529
  return {
365
530
  tableName: this.table.tableName,
366
531
  filters: this.filters,
367
- indexName: this.indexName,
368
- pkField: this.table.pk,
369
- skField: this.table.sk,
370
- sortField: this.table.sk,
371
- sortOrder: this.ascending ? "asc" : "desc",
532
+ indexName: index?.name,
533
+ indexPkField: index?.pk,
534
+ indexSkField: index?.sk,
535
+ ...sortField ? { sortField, sortOrder: this.ascending ? "asc" : "desc" } : {},
372
536
  limit: this.limit
373
537
  };
374
538
  }
375
539
  };
376
- function createFilterBuilder() {
377
- return new FilterBuilderImpl();
540
+ var FilterConditionSchema = z.lazy(() => {
541
+ const comparison = z.object({
542
+ type: z.literal("comparison"),
543
+ operator: z.enum(["eq", "ne", "gt", "gte", "lt", "lte"]),
544
+ field: z.string().min(1),
545
+ value: z.unknown()
546
+ }).strict();
547
+ const queryFunction = z.union([
548
+ z.object({
549
+ type: z.literal("function"),
550
+ operator: z.literal("between"),
551
+ field: z.string().min(1),
552
+ value: z.unknown(),
553
+ value2: z.unknown()
554
+ }).strict(),
555
+ z.object({
556
+ type: z.literal("function"),
557
+ operator: z.enum(["beginsWith", "contains"]),
558
+ field: z.string().min(1),
559
+ value: z.unknown()
560
+ }).strict()
561
+ ]);
562
+ const logical = z.object({
563
+ type: z.literal("logical"),
564
+ operator: z.enum(["and", "or", "not"]),
565
+ conditions: z.array(FilterConditionSchema).min(1)
566
+ }).strict().superRefine((condition, context) => {
567
+ if (condition.operator === "not" && condition.conditions.length !== 1) {
568
+ context.addIssue({
569
+ code: "custom",
570
+ path: ["conditions"],
571
+ message: "not requires exactly one condition"
572
+ });
573
+ }
574
+ });
575
+ return z.union([comparison, queryFunction, logical]);
576
+ });
577
+ var QueryMetadataSchema = z.object({
578
+ planVersion: z.literal(1),
579
+ tableName: z.string().min(1),
580
+ indexName: z.string().min(1).optional(),
581
+ indexPkField: z.string().min(1).optional(),
582
+ indexSkField: z.string().min(1).optional(),
583
+ filterConditions: z.array(FilterConditionSchema),
584
+ sortField: z.string().min(1).optional(),
585
+ sortOrder: z.enum(["asc", "desc"]).optional(),
586
+ limit: z.number().int().positive().optional()
587
+ }).strict().superRefine((metadata, context) => {
588
+ if (metadata.indexName && !metadata.indexPkField) {
589
+ context.addIssue({
590
+ code: "custom",
591
+ path: ["indexPkField"],
592
+ message: "Index metadata requires its partition key field"
593
+ });
594
+ }
595
+ if (!metadata.indexName && (metadata.indexPkField || metadata.indexSkField)) {
596
+ context.addIssue({
597
+ code: "custom",
598
+ path: ["indexName"],
599
+ message: "Index key fields require an index name"
600
+ });
601
+ }
602
+ if (Boolean(metadata.sortField) !== Boolean(metadata.sortOrder)) {
603
+ context.addIssue({
604
+ code: "custom",
605
+ path: ["sortOrder"],
606
+ message: "Sort field and order must be specified together"
607
+ });
608
+ }
609
+ });
610
+ var DeliveryVersionSchema = z.object({
611
+ generation: z.number().int().nonnegative(),
612
+ fromVersion: z.number().int().nonnegative(),
613
+ toVersion: z.number().int().positive(),
614
+ messageId: z.string().min(1)
615
+ });
616
+ var PendingDeliverySchema = z.discriminatedUnion("type", [
617
+ DeliveryVersionSchema.extend({
618
+ type: z.literal("patch"),
619
+ patches: z.array(
620
+ z.object({
621
+ op: z.enum(["add", "remove", "replace", "move", "copy", "test"]),
622
+ path: z.string(),
623
+ value: z.unknown().optional(),
624
+ from: z.string().optional()
625
+ })
626
+ )
627
+ }),
628
+ DeliveryVersionSchema.extend({
629
+ type: z.literal("resync")
630
+ }).strict()
631
+ ]);
632
+ var SystemSchemas = {
633
+ /**
634
+ * ReactiveConnectionsV2 - Tracks active WebSocket connections
635
+ */
636
+ connections: z.object({
637
+ connectionId: z.string(),
638
+ status: z.enum(["open", "closed"]),
639
+ context: z.record(z.string(), z.unknown()).optional(),
640
+ authorizerContext: z.record(z.string(), z.unknown()).optional(),
641
+ requestIdentity: z.record(z.string(), z.unknown()).optional(),
642
+ connectedAt: z.number(),
643
+ ttl: z.number()
644
+ }),
645
+ /**
646
+ * ReactiveDependenciesV2 - The inverted index for O(1) lookups
647
+ */
648
+ dependencies: z.object({
649
+ pk: z.string(),
650
+ // Versioned encoded dependency key
651
+ sk: z.string(),
652
+ // ConnectionID#SubscriptionID
653
+ connectionId: z.string(),
654
+ subscriptionId: z.string(),
655
+ tableName: z.string(),
656
+ fieldName: z.string(),
657
+ fieldValue: z.string(),
658
+ generation: z.number().int().nonnegative(),
659
+ registrationId: z.string().min(1),
660
+ ttl: z.number()
661
+ }),
662
+ /**
663
+ * ReactiveConnectionQueriesV2 - Stores subscription state for diffing
664
+ */
665
+ queries: z.object({
666
+ pk: z.string(),
667
+ // ConnectionID
668
+ sk: z.string(),
669
+ // SubscriptionID
670
+ connectionId: z.string(),
671
+ subscriptionId: z.string(),
672
+ status: z.enum(["registering", "active", "cancelled", "faulted"]),
673
+ generation: z.number().int().nonnegative(),
674
+ registrationId: z.string().min(1),
675
+ queryMetadata: QueryMetadataSchema,
676
+ lastResult: z.array(z.unknown()),
677
+ dependencies: z.array(z.string()),
678
+ version: z.number().int().nonnegative(),
679
+ pendingDelivery: PendingDeliverySchema.optional(),
680
+ createdAt: z.number(),
681
+ updatedAt: z.number(),
682
+ ttl: z.number()
683
+ })
684
+ };
685
+ var SystemTableNames = {
686
+ connections: "ReactiveConnectionsV2",
687
+ dependencies: "ReactiveDependenciesV2",
688
+ queries: "ReactiveConnectionQueriesV2"
689
+ };
690
+ var InvalidQueryMetadataError = class extends Error {
691
+ constructor(message) {
692
+ super(message);
693
+ this.name = "InvalidQueryMetadataError";
694
+ }
695
+ };
696
+ function attributeValue(value) {
697
+ if (value === null) return { NULL: true };
698
+ if (value === void 0) {
699
+ throw new InvalidQueryMetadataError("Query parameter values are required");
700
+ }
701
+ if (typeof value === "string") return { S: value };
702
+ if (typeof value === "number") {
703
+ if (!Number.isFinite(value)) {
704
+ throw new InvalidQueryMetadataError("Query values must be finite numbers");
705
+ }
706
+ return { N: String(value) };
707
+ }
708
+ if (typeof value === "boolean") return { BOOL: value };
709
+ if (value instanceof Uint8Array) return { B: value };
710
+ if (value instanceof Set) {
711
+ const values = [...value];
712
+ if (values.length === 0) {
713
+ throw new InvalidQueryMetadataError("Query sets must not be empty");
714
+ }
715
+ if (values.every((item) => typeof item === "string")) {
716
+ return { SS: values };
717
+ }
718
+ if (values.every((item) => typeof item === "number")) {
719
+ if (values.some((item) => !Number.isFinite(item))) {
720
+ throw new InvalidQueryMetadataError("Query values must be finite numbers");
721
+ }
722
+ return { NS: values.map(String) };
723
+ }
724
+ if (values.every((item) => item instanceof Uint8Array)) {
725
+ return { BS: values };
726
+ }
727
+ throw new InvalidQueryMetadataError(
728
+ "Query sets must contain one supported DynamoDB scalar type"
729
+ );
730
+ }
731
+ if (Array.isArray(value)) return { L: value.map(attributeValue) };
732
+ if (typeof value === "object") {
733
+ return {
734
+ M: Object.fromEntries(
735
+ Object.entries(value).map(([key, item]) => [
736
+ key,
737
+ attributeValue(item)
738
+ ])
739
+ )
740
+ };
741
+ }
742
+ throw new InvalidQueryMetadataError(
743
+ `Unsupported query value type: ${typeof value}`
744
+ );
745
+ }
746
+ function quoteIdentifier(identifier) {
747
+ if (!identifier) {
748
+ throw new InvalidQueryMetadataError("Identifier must not be empty");
749
+ }
750
+ return `"${identifier.replace(/"/g, '""')}"`;
751
+ }
752
+ function quotePath(path) {
753
+ if (!path || path.split(".").some((part) => !part)) {
754
+ throw new InvalidQueryMetadataError(`Invalid field path: ${path}`);
755
+ }
756
+ return path.split(".").map(quoteIdentifier).join(".");
757
+ }
758
+ function compileCondition(condition, parameters) {
759
+ if (condition.type === "comparison" && condition.field) {
760
+ const operators = {
761
+ eq: "=",
762
+ ne: "<>",
763
+ gt: ">",
764
+ gte: ">=",
765
+ lt: "<",
766
+ lte: "<="
767
+ };
768
+ const operator = operators[condition.operator];
769
+ if (!operator) {
770
+ throw new InvalidQueryMetadataError(
771
+ `Unsupported comparison operator: ${condition.operator}`
772
+ );
773
+ }
774
+ parameters.push(attributeValue(condition.value));
775
+ return `${quotePath(condition.field)} ${operator} ?`;
776
+ }
777
+ if (condition.type === "function" && condition.field) {
778
+ if (condition.operator === "between") {
779
+ parameters.push(attributeValue(condition.value));
780
+ parameters.push(attributeValue(condition.value2));
781
+ return `${quotePath(condition.field)} BETWEEN ? AND ?`;
782
+ }
783
+ if (condition.operator === "beginsWith" || condition.operator === "contains") {
784
+ parameters.push(attributeValue(condition.value));
785
+ const name = condition.operator === "beginsWith" ? "begins_with" : "contains";
786
+ return `${name}(${quotePath(condition.field)}, ?)`;
787
+ }
788
+ throw new InvalidQueryMetadataError(
789
+ `Unsupported function operator: ${condition.operator}`
790
+ );
791
+ }
792
+ if (condition.type === "logical" && condition.conditions?.length) {
793
+ if (condition.operator === "not") {
794
+ if (condition.conditions.length !== 1) {
795
+ throw new InvalidQueryMetadataError("NOT requires exactly one condition");
796
+ }
797
+ return `NOT (${compileCondition(condition.conditions[0], parameters)})`;
798
+ }
799
+ if (condition.operator === "and" || condition.operator === "or") {
800
+ const operator = condition.operator.toUpperCase();
801
+ return `(${condition.conditions.map((item) => compileCondition(item, parameters)).join(` ${operator} `)})`;
802
+ }
803
+ }
804
+ throw new InvalidQueryMetadataError("Malformed query filter condition");
805
+ }
806
+ function parseQueryMetadata(metadata) {
807
+ const parsed = QueryMetadataSchema.safeParse(metadata);
808
+ if (!parsed.success) {
809
+ throw new InvalidQueryMetadataError(
810
+ `Invalid query metadata: ${parsed.error.message}`
811
+ );
812
+ }
813
+ return parsed.data;
814
+ }
815
+ function compileQueryMetadata(metadataInput, nextToken) {
816
+ const metadata = parseQueryMetadata(metadataInput);
817
+ const parameters = [];
818
+ let statement = `SELECT * FROM ${quoteIdentifier(metadata.tableName)}`;
819
+ const predicates = [];
820
+ if (metadata.indexPkField) {
821
+ predicates.push(`${quotePath(metadata.indexPkField)} IS NOT MISSING`);
822
+ }
823
+ if (metadata.indexSkField) {
824
+ predicates.push(`${quotePath(metadata.indexSkField)} IS NOT MISSING`);
825
+ }
826
+ if (metadata.filterConditions.length) {
827
+ predicates.push(
828
+ ...metadata.filterConditions.map(
829
+ (condition) => compileCondition(condition, parameters)
830
+ )
831
+ );
832
+ }
833
+ if (predicates.length) statement += ` WHERE ${predicates.join(" AND ")}`;
834
+ return {
835
+ Statement: statement,
836
+ ConsistentRead: true,
837
+ ...parameters.length ? { Parameters: parameters } : {},
838
+ ...nextToken ? { NextToken: nextToken } : {}
839
+ };
840
+ }
841
+ function nestedValue(item, path) {
842
+ return path.split(".").reduce((value, part) => {
843
+ if (!value || typeof value !== "object") return void 0;
844
+ return value[part];
845
+ }, item);
846
+ }
847
+ function compareValues(left, right) {
848
+ if (left === right) return 0;
849
+ if (typeof left === "number" && typeof right === "number") return left - right;
850
+ if (typeof left === "string" && typeof right === "string") {
851
+ return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
852
+ }
853
+ if (left instanceof Uint8Array && right instanceof Uint8Array) {
854
+ return Buffer.compare(Buffer.from(left), Buffer.from(right));
855
+ }
856
+ throw new InvalidQueryMetadataError(
857
+ "Sorted query fields must contain one consistent DynamoDB key type"
858
+ );
859
+ }
860
+ async function executeQueryMetadata(client, metadataInput) {
861
+ const metadata = parseQueryMetadata(metadataInput);
862
+ const items = [];
863
+ let nextToken;
864
+ const seenTokens = /* @__PURE__ */ new Set();
865
+ do {
866
+ const response = await client.send(
867
+ new ExecuteStatementCommand(compileQueryMetadata(metadata, nextToken))
868
+ );
869
+ items.push(
870
+ ...(response.Items ?? []).map(
871
+ (item) => unmarshall(item)
872
+ )
873
+ );
874
+ nextToken = response.NextToken;
875
+ if (nextToken && seenTokens.has(nextToken)) {
876
+ throw new Error("DynamoDB returned a repeated pagination token");
877
+ }
878
+ if (nextToken) seenTokens.add(nextToken);
879
+ if (metadata.limit !== void 0 && !metadata.sortField && items.length >= metadata.limit) {
880
+ break;
881
+ }
882
+ } while (nextToken);
883
+ if (metadata.sortField) {
884
+ items.sort((left, right) => {
885
+ const result = compareValues(
886
+ nestedValue(left, metadata.sortField),
887
+ nestedValue(right, metadata.sortField)
888
+ );
889
+ return metadata.sortOrder === "desc" ? -result : result;
890
+ });
891
+ }
892
+ return metadata.limit === void 0 ? items : items.slice(0, metadata.limit);
378
893
  }
379
894
 
380
895
  // ../server/src/db-context.ts
381
- function createDocClient(config) {
896
+ var MAX_CONDITION_EXPRESSION_BYTES = 4 * 1024;
897
+ var MAX_EXPRESSION_VARIABLE_BYTES = 2 * 1024 * 1024;
898
+ var MAX_CONDITION_OPERATORS = 300;
899
+ function createClients(config) {
382
900
  const client = config.client ?? new DynamoDBClient({
383
901
  region: config.region ?? process.env.AWS_REGION ?? "us-east-1",
384
902
  endpoint: config.endpoint
385
903
  });
386
- return DynamoDBDocumentClient.from(client, {
904
+ const docClient = config.docClient ?? DynamoDBDocumentClient.from(client, {
387
905
  marshallOptions: {
388
906
  removeUndefinedValues: true,
389
907
  convertEmptyValues: false
@@ -392,43 +910,15 @@ function createDocClient(config) {
392
910
  wrapNumbers: false
393
911
  }
394
912
  });
395
- }
396
- function toAttributeValue(value) {
397
- if (typeof value === "string") return { S: value };
398
- if (typeof value === "number") return { N: String(value) };
399
- if (typeof value === "boolean") return { BOOL: value };
400
- if (value === null || value === void 0) return { NULL: true };
401
- if (Array.isArray(value)) {
402
- return { L: value.map(toAttributeValue) };
403
- }
404
- if (typeof value === "object") {
405
- const m = {};
406
- for (const [k, v] of Object.entries(value)) {
407
- m[k] = toAttributeValue(v);
408
- }
409
- return { M: m };
410
- }
411
- return { S: String(value) };
412
- }
413
- async function executePartiQL(docClient, statement) {
414
- const command = new ExecuteStatementCommand({
415
- Statement: statement.statement,
416
- Parameters: statement.parameters.map(toAttributeValue)
417
- });
418
- const response = await docClient.send(command);
419
- return (response.Items ?? []).map(
420
- (item) => unmarshall(item)
421
- );
913
+ return { client, docClient };
422
914
  }
423
915
  function createDbContext(config, dependencyTracker) {
424
- const docClient = createDocClient(config);
425
- async function executeQuery(operation, options) {
426
- const statement = buildSelectStatement(operation);
427
- const items = await executePartiQL(docClient, statement);
428
- if (options.limit && items.length > options.limit) {
429
- return items.slice(0, options.limit);
430
- }
431
- return items;
916
+ const { client, docClient } = createClients(config);
917
+ async function executeQuery(operation) {
918
+ return executeQueryMetadata(
919
+ client,
920
+ operationToQueryMetadata(operation)
921
+ );
432
922
  }
433
923
  return {
434
924
  query(table) {
@@ -441,16 +931,17 @@ function createDbContext(config, dependencyTracker) {
441
931
  async get(table, key) {
442
932
  const command = new GetCommand({
443
933
  TableName: table.tableName,
444
- Key: key
934
+ Key: key,
935
+ ConsistentRead: true
445
936
  });
446
937
  const response = await docClient.send(command);
447
938
  return response.Item ? response.Item : null;
448
939
  },
449
940
  async put(table, item) {
450
- table.validate(item);
941
+ const parsedItem = table.validate(item);
451
942
  const command = new PutCommand({
452
943
  TableName: table.tableName,
453
- Item: item
944
+ Item: parsedItem
454
945
  });
455
946
  await docClient.send(command);
456
947
  },
@@ -462,528 +953,698 @@ function createDbContext(config, dependencyTracker) {
462
953
  await docClient.send(command);
463
954
  },
464
955
  async update(table, key, updates) {
465
- const updateExpressions = [];
956
+ const currentResponse = await docClient.send(
957
+ new GetCommand({
958
+ TableName: table.tableName,
959
+ Key: key,
960
+ ConsistentRead: true
961
+ })
962
+ );
963
+ const current = currentResponse.Item;
964
+ if (!current) throw new Error("Item not found");
965
+ const parsed = table.validate({
966
+ ...current,
967
+ ...updates,
968
+ ...key
969
+ });
970
+ const expected = current;
971
+ const fields = [
972
+ .../* @__PURE__ */ new Set([
973
+ ...table.getFieldNames().map(String),
974
+ ...Object.keys(expected)
975
+ ])
976
+ ].sort();
977
+ const conditions = [];
466
978
  const expressionAttributeNames = {};
467
979
  const expressionAttributeValues = {};
468
- let i = 0;
469
- for (const [field, value] of Object.entries(updates)) {
470
- if (field in key) continue;
980
+ for (const [i, field] of fields.entries()) {
471
981
  const nameKey = `#f${i}`;
472
- const valueKey = `:v${i}`;
473
- updateExpressions.push(`${nameKey} = ${valueKey}`);
474
982
  expressionAttributeNames[nameKey] = field;
475
- expressionAttributeValues[valueKey] = value;
476
- i++;
477
- }
478
- if (updateExpressions.length === 0) {
479
- const current = await this.get(table, key);
480
- if (!current) {
481
- throw new Error("Item not found");
983
+ if (Object.prototype.hasOwnProperty.call(expected, field)) {
984
+ const valueKey = `:e${i}`;
985
+ conditions.push(`${nameKey} = ${valueKey}`);
986
+ expressionAttributeValues[valueKey] = expected[field];
987
+ } else {
988
+ conditions.push(`attribute_not_exists(${nameKey})`);
482
989
  }
483
- return current;
484
990
  }
485
- const command = new UpdateCommand({
991
+ const conditionExpression = conditions.join(" AND ");
992
+ const variableBytes = Buffer.byteLength(
993
+ JSON.stringify({
994
+ names: expressionAttributeNames,
995
+ values: expressionAttributeValues
996
+ }),
997
+ "utf8"
998
+ );
999
+ if (Buffer.byteLength(conditionExpression, "utf8") > MAX_CONDITION_EXPRESSION_BYTES || conditions.length > MAX_CONDITION_OPERATORS || variableBytes > MAX_EXPRESSION_VARIABLE_BYTES) {
1000
+ throw new Error(
1001
+ "Item is too complex to update with a safe compare-and-swap"
1002
+ );
1003
+ }
1004
+ const command = new PutCommand({
486
1005
  TableName: table.tableName,
487
- Key: key,
488
- UpdateExpression: `SET ${updateExpressions.join(", ")}`,
489
- ExpressionAttributeNames: expressionAttributeNames,
490
- ExpressionAttributeValues: expressionAttributeValues,
491
- ReturnValues: "ALL_NEW"
1006
+ Item: parsed,
1007
+ ConditionExpression: conditionExpression,
1008
+ ...Object.keys(expressionAttributeNames).length ? { ExpressionAttributeNames: expressionAttributeNames } : {},
1009
+ ...Object.keys(expressionAttributeValues).length ? { ExpressionAttributeValues: expressionAttributeValues } : {}
492
1010
  });
493
- const response = await docClient.send(command);
494
- return response.Attributes;
1011
+ await docClient.send(command);
1012
+ return parsed;
495
1013
  }
496
1014
  };
497
1015
  }
498
1016
 
499
- // ../server/src/dependency-extractor.ts
500
- function extractFromCondition(tableName, condition, indexName) {
501
- const dependencies = [];
502
- switch (condition.type) {
503
- case "comparison": {
504
- if (condition.operator === "=" && condition.field && condition.value !== void 0) {
505
- dependencies.push({
506
- tableName,
507
- fieldName: condition.field,
508
- fieldValue: String(condition.value),
509
- indexName
510
- });
511
- }
512
- break;
513
- }
514
- case "function": {
515
- if (condition.operator === "begins_with" && condition.field && condition.value) {
516
- dependencies.push({
517
- tableName,
518
- fieldName: condition.field,
519
- fieldValue: `prefix:${String(condition.value)}`,
520
- indexName
521
- });
522
- }
523
- break;
524
- }
525
- case "logical": {
526
- if (condition.conditions) {
527
- for (const subCondition of condition.conditions) {
528
- dependencies.push(
529
- ...extractFromCondition(tableName, subCondition, indexName)
530
- );
531
- }
532
- }
533
- break;
534
- }
535
- }
536
- return dependencies;
537
- }
538
- function extractDependencies(operation) {
539
- const dependencies = [];
540
- for (const filter of operation.filters) {
541
- dependencies.push(
542
- ...extractFromCondition(operation.tableName, filter, operation.indexName)
543
- );
1017
+ // ../server/src/log.ts
1018
+ var consoleLogger = globalThis.console;
1019
+ var SAFE_ERROR_NAMES = /* @__PURE__ */ new Set([
1020
+ "ConditionalCheckFailedException",
1021
+ "Error",
1022
+ "GoneException",
1023
+ "TransactionCanceledException"
1024
+ ]);
1025
+ function sanitized(value) {
1026
+ if (value instanceof Error) {
1027
+ return {
1028
+ errorName: SAFE_ERROR_NAMES.has(value.name) ? value.name : "Error"
1029
+ };
544
1030
  }
545
- return dependencies;
1031
+ if (value && typeof value === "object") return { valueType: "object" };
1032
+ return value;
546
1033
  }
547
- function createDependencyKey(dependency) {
548
- return `${dependency.tableName}#${dependency.fieldName}#${dependency.fieldValue}`;
1034
+ function logger(method) {
1035
+ return (...values) => consoleLogger[method](...values.map(sanitized));
549
1036
  }
550
- function parseDependencyKey(key) {
551
- const parts = key.split("#");
552
- if (parts.length < 3) return null;
553
- return {
554
- tableName: parts[0],
555
- fieldName: parts[1],
556
- fieldValue: parts.slice(2).join("#")
557
- // Handle values that contain #
558
- };
1037
+ var debug = logger("debug");
1038
+ var info = logger("info");
1039
+ var error = logger("error");
1040
+
1041
+ // ../server/src/handlers/reactive-handler.ts
1042
+ var MAX_TRANSACTION_DEPENDENCIES = 90;
1043
+ var MAX_QUERY_ENTRY_BYTES = 300 * 1024;
1044
+ var DYNAMODB_PARTITION_KEY_MAX_BYTES2 = 2048;
1045
+ var DYNAMODB_SORT_KEY_MAX_BYTES = 1024;
1046
+ var MAX_TABLE_PREFIX_LENGTH = 227;
1047
+ var DEFAULT_CONNECTION_LEASE_SECONDS = 3 * 60 * 60;
1048
+ function requestError(message, code) {
1049
+ return new SafeRequestError(message, code);
559
1050
  }
560
- function extractAffectedKeys(tableName, item) {
561
- const keys = [];
562
- for (const [fieldName, fieldValue] of Object.entries(item)) {
563
- if (fieldValue !== null && fieldValue !== void 0) {
564
- keys.push(`${tableName}#${fieldName}#${String(fieldValue)}`);
565
- if (typeof fieldValue === "string") {
566
- const maxPrefixLen = Math.min(fieldValue.length, 10);
567
- for (let i = 1; i <= maxPrefixLen; i++) {
568
- keys.push(
569
- `${tableName}#${fieldName}#prefix:${fieldValue.substring(0, i)}`
570
- );
571
- }
572
- }
573
- }
1051
+ function validateTablePrefix(tablePrefix) {
1052
+ if (typeof tablePrefix !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(tablePrefix) || tablePrefix.length > MAX_TABLE_PREFIX_LENGTH) {
1053
+ throw new Error(
1054
+ "createReactiveHandler tablePrefix must be a nonempty concrete value of at most 227 letters, numbers, hyphens, or underscores, starting with a letter or number"
1055
+ );
574
1056
  }
575
- return keys;
576
- }
577
- function operationToQueryMetadata(operation) {
578
- const normalizeFilters = (filters) => {
579
- return filters.map((f) => normalizeFilter(f));
580
- };
581
- const normalizeFilter = (filter) => {
582
- if (filter.type === "comparison") {
583
- const operatorMap = {
584
- "=": "eq",
585
- "<>": "ne",
586
- ">": "gt",
587
- ">=": "gte",
588
- "<": "lt",
589
- "<=": "lte"
590
- };
591
- return {
592
- ...filter,
593
- operator: operatorMap[filter.operator] ?? filter.operator
594
- };
595
- }
596
- if (filter.type === "function") {
597
- const operatorMap = {
598
- begins_with: "beginsWith",
599
- BETWEEN: "between"
600
- };
601
- return {
602
- ...filter,
603
- operator: operatorMap[filter.operator] ?? filter.operator
604
- };
605
- }
606
- if (filter.conditions) {
607
- const operatorMap = {
608
- AND: "and",
609
- OR: "or",
610
- NOT: "not"
611
- };
612
- return {
613
- ...filter,
614
- operator: operatorMap[filter.operator] ?? filter.operator,
615
- conditions: normalizeFilters(filter.conditions)
616
- };
617
- }
618
- return filter;
619
- };
620
- return {
621
- tableName: operation.tableName,
622
- indexName: operation.indexName,
623
- filterConditions: normalizeFilters(operation.filters),
624
- sortField: operation.sortField,
625
- sortOrder: operation.sortOrder,
626
- limit: operation.limit
627
- };
628
1057
  }
629
- var DependencyTracker = class {
630
- operations = [];
631
- /**
632
- * Track a query operation
633
- */
634
- track(operation) {
635
- this.operations.push(operation);
636
- }
637
- /**
638
- * Get all tracked operations
639
- */
640
- getOperations() {
641
- return [...this.operations];
642
- }
643
- /**
644
- * Extract all dependencies from tracked operations
645
- */
646
- extractAll() {
647
- const dependencies = [];
648
- for (const operation of this.operations) {
649
- dependencies.push(...extractDependencies(operation));
650
- }
651
- return dependencies;
652
- }
653
- /**
654
- * Get all dependency keys for the inverted index
655
- */
656
- getDependencyKeys() {
657
- return this.extractAll().map(createDependencyKey);
658
- }
659
- /**
660
- * Get query metadata for the first tracked operation.
661
- * Used for storing subscription state.
662
- */
663
- getQueryMetadata() {
664
- if (this.operations.length === 0) return null;
665
- return operationToQueryMetadata(this.operations[0]);
666
- }
667
- /**
668
- * Get the primary key field from the first operation
669
- */
670
- getPkField() {
671
- if (this.operations.length === 0) return null;
672
- return this.operations[0].pkField;
673
- }
674
- /**
675
- * Get the sort key field from the first operation
676
- */
677
- getSkField() {
678
- if (this.operations.length === 0) return void 0;
679
- return this.operations[0].skField;
1058
+ function validateKeyString(name, value, maxBytes) {
1059
+ if (typeof value !== "string" || value.length === 0) {
1060
+ throw requestError(`${name} must be a non-empty string`, 400);
680
1061
  }
681
- /**
682
- * Clear tracked operations
683
- */
684
- clear() {
685
- this.operations = [];
1062
+ if (Buffer.byteLength(value, "utf8") > maxBytes) {
1063
+ throw requestError(`${name} exceeds DynamoDB's UTF-8 key length limit`, 400);
686
1064
  }
687
- };
688
- var { applyPatch, compare } = jsonpatch;
689
- function generatePatches(oldValue, newValue) {
690
- const operations = compare(
691
- oldValue,
692
- newValue
693
- );
694
- return operations.map((op) => ({
695
- op: op.op,
696
- path: op.path,
697
- value: "value" in op ? op.value : void 0,
698
- from: "from" in op ? op.from : void 0
699
- }));
700
1065
  }
701
- function applyPatches(document, patches) {
702
- const operations = patches.map((patch) => {
703
- const op = {
704
- op: patch.op,
705
- path: patch.path
706
- };
707
- if ("value" in patch && patch.value !== void 0) {
708
- op.value = patch.value;
709
- }
710
- if ("from" in patch && patch.from !== void 0) {
711
- op.from = patch.from;
712
- }
713
- return op;
714
- });
715
- const result = applyPatch(
716
- structuredClone(document),
717
- operations,
718
- true,
719
- // Validate operations
720
- false
721
- // Don't mutate the original
1066
+ function validateRequest(request) {
1067
+ validateKeyString(
1068
+ "connectionId",
1069
+ request.connectionId,
1070
+ DYNAMODB_PARTITION_KEY_MAX_BYTES2
722
1071
  );
723
- return result.newDocument;
724
- }
725
- function hasChanges(oldValue, newValue) {
726
- const patches = generatePatches(oldValue, newValue);
727
- return patches.length > 0;
728
- }
729
- function optimizePatches(patches) {
730
- const seen = /* @__PURE__ */ new Set();
731
- const optimized = [];
732
- for (let i = patches.length - 1; i >= 0; i--) {
733
- const patch = patches[i];
734
- if (!seen.has(patch.path)) {
735
- seen.add(patch.path);
736
- optimized.unshift(patch);
1072
+ if (request.type === "subscribe" || request.type === "unsubscribe") {
1073
+ validateKeyString(
1074
+ "subscriptionId",
1075
+ request.subscriptionId,
1076
+ DYNAMODB_SORT_KEY_MAX_BYTES
1077
+ );
1078
+ validateKeyString(
1079
+ "registrationId",
1080
+ request.registrationId,
1081
+ DYNAMODB_SORT_KEY_MAX_BYTES
1082
+ );
1083
+ if (Buffer.byteLength(
1084
+ `${request.connectionId}#${request.subscriptionId}`,
1085
+ "utf8"
1086
+ ) > DYNAMODB_SORT_KEY_MAX_BYTES) {
1087
+ throw requestError(
1088
+ "connectionId and subscriptionId exceed DynamoDB dependency key limits",
1089
+ 400
1090
+ );
1091
+ }
1092
+ if (!Number.isInteger(request.generation) || request.generation < 0) {
1093
+ throw requestError("generation must be a non-negative integer", 400);
737
1094
  }
738
1095
  }
739
- return optimized;
740
1096
  }
741
- function batchPatches(patchSets) {
742
- const allPatches = patchSets.flat();
743
- return optimizePatches(allPatches);
1097
+ function assertEntrySize(entry) {
1098
+ if (Buffer.byteLength(JSON.stringify(entry), "utf8") > MAX_QUERY_ENTRY_BYTES) {
1099
+ throw new Error("Subscription result is too large to store safely");
1100
+ }
744
1101
  }
745
1102
  function createReactiveHandler(config) {
746
- const ttlSeconds = config.ttlSeconds ?? 3600;
747
- const prefix = config.tablePrefix ? `${config.tablePrefix}-` : "";
1103
+ validateTablePrefix(config.tablePrefix);
1104
+ const ttlSeconds = config.ttlSeconds ?? DEFAULT_CONNECTION_LEASE_SECONDS;
1105
+ const prefix = `${config.tablePrefix}-`;
748
1106
  const connectionsTable = `${prefix}${SystemTableNames.connections}`;
749
1107
  const dependenciesTable = `${prefix}${SystemTableNames.dependencies}`;
750
1108
  const queriesTable = `${prefix}${SystemTableNames.queries}`;
751
- const ddbClient = new DynamoDBClient({
752
- region: config.dbConfig?.region ?? process.env.AWS_REGION
1109
+ const ddbClient = config.dbConfig?.client ?? new DynamoDBClient({
1110
+ region: config.dbConfig?.region ?? process.env.AWS_REGION,
1111
+ endpoint: config.dbConfig?.endpoint
753
1112
  });
754
- const docClient = DynamoDBDocumentClient.from(ddbClient, {
1113
+ const docClient = config.dbConfig?.docClient ?? DynamoDBDocumentClient.from(ddbClient, {
755
1114
  marshallOptions: { removeUndefinedValues: true }
756
1115
  });
1116
+ const dbConfig = {
1117
+ ...config.dbConfig,
1118
+ client: ddbClient,
1119
+ docClient
1120
+ };
1121
+ async function authorize(requestInfo, context) {
1122
+ const response = await docClient.send(
1123
+ new GetCommand({
1124
+ TableName: connectionsTable,
1125
+ Key: { connectionId: requestInfo.connectionId },
1126
+ ConsistentRead: true
1127
+ })
1128
+ );
1129
+ const parsed = SystemSchemas.connections.safeParse(response.Item);
1130
+ const now = Math.floor(Date.now() / 1e3);
1131
+ if (!parsed.success || parsed.data.status !== "open" || parsed.data.ttl <= now) {
1132
+ throw requestError("Connection is not open", 403);
1133
+ }
1134
+ if (!await config.authorizeConnection({
1135
+ context,
1136
+ connection: parsed.data,
1137
+ requestInfo
1138
+ })) {
1139
+ throw requestError("Connection is not authorized", 403);
1140
+ }
1141
+ const refreshedTtl = now + ttlSeconds;
1142
+ try {
1143
+ await docClient.send(
1144
+ new UpdateCommand({
1145
+ TableName: connectionsTable,
1146
+ Key: { connectionId: requestInfo.connectionId },
1147
+ UpdateExpression: "SET #ttl = :ttl",
1148
+ ConditionExpression: "#status = :open AND #ttl > :now AND connectedAt = :connectedAt",
1149
+ ExpressionAttributeNames: { "#status": "status", "#ttl": "ttl" },
1150
+ ExpressionAttributeValues: {
1151
+ ":open": "open",
1152
+ ":now": now,
1153
+ ":ttl": refreshedTtl,
1154
+ ":connectedAt": parsed.data.connectedAt
1155
+ }
1156
+ })
1157
+ );
1158
+ } catch (error2) {
1159
+ if (error2 instanceof Error && error2.name === "ConditionalCheckFailedException") {
1160
+ throw requestError("Connection is not open", 403);
1161
+ }
1162
+ throw error2;
1163
+ }
1164
+ return { ...parsed.data, ttl: refreshedTtl };
1165
+ }
757
1166
  async function handleRequest(request, headers) {
758
1167
  try {
759
- const connectionId = request.connectionId;
760
- debug("[reactive-handler] handleRequest:", {
761
- type: request.type,
762
- connectionId,
763
- path: "path" in request ? request.path : void 0
764
- });
765
- const ctx = await config.getContext({ connectionId, headers });
766
- const dependencyTracker = new DependencyTracker();
767
- const db = createDbContext(config.dbConfig ?? {}, dependencyTracker);
768
- const fullCtx = { ...ctx, db };
769
- switch (request.type) {
770
- case "subscribe":
771
- return await handleSubscribe(
772
- connectionId,
773
- request,
774
- fullCtx,
775
- dependencyTracker
776
- );
777
- case "unsubscribe":
778
- return await handleUnsubscribe(connectionId, request);
779
- case "call":
780
- return await handleCall(request, fullCtx);
781
- default:
782
- return {
783
- type: "error",
784
- message: `Unknown request type: ${request.type}`
785
- };
1168
+ validateRequest(request);
1169
+ const requestInfo = { connectionId: request.connectionId, headers };
1170
+ const context = await config.getContext(requestInfo);
1171
+ const connection = await authorize(requestInfo, context);
1172
+ const tracker = new DependencyTracker();
1173
+ const db = createDbContext(dbConfig, tracker);
1174
+ const fullContext = { ...context, db };
1175
+ if (request.type === "subscribe") {
1176
+ return await handleSubscribe(request, fullContext, tracker, connection);
786
1177
  }
787
- } catch (error) {
1178
+ if (request.type === "unsubscribe") {
1179
+ return await handleUnsubscribe(request);
1180
+ }
1181
+ return await handleCall(request, fullContext);
1182
+ } catch (error2) {
1183
+ const safeError = isSafeRequestError(error2) ? error2 : null;
1184
+ if (!safeError) error("[reactive-handler] Request failed", error2);
788
1185
  return {
789
1186
  type: "error",
790
- message: error instanceof Error ? error.message : "Unknown error",
791
- code: error.code,
792
- subscriptionId: "subscriptionId" in request ? request.subscriptionId : void 0
1187
+ message: safeError?.message ?? "Internal server error",
1188
+ code: safeError?.statusCode ?? 500,
1189
+ ..."subscriptionId" in request ? { subscriptionId: request.subscriptionId } : {}
793
1190
  };
794
1191
  }
795
1192
  }
796
- async function handleSubscribe(connectionId, request, ctx, dependencyTracker) {
797
- const result = await config.router.execute(
1193
+ async function activeSnapshot(request) {
1194
+ const response = await docClient.send(
1195
+ new GetCommand({
1196
+ TableName: queriesTable,
1197
+ Key: { pk: request.connectionId, sk: request.subscriptionId },
1198
+ ConsistentRead: true
1199
+ })
1200
+ );
1201
+ const query2 = SystemSchemas.queries.parse(response.Item);
1202
+ if (query2.status !== "active" || query2.generation !== request.generation || query2.registrationId !== request.registrationId) {
1203
+ throw new Error("Subscription registration was superseded");
1204
+ }
1205
+ return {
1206
+ type: "snapshot",
1207
+ subscriptionId: query2.subscriptionId,
1208
+ generation: query2.generation,
1209
+ version: query2.version,
1210
+ data: query2.lastResult
1211
+ };
1212
+ }
1213
+ async function handleSubscribe(request, context, tracker, connection) {
1214
+ const procedure = config.router.getProcedure(request.path);
1215
+ if (!procedure) throw requestError(`Procedure not found: ${request.path}`, 404);
1216
+ if (procedure.type !== "query") {
1217
+ throw requestError("Only query procedures can be subscribed to", 400);
1218
+ }
1219
+ const resolverResult = await config.router.execute(
798
1220
  request.path,
799
- ctx,
1221
+ context,
800
1222
  request.input
801
1223
  );
802
- const queryMetadata = dependencyTracker.getQueryMetadata();
803
- const dependencyKeys = dependencyTracker.getDependencyKeys();
804
- if (!queryMetadata) {
805
- warn("[reactive-handler] No query metadata captured for subscription");
1224
+ const operations = tracker.getOperations();
1225
+ const capturedResult = tracker.getCapturedResult();
1226
+ if (operations.length !== 1 || !capturedResult) {
1227
+ throw requestError(
1228
+ "A subscription must execute exactly one ctx.db query",
1229
+ 400
1230
+ );
1231
+ }
1232
+ if (!Array.isArray(resolverResult) || resolverResult !== capturedResult.reference || !isDeepStrictEqual(resolverResult, capturedResult.snapshot)) {
1233
+ throw requestError(
1234
+ "A subscription resolver must return its ctx.db query result unchanged",
1235
+ 400
1236
+ );
1237
+ }
1238
+ const queryMetadata = tracker.getQueryMetadata();
1239
+ if (!queryMetadata) throw new Error("Query metadata was not captured");
1240
+ const dependencies = tracker.extractAll();
1241
+ const uniqueDependencies = new Map(
1242
+ dependencies.map((dependency) => [createDependencyKey(dependency), dependency])
1243
+ );
1244
+ const dependencyKeys = [...uniqueDependencies.keys()];
1245
+ if (uniqueDependencies.size > MAX_TRANSACTION_DEPENDENCIES) {
1246
+ throw requestError("Query has too many reactive dependencies", 400);
806
1247
  }
807
1248
  const now = Date.now();
808
1249
  const ttl = Math.floor(now / 1e3) + ttlSeconds;
809
- const queryEntry = {
810
- pk: connectionId,
1250
+ const registering = {
1251
+ pk: request.connectionId,
811
1252
  sk: request.subscriptionId,
812
- connectionId,
1253
+ connectionId: request.connectionId,
813
1254
  subscriptionId: request.subscriptionId,
814
- queryMetadata: queryMetadata ?? {
815
- tableName: "",
816
- filterConditions: []
817
- },
818
- lastResult: result,
1255
+ status: "registering",
1256
+ generation: request.generation,
1257
+ registrationId: request.registrationId,
1258
+ queryMetadata,
1259
+ lastResult: [],
819
1260
  dependencies: dependencyKeys,
820
1261
  version: 0,
821
1262
  createdAt: now,
822
1263
  updatedAt: now,
823
1264
  ttl
824
1265
  };
825
- await docClient.send(
826
- new PutCommand({
827
- TableName: queriesTable,
828
- Item: queryEntry
829
- })
830
- );
831
- await Promise.all(
832
- dependencyKeys.map(
833
- (key) => docClient.send(
834
- new PutCommand({
835
- TableName: dependenciesTable,
836
- Item: {
837
- pk: key,
838
- sk: `${connectionId}#${request.subscriptionId}`,
839
- connectionId,
840
- subscriptionId: request.subscriptionId,
841
- ttl
842
- }
843
- })
844
- )
1266
+ let registrationEntry = registering;
1267
+ assertEntrySize(registering);
1268
+ const connectionCondition = {
1269
+ TableName: connectionsTable,
1270
+ Key: { connectionId: request.connectionId },
1271
+ ConditionExpression: "#connectionStatus = :open AND #connectionTtl > :now AND connectedAt = :connectedAt",
1272
+ ExpressionAttributeNames: {
1273
+ "#connectionStatus": "status",
1274
+ "#connectionTtl": "ttl"
1275
+ },
1276
+ ExpressionAttributeValues: {
1277
+ ":open": "open",
1278
+ ":now": Math.floor(Date.now() / 1e3),
1279
+ ":connectedAt": connection.connectedAt
1280
+ }
1281
+ };
1282
+ async function renewRegistration(query2) {
1283
+ const dependencySk = `${request.connectionId}#${request.subscriptionId}`;
1284
+ await docClient.send(
1285
+ new TransactWriteCommand({
1286
+ TransactItems: [
1287
+ { ConditionCheck: connectionCondition },
1288
+ {
1289
+ Update: {
1290
+ TableName: queriesTable,
1291
+ Key: { pk: query2.pk, sk: query2.sk },
1292
+ UpdateExpression: "SET #ttl = :ttl, updatedAt = :updatedAt",
1293
+ ConditionExpression: "#status = :status AND #generation = :generation AND registrationId = :registrationId",
1294
+ ExpressionAttributeNames: {
1295
+ "#status": "status",
1296
+ "#generation": "generation",
1297
+ "#ttl": "ttl"
1298
+ },
1299
+ ExpressionAttributeValues: {
1300
+ ":status": query2.status,
1301
+ ":generation": query2.generation,
1302
+ ":registrationId": query2.registrationId,
1303
+ ":ttl": ttl,
1304
+ ":updatedAt": Date.now()
1305
+ }
1306
+ }
1307
+ },
1308
+ ...[...new Set(query2.dependencies)].map((dependency) => ({
1309
+ Update: {
1310
+ TableName: dependenciesTable,
1311
+ Key: { pk: dependency, sk: dependencySk },
1312
+ UpdateExpression: "SET #ttl = :ttl",
1313
+ ConditionExpression: "#generation = :generation AND registrationId = :registrationId",
1314
+ ExpressionAttributeNames: {
1315
+ "#generation": "generation",
1316
+ "#ttl": "ttl"
1317
+ },
1318
+ ExpressionAttributeValues: {
1319
+ ":generation": query2.generation,
1320
+ ":registrationId": query2.registrationId,
1321
+ ":ttl": ttl
1322
+ }
1323
+ }
1324
+ }))
1325
+ ]
1326
+ })
1327
+ );
1328
+ }
1329
+ try {
1330
+ await docClient.send(
1331
+ new TransactWriteCommand({
1332
+ TransactItems: [
1333
+ { ConditionCheck: connectionCondition },
1334
+ {
1335
+ Put: {
1336
+ TableName: queriesTable,
1337
+ Item: registering,
1338
+ ConditionExpression: "attribute_not_exists(pk) OR #generation < :generation",
1339
+ ExpressionAttributeNames: {
1340
+ "#generation": "generation"
1341
+ },
1342
+ ExpressionAttributeValues: {
1343
+ ":generation": request.generation
1344
+ }
1345
+ }
1346
+ },
1347
+ ...[...uniqueDependencies].map(([key, dependency]) => {
1348
+ const debugMetadata = parseDependencyKey(key) ?? dependency;
1349
+ return {
1350
+ Put: {
1351
+ TableName: dependenciesTable,
1352
+ Item: {
1353
+ pk: key,
1354
+ sk: `${request.connectionId}#${request.subscriptionId}`,
1355
+ connectionId: request.connectionId,
1356
+ subscriptionId: request.subscriptionId,
1357
+ tableName: debugMetadata.tableName,
1358
+ fieldName: debugMetadata.fieldName,
1359
+ fieldValue: debugMetadata.fieldValue,
1360
+ generation: request.generation,
1361
+ registrationId: request.registrationId,
1362
+ ttl
1363
+ }
1364
+ }
1365
+ };
1366
+ })
1367
+ ]
1368
+ })
1369
+ );
1370
+ } catch (error2) {
1371
+ const existing = await docClient.send(
1372
+ new GetCommand({
1373
+ TableName: queriesTable,
1374
+ Key: { pk: request.connectionId, sk: request.subscriptionId },
1375
+ ConsistentRead: true
1376
+ })
1377
+ );
1378
+ const query2 = existing.Item;
1379
+ if (!query2 || query2.generation !== request.generation || query2.registrationId !== request.registrationId || query2.status !== "registering" && query2.status !== "active") {
1380
+ throw error2;
1381
+ }
1382
+ if (query2.status === "active") {
1383
+ await renewRegistration(query2);
1384
+ return activeSnapshot(request);
1385
+ }
1386
+ registrationEntry = query2;
1387
+ try {
1388
+ await renewRegistration(query2);
1389
+ } catch (renewalError) {
1390
+ const winner = await matchingActiveSnapshot(request, connection);
1391
+ if (winner) return winner;
1392
+ throw renewalError;
1393
+ }
1394
+ }
1395
+ let baseline;
1396
+ try {
1397
+ baseline = await executeQueryMetadata(
1398
+ ddbClient,
1399
+ registrationEntry.queryMetadata
1400
+ );
1401
+ const active = {
1402
+ ...registrationEntry,
1403
+ status: "active",
1404
+ lastResult: baseline
1405
+ };
1406
+ assertEntrySize(active);
1407
+ } catch (error2) {
1408
+ const winner = await matchingActiveSnapshot(request, connection);
1409
+ if (winner) return winner;
1410
+ await faultRegistration(request);
1411
+ const winnerAfterFault = await matchingActiveSnapshot(request, connection);
1412
+ if (winnerAfterFault) return winnerAfterFault;
1413
+ throw error2;
1414
+ }
1415
+ try {
1416
+ await docClient.send(
1417
+ new UpdateCommand({
1418
+ TableName: queriesTable,
1419
+ Key: { pk: request.connectionId, sk: request.subscriptionId },
1420
+ UpdateExpression: "SET #status = :active, lastResult = :lastResult, updatedAt = :updatedAt",
1421
+ ConditionExpression: "#status = :registering AND #generation = :generation AND registrationId = :registrationId",
1422
+ ExpressionAttributeNames: {
1423
+ "#status": "status",
1424
+ "#generation": "generation"
1425
+ },
1426
+ ExpressionAttributeValues: {
1427
+ ":active": "active",
1428
+ ":registering": "registering",
1429
+ ":generation": request.generation,
1430
+ ":registrationId": request.registrationId,
1431
+ ":lastResult": baseline,
1432
+ ":updatedAt": Date.now()
1433
+ }
1434
+ })
1435
+ );
1436
+ } catch (error2) {
1437
+ const winner = await matchingActiveSnapshot(request, connection);
1438
+ if (winner) return winner;
1439
+ await faultRegistration(request);
1440
+ const winnerAfterFault = await matchingActiveSnapshot(request, connection);
1441
+ if (winnerAfterFault) return winnerAfterFault;
1442
+ throw error2;
1443
+ }
1444
+ return activeSnapshot(request);
1445
+ }
1446
+ async function matchingActiveSnapshot(request, connection) {
1447
+ const [queryResponse, connectionResponse] = await Promise.all([
1448
+ docClient.send(
1449
+ new GetCommand({
1450
+ TableName: queriesTable,
1451
+ Key: { pk: request.connectionId, sk: request.subscriptionId },
1452
+ ConsistentRead: true
1453
+ })
1454
+ ),
1455
+ docClient.send(
1456
+ new GetCommand({
1457
+ TableName: connectionsTable,
1458
+ Key: { connectionId: request.connectionId },
1459
+ ConsistentRead: true
1460
+ })
845
1461
  )
1462
+ ]);
1463
+ const query2 = queryResponse.Item;
1464
+ const currentConnection = SystemSchemas.connections.safeParse(
1465
+ connectionResponse.Item
846
1466
  );
847
- debug("[reactive-handler] Subscription created:", {
848
- connectionId,
849
- subscriptionId: request.subscriptionId,
850
- tableName: queryMetadata?.tableName,
851
- filterConditions: queryMetadata?.filterConditions,
852
- dependencies: dependencyKeys,
853
- isArrayResult: Array.isArray(result),
854
- resultCount: Array.isArray(result) ? result.length : 1
855
- });
1467
+ if (query2?.status !== "active" || query2.generation !== request.generation || query2.registrationId !== request.registrationId || !currentConnection.success || currentConnection.data.status !== "open" || currentConnection.data.connectedAt !== connection.connectedAt) {
1468
+ return null;
1469
+ }
856
1470
  return {
857
1471
  type: "snapshot",
858
- subscriptionId: request.subscriptionId,
859
- data: result
1472
+ subscriptionId: query2.subscriptionId,
1473
+ generation: query2.generation,
1474
+ version: query2.version,
1475
+ data: query2.lastResult
860
1476
  };
861
1477
  }
862
- async function handleUnsubscribe(connectionId, request) {
863
- const subResponse = await docClient.send(
1478
+ async function faultRegistration(request) {
1479
+ await docClient.send(
1480
+ new UpdateCommand({
1481
+ TableName: queriesTable,
1482
+ Key: { pk: request.connectionId, sk: request.subscriptionId },
1483
+ UpdateExpression: "SET #status = :faulted, updatedAt = :updatedAt",
1484
+ ConditionExpression: "#status = :registering AND #generation = :generation AND registrationId = :registrationId",
1485
+ ExpressionAttributeNames: {
1486
+ "#status": "status",
1487
+ "#generation": "generation"
1488
+ },
1489
+ ExpressionAttributeValues: {
1490
+ ":faulted": "faulted",
1491
+ ":registering": "registering",
1492
+ ":generation": request.generation,
1493
+ ":registrationId": request.registrationId,
1494
+ ":updatedAt": Date.now()
1495
+ }
1496
+ })
1497
+ ).catch(() => void 0);
1498
+ }
1499
+ async function handleUnsubscribe(request) {
1500
+ const response = await docClient.send(
864
1501
  new GetCommand({
865
1502
  TableName: queriesTable,
866
- Key: { pk: connectionId, sk: request.subscriptionId }
1503
+ Key: { pk: request.connectionId, sk: request.subscriptionId },
1504
+ ConsistentRead: true
1505
+ })
1506
+ );
1507
+ const query2 = response.Item;
1508
+ if (!query2) return { type: "result", data: { success: true } };
1509
+ if (query2.generation !== request.generation || query2.registrationId !== request.registrationId) {
1510
+ throw requestError("Unsubscribe generation is stale", 409);
1511
+ }
1512
+ await docClient.send(
1513
+ new UpdateCommand({
1514
+ TableName: queriesTable,
1515
+ Key: { pk: request.connectionId, sk: request.subscriptionId },
1516
+ UpdateExpression: "SET #status = :cancelled, updatedAt = :updatedAt REMOVE pendingDelivery",
1517
+ ConditionExpression: "#generation = :generation AND registrationId = :registrationId",
1518
+ ExpressionAttributeNames: {
1519
+ "#status": "status",
1520
+ "#generation": "generation"
1521
+ },
1522
+ ExpressionAttributeValues: {
1523
+ ":cancelled": "cancelled",
1524
+ ":generation": request.generation,
1525
+ ":registrationId": request.registrationId,
1526
+ ":updatedAt": Date.now()
1527
+ }
867
1528
  })
868
1529
  );
869
- if (subResponse.Item) {
870
- const queryEntry = subResponse.Item;
871
- await Promise.all(
872
- queryEntry.dependencies.map(
873
- (key) => docClient.send(
1530
+ await Promise.all(
1531
+ query2.dependencies.map(async (key) => {
1532
+ try {
1533
+ await docClient.send(
874
1534
  new DeleteCommand({
875
1535
  TableName: dependenciesTable,
876
1536
  Key: {
877
1537
  pk: key,
878
- sk: `${connectionId}#${request.subscriptionId}`
879
- }
1538
+ sk: `${request.connectionId}#${request.subscriptionId}`
1539
+ },
1540
+ ConditionExpression: "#generation = :generation",
1541
+ ExpressionAttributeNames: { "#generation": "generation" },
1542
+ ExpressionAttributeValues: { ":generation": request.generation }
880
1543
  })
881
- )
882
- )
883
- );
884
- }
885
- await docClient.send(
886
- new DeleteCommand({
887
- TableName: queriesTable,
888
- Key: { pk: connectionId, sk: request.subscriptionId }
1544
+ );
1545
+ } catch (error2) {
1546
+ if (!(error2 instanceof Error) || error2.name !== "ConditionalCheckFailedException") {
1547
+ throw error2;
1548
+ }
1549
+ }
889
1550
  })
890
1551
  );
891
- debug("[reactive-handler] Subscription removed:", {
892
- connectionId,
893
- subscriptionId: request.subscriptionId
894
- });
895
- return {
896
- type: "result",
897
- data: { success: true }
898
- };
1552
+ return { type: "result", data: { success: true } };
899
1553
  }
900
- async function handleCall(request, ctx) {
901
- const result = await config.router.execute(
902
- request.path,
903
- ctx,
904
- request.input
905
- );
1554
+ async function handleCall(request, context) {
1555
+ const procedure = config.router.getProcedure(request.path);
1556
+ if (!procedure) throw requestError(`Procedure not found: ${request.path}`, 404);
1557
+ if (procedure.type !== "mutation") {
1558
+ throw requestError("Call requests require a mutation procedure", 400);
1559
+ }
906
1560
  return {
907
1561
  type: "result",
908
- data: result
1562
+ data: await config.router.execute(request.path, context, request.input)
909
1563
  };
910
1564
  }
911
1565
  async function registerConnection(connectionId, context) {
912
- const now = Date.now();
913
- const ttl = Math.floor(now / 1e3) + ttlSeconds;
914
- const connectionEntry = {
1566
+ validateKeyString(
1567
+ "connectionId",
915
1568
  connectionId,
916
- context,
917
- connectedAt: now,
918
- ttl
919
- };
1569
+ DYNAMODB_PARTITION_KEY_MAX_BYTES2
1570
+ );
1571
+ const now = Date.now();
920
1572
  await docClient.send(
921
1573
  new PutCommand({
922
1574
  TableName: connectionsTable,
923
- Item: connectionEntry
1575
+ Item: {
1576
+ connectionId,
1577
+ status: "open",
1578
+ context,
1579
+ connectedAt: now,
1580
+ ttl: Math.floor(now / 1e3) + ttlSeconds
1581
+ }
924
1582
  })
925
1583
  );
926
- debug("[reactive-handler] Connection registered:", connectionEntry);
927
1584
  }
928
1585
  async function unregisterConnection(connectionId) {
929
- const subsResponse = await docClient.send(
930
- new QueryCommand({
931
- TableName: queriesTable,
932
- KeyConditionExpression: "pk = :pk",
933
- ExpressionAttributeValues: { ":pk": connectionId }
934
- })
935
- );
936
- const subscriptions = subsResponse.Items ?? [];
937
- await Promise.all(
938
- subscriptions.map(async (sub) => {
939
- const deps = Array.isArray(sub.dependencies) ? sub.dependencies : [];
940
- await Promise.all([
941
- ...deps.map(
942
- (depKey) => docClient.send(
943
- new DeleteCommand({
944
- TableName: dependenciesTable,
945
- Key: { pk: depKey, sk: `${connectionId}#${sub.sk}` }
946
- })
947
- )
948
- ),
949
- docClient.send(
950
- new DeleteCommand({
951
- TableName: queriesTable,
952
- Key: { pk: connectionId, sk: sub.sk }
953
- })
954
- )
955
- ]);
956
- })
1586
+ validateKeyString(
1587
+ "connectionId",
1588
+ connectionId,
1589
+ DYNAMODB_PARTITION_KEY_MAX_BYTES2
957
1590
  );
958
1591
  await docClient.send(
959
- new DeleteCommand({
1592
+ new UpdateCommand({
960
1593
  TableName: connectionsTable,
961
- Key: { connectionId }
1594
+ Key: { connectionId },
1595
+ UpdateExpression: "SET #status = :closed",
1596
+ ConditionExpression: "attribute_exists(connectionId)",
1597
+ ExpressionAttributeNames: { "#status": "status" },
1598
+ ExpressionAttributeValues: { ":closed": "closed" }
962
1599
  })
963
1600
  );
964
- debug(
965
- "[reactive-handler] Connection unregistered:",
966
- connectionId,
967
- `(${subscriptions.length} subscriptions removed)`
968
- );
1601
+ let lastKey;
1602
+ do {
1603
+ const response = await docClient.send(
1604
+ new QueryCommand({
1605
+ TableName: queriesTable,
1606
+ KeyConditionExpression: "pk = :pk",
1607
+ ExpressionAttributeValues: { ":pk": connectionId },
1608
+ ConsistentRead: true,
1609
+ ExclusiveStartKey: lastKey
1610
+ })
1611
+ );
1612
+ for (const item of response.Items ?? []) {
1613
+ const query2 = item;
1614
+ await handleUnsubscribe({
1615
+ type: "unsubscribe",
1616
+ connectionId,
1617
+ subscriptionId: query2.subscriptionId,
1618
+ generation: query2.generation,
1619
+ registrationId: query2.registrationId
1620
+ });
1621
+ }
1622
+ lastKey = response.LastEvaluatedKey;
1623
+ } while (lastKey);
1624
+ debug("[reactive-handler] Connection unregistered");
969
1625
  }
970
1626
  async function query(path, input, headers) {
971
- const ctx = await config.getContext({ connectionId: "query", headers });
972
- const dependencyTracker = new DependencyTracker();
973
- const db = createDbContext(config.dbConfig ?? {}, dependencyTracker);
974
- const fullCtx = { ...ctx, db };
975
- return config.router.execute(path, fullCtx, input);
1627
+ const procedure = config.router.getProcedure(path);
1628
+ if (!procedure) throw requestError(`Procedure not found: ${path}`, 404);
1629
+ if (procedure.type !== "query") {
1630
+ throw requestError("One-time query requests require a query procedure", 400);
1631
+ }
1632
+ const context = await config.getContext({ connectionId: "query", headers });
1633
+ const db = createDbContext(dbConfig);
1634
+ return config.router.execute(
1635
+ path,
1636
+ { ...context, db },
1637
+ input
1638
+ );
976
1639
  }
977
1640
  function createCaller() {
978
1641
  function buildProxy(path = []) {
979
- return new Proxy(() => {
980
- }, {
981
- get(_target, prop) {
982
- return buildProxy([...path, prop]);
1642
+ return new Proxy(() => void 0, {
1643
+ get(_target, property) {
1644
+ return buildProxy([...path, property]);
983
1645
  },
984
1646
  apply(_target, _thisArg, args) {
985
- const procedurePath = path.join(".");
986
- return query(procedurePath, args[0]);
1647
+ return query(path.join("."), args[0]);
987
1648
  }
988
1649
  });
989
1650
  }
@@ -997,74 +1658,37 @@ function createReactiveHandler(config) {
997
1658
  createCaller
998
1659
  };
999
1660
  }
1000
- function extractTableName(record) {
1001
- const arn = record.eventSourceARN;
1002
- if (!arn) return null;
1003
- const match = arn.match(/table\/([^/]+)/);
1004
- return match ? match[1] : null;
1005
- }
1006
- function buildWhereClause(conditions) {
1007
- if (conditions.length === 0) return "";
1008
- const clauses = conditions.map((c) => buildConditionClause(c)).filter(Boolean);
1009
- if (clauses.length === 0) return "";
1010
- return `WHERE ${clauses.join(" AND ")}`;
1011
- }
1012
- function buildConditionClause(condition) {
1013
- const { type, operator, field, value, value2, conditions } = condition;
1014
- if (type === "comparison" && field) {
1015
- const escapedValue = escapeValue(value);
1016
- switch (operator) {
1017
- case "eq":
1018
- return `"${field}" = ${escapedValue}`;
1019
- case "ne":
1020
- return `"${field}" <> ${escapedValue}`;
1021
- case "gt":
1022
- return `"${field}" > ${escapedValue}`;
1023
- case "gte":
1024
- return `"${field}" >= ${escapedValue}`;
1025
- case "lt":
1026
- return `"${field}" < ${escapedValue}`;
1027
- case "lte":
1028
- return `"${field}" <= ${escapedValue}`;
1029
- case "between":
1030
- return `"${field}" BETWEEN ${escapedValue} AND ${escapeValue(value2)}`;
1031
- default:
1032
- return "";
1033
- }
1034
- }
1035
- if (type === "function" && field) {
1036
- const escapedValue = escapeValue(value);
1037
- switch (operator) {
1038
- case "beginsWith":
1039
- return `begins_with("${field}", ${escapedValue})`;
1040
- case "contains":
1041
- return `contains("${field}", ${escapedValue})`;
1042
- default:
1043
- return "";
1044
- }
1045
- }
1046
- if (type === "logical" && conditions) {
1047
- const subclauses = conditions.map((c) => buildConditionClause(c)).filter(Boolean);
1048
- if (subclauses.length === 0) return "";
1049
- switch (operator) {
1050
- case "and":
1051
- return `(${subclauses.join(" AND ")})`;
1052
- case "or":
1053
- return `(${subclauses.join(" OR ")})`;
1054
- case "not":
1055
- return subclauses.length > 0 ? `NOT (${subclauses[0]})` : "";
1056
- default:
1057
- return "";
1058
- }
1661
+ var { compare } = jsonpatch;
1662
+ function generatePatches(oldValue, newValue) {
1663
+ const operations = compare(
1664
+ oldValue,
1665
+ newValue
1666
+ );
1667
+ return operations.map((op) => ({
1668
+ op: op.op,
1669
+ path: op.path,
1670
+ ..."value" in op ? { value: op.value } : {},
1671
+ ..."from" in op ? { from: op.from } : {}
1672
+ }));
1673
+ }
1674
+
1675
+ // ../server/src/handlers/stream-utils.ts
1676
+ var MAX_PATCH_MESSAGE_BYTES = 30 * 1024;
1677
+ var MAX_QUERY_ENTRY_BYTES2 = 300 * 1024;
1678
+ var MAX_PROCESS_ATTEMPTS = 4;
1679
+ var REGISTRATION_WINDOW_MS = 60 * 1e3;
1680
+ var RetryableSubscriptionError = class extends Error {
1681
+ constructor(message) {
1682
+ super(message);
1683
+ this.name = "RetryableSubscriptionError";
1059
1684
  }
1060
- return "";
1685
+ };
1686
+ function extractTableName(record) {
1687
+ const match = record.eventSourceARN?.match(/table\/([^/]+)/);
1688
+ return match?.[1] ?? null;
1061
1689
  }
1062
- function escapeValue(value) {
1063
- if (value === null || value === void 0) return "NULL";
1064
- if (typeof value === "string") return `'${value.replace(/'/g, "''")}'`;
1065
- if (typeof value === "number") return String(value);
1066
- if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
1067
- return `'${String(value).replace(/'/g, "''")}'`;
1690
+ function isConditionalFailure(error2) {
1691
+ return error2 instanceof Error && error2.name === "ConditionalCheckFailedException";
1068
1692
  }
1069
1693
  function createStreamUtils(config) {
1070
1694
  const {
@@ -1074,435 +1698,624 @@ function createStreamUtils(config) {
1074
1698
  dependenciesTable,
1075
1699
  queriesTable
1076
1700
  } = config;
1077
- const getApiClient = /* @__PURE__ */ (() => {
1078
- let client;
1079
- return () => {
1080
- client ??= new ApiGatewayManagementApiClient({
1081
- endpoint: config.apiGatewayEndpoint
1082
- });
1083
- return client;
1084
- };
1085
- })();
1701
+ let apiClient = config.apiClient;
1702
+ function getApiClient() {
1703
+ apiClient ??= new ApiGatewayManagementApiClient({
1704
+ endpoint: config.apiGatewayEndpoint
1705
+ });
1706
+ return apiClient;
1707
+ }
1086
1708
  async function findAffectedSubscriptions(dependencyKey) {
1087
- try {
1709
+ const subscriptions = [];
1710
+ let lastKey;
1711
+ do {
1088
1712
  const response = await docClient.send(
1089
1713
  new QueryCommand({
1090
1714
  TableName: dependenciesTable,
1091
1715
  KeyConditionExpression: "pk = :pk",
1092
- ExpressionAttributeValues: { ":pk": dependencyKey }
1716
+ ExpressionAttributeValues: { ":pk": dependencyKey },
1717
+ ConsistentRead: true,
1718
+ ExclusiveStartKey: lastKey
1093
1719
  })
1094
1720
  );
1095
- return (response.Items ?? []).map((item) => ({
1096
- connectionId: item.connectionId,
1097
- subscriptionId: item.subscriptionId
1098
- }));
1099
- } catch (error$1) {
1100
- error("Error finding affected subscriptions:", error$1);
1101
- return [];
1102
- }
1721
+ subscriptions.push(
1722
+ ...(response.Items ?? []).map((item) => ({
1723
+ connectionId: item.connectionId,
1724
+ subscriptionId: item.subscriptionId
1725
+ }))
1726
+ );
1727
+ lastKey = response.LastEvaluatedKey;
1728
+ } while (lastKey);
1729
+ return subscriptions;
1103
1730
  }
1104
1731
  async function executeQueryFromMetadata(metadata) {
1105
- const { tableName, filterConditions, sortOrder, limit } = metadata;
1106
- const whereClause = buildWhereClause(filterConditions);
1107
- const orderClause = sortOrder === "desc" ? "ORDER BY SK DESC" : "";
1108
- const limitClause = limit ? `LIMIT ${limit}` : "";
1109
- const statement = `SELECT * FROM "${tableName}" ${whereClause} ${orderClause} ${limitClause}`.trim();
1110
- try {
1111
- const result = await ddbClient.send(
1112
- new ExecuteStatementCommand({ Statement: statement })
1113
- );
1114
- return (result.Items ?? []).map(
1115
- (item) => unmarshall(item)
1116
- );
1117
- } catch (error$1) {
1118
- error("Error executing query from metadata:", error$1);
1119
- error("Statement:", statement);
1120
- return [];
1121
- }
1732
+ return executeQueryMetadata(ddbClient, metadata);
1122
1733
  }
1123
- async function sendPatch(connectionId, subscriptionId, patches) {
1124
- const message = JSON.stringify({
1125
- type: "patch",
1126
- subscriptionId,
1127
- patches
1128
- });
1734
+ function deliveryMessage(query, delivery) {
1735
+ return {
1736
+ type: delivery.type,
1737
+ subscriptionId: query.subscriptionId,
1738
+ generation: delivery.generation,
1739
+ fromVersion: delivery.fromVersion,
1740
+ toVersion: delivery.toVersion,
1741
+ messageId: delivery.messageId,
1742
+ ...delivery.type === "patch" ? { patches: delivery.patches } : {}
1743
+ };
1744
+ }
1745
+ async function sendDelivery(query, delivery) {
1746
+ await getApiClient().send(
1747
+ new PostToConnectionCommand({
1748
+ ConnectionId: query.connectionId,
1749
+ Data: Buffer.from(JSON.stringify(deliveryMessage(query, delivery)))
1750
+ })
1751
+ );
1752
+ }
1753
+ async function clearDelivery(query, delivery) {
1129
1754
  try {
1130
- await getApiClient().send(
1131
- new PostToConnectionCommand({
1132
- ConnectionId: connectionId,
1133
- Data: Buffer.from(message)
1755
+ await docClient.send(
1756
+ new UpdateCommand({
1757
+ TableName: queriesTable,
1758
+ Key: { pk: query.pk, sk: query.sk },
1759
+ UpdateExpression: "REMOVE pendingDelivery",
1760
+ ConditionExpression: "#status = :status AND #generation = :generation AND registrationId = :registrationId AND pendingDelivery.messageId = :messageId",
1761
+ ExpressionAttributeNames: {
1762
+ "#status": "status",
1763
+ "#generation": "generation"
1764
+ },
1765
+ ExpressionAttributeValues: {
1766
+ ":status": query.status,
1767
+ ":generation": query.generation,
1768
+ ":registrationId": query.registrationId,
1769
+ ":messageId": delivery.messageId
1770
+ }
1134
1771
  })
1135
1772
  );
1136
- } catch (error$1) {
1137
- if (error$1 instanceof GoneException) {
1138
- throw error$1;
1139
- }
1140
- error(`Error sending patch to ${connectionId}:`, error$1);
1773
+ } catch (error2) {
1774
+ if (!isConditionalFailure(error2)) throw error2;
1141
1775
  }
1142
1776
  }
1777
+ async function cleanupSubscriptionDependencies(query) {
1778
+ await Promise.all(
1779
+ query.dependencies.map(async (dependency) => {
1780
+ try {
1781
+ await docClient.send(
1782
+ new DeleteCommand({
1783
+ TableName: dependenciesTable,
1784
+ Key: {
1785
+ pk: dependency,
1786
+ sk: `${query.connectionId}#${query.subscriptionId}`
1787
+ },
1788
+ ConditionExpression: "#generation = :generation AND registrationId = :registrationId",
1789
+ ExpressionAttributeNames: { "#generation": "generation" },
1790
+ ExpressionAttributeValues: {
1791
+ ":generation": query.generation,
1792
+ ":registrationId": query.registrationId
1793
+ }
1794
+ })
1795
+ );
1796
+ } catch (error2) {
1797
+ if (!isConditionalFailure(error2)) throw error2;
1798
+ }
1799
+ })
1800
+ );
1801
+ }
1143
1802
  async function processSubscription(connectionId, subscriptionId) {
1144
1803
  try {
1145
- const response = await docClient.send(
1146
- new GetCommand({
1147
- TableName: queriesTable,
1148
- Key: { pk: connectionId, sk: subscriptionId }
1149
- })
1150
- );
1151
- const queryState = response.Item;
1152
- if (!queryState) {
1153
- warn(`Subscription not found: ${connectionId}/${subscriptionId}`);
1154
- return;
1155
- }
1156
- const rawNewResult = await executeQueryFromMetadata(
1157
- queryState.queryMetadata
1158
- );
1159
- const isSingleItemResult = !Array.isArray(queryState.lastResult);
1160
- const newResult = isSingleItemResult ? rawNewResult[0] ?? null : rawNewResult;
1161
- if (!hasChanges(queryState.lastResult, newResult)) {
1162
- info(
1163
- `[stream] No changes for ${subscriptionId} (version ${queryState.version ?? 0})`
1164
- );
1165
- return;
1166
- }
1167
- const patches = generatePatches(queryState.lastResult, newResult);
1168
- info(
1169
- `[stream] ${subscriptionId}: ${patches.length} patch ops, version ${queryState.version ?? 0} \u2192 ${(queryState.version ?? 0) + 1}`,
1170
- {
1171
- ops: patches.map((p) => `${p.op} ${p.path}`).join(", ")
1172
- }
1173
- );
1174
- const currentVersion = queryState.version ?? 0;
1175
- try {
1176
- await docClient.send(
1177
- new PutCommand({
1804
+ for (let attempt = 0; attempt < MAX_PROCESS_ATTEMPTS; attempt++) {
1805
+ const response = await docClient.send(
1806
+ new GetCommand({
1178
1807
  TableName: queriesTable,
1179
- Item: {
1180
- ...queryState,
1181
- lastResult: newResult,
1182
- version: currentVersion + 1,
1183
- updatedAt: Date.now()
1184
- },
1185
- ConditionExpression: "attribute_not_exists(version) OR version = :v",
1186
- ExpressionAttributeValues: { ":v": currentVersion }
1808
+ Key: { pk: connectionId, sk: subscriptionId },
1809
+ ConsistentRead: true
1187
1810
  })
1188
1811
  );
1189
- } catch (error) {
1190
- if (error instanceof Error && error.name === "ConditionalCheckFailedException") {
1191
- info(
1192
- `[stream] Version conflict for ${subscriptionId} at version ${currentVersion}, skipping (another invocation won)`
1193
- );
1812
+ const query = response.Item;
1813
+ if (!query || query.status === "cancelled") {
1814
+ return;
1815
+ }
1816
+ if (query.pendingDelivery) {
1817
+ await sendDelivery(query, query.pendingDelivery);
1818
+ await clearDelivery(query, query.pendingDelivery);
1819
+ if (query.status === "faulted") {
1820
+ await cleanupSubscriptionDependencies(query);
1821
+ return;
1822
+ }
1823
+ continue;
1824
+ }
1825
+ if (query.status === "faulted") {
1826
+ await cleanupSubscriptionDependencies(query);
1194
1827
  return;
1195
1828
  }
1196
- throw error;
1829
+ if (query.status === "registering") {
1830
+ if (query.createdAt <= Date.now() - REGISTRATION_WINDOW_MS) {
1831
+ try {
1832
+ await docClient.send(
1833
+ new UpdateCommand({
1834
+ TableName: queriesTable,
1835
+ Key: { pk: query.pk, sk: query.sk },
1836
+ UpdateExpression: "SET #status = :faulted, updatedAt = :updatedAt",
1837
+ ConditionExpression: "#status = :registering AND #generation = :generation AND registrationId = :registrationId AND createdAt = :createdAt AND createdAt <= :registrationCutoff",
1838
+ ExpressionAttributeNames: {
1839
+ "#status": "status",
1840
+ "#generation": "generation"
1841
+ },
1842
+ ExpressionAttributeValues: {
1843
+ ":faulted": "faulted",
1844
+ ":registering": "registering",
1845
+ ":generation": query.generation,
1846
+ ":registrationId": query.registrationId,
1847
+ ":createdAt": query.createdAt,
1848
+ ":registrationCutoff": Date.now() - REGISTRATION_WINDOW_MS,
1849
+ ":updatedAt": Date.now()
1850
+ }
1851
+ })
1852
+ );
1853
+ return;
1854
+ } catch (error2) {
1855
+ if (isConditionalFailure(error2)) continue;
1856
+ throw error2;
1857
+ }
1858
+ }
1859
+ throw new RetryableSubscriptionError(
1860
+ `Subscription ${connectionId}/${subscriptionId} is still registering`
1861
+ );
1862
+ }
1863
+ const newResult = await executeQueryFromMetadata(query.queryMetadata);
1864
+ const patches = generatePatches(query.lastResult, newResult);
1865
+ if (patches.length === 0) return;
1866
+ const fromVersion = query.version;
1867
+ const toVersion = fromVersion + 1;
1868
+ const patchDelivery = {
1869
+ type: "patch",
1870
+ generation: query.generation,
1871
+ fromVersion,
1872
+ toVersion,
1873
+ messageId: randomUUID(),
1874
+ patches
1875
+ };
1876
+ const pendingDelivery = Buffer.byteLength(
1877
+ JSON.stringify(deliveryMessage(query, patchDelivery)),
1878
+ "utf8"
1879
+ ) > MAX_PATCH_MESSAGE_BYTES ? {
1880
+ type: "resync",
1881
+ generation: query.generation,
1882
+ fromVersion,
1883
+ toVersion,
1884
+ messageId: patchDelivery.messageId
1885
+ } : patchDelivery;
1886
+ const persisted = {
1887
+ ...query,
1888
+ lastResult: newResult,
1889
+ version: toVersion,
1890
+ pendingDelivery
1891
+ };
1892
+ if (Buffer.byteLength(JSON.stringify(persisted), "utf8") > MAX_QUERY_ENTRY_BYTES2) {
1893
+ const faultDelivery = {
1894
+ type: "resync",
1895
+ generation: query.generation,
1896
+ fromVersion,
1897
+ toVersion,
1898
+ messageId: pendingDelivery.messageId
1899
+ };
1900
+ try {
1901
+ await docClient.send(
1902
+ new UpdateCommand({
1903
+ TableName: queriesTable,
1904
+ Key: { pk: query.pk, sk: query.sk },
1905
+ UpdateExpression: "SET #status = :faulted, pendingDelivery = :pendingDelivery, updatedAt = :updatedAt",
1906
+ ConditionExpression: "#status = :active AND #generation = :generation AND registrationId = :registrationId AND #version = :fromVersion AND attribute_not_exists(pendingDelivery)",
1907
+ ExpressionAttributeNames: {
1908
+ "#status": "status",
1909
+ "#generation": "generation",
1910
+ "#version": "version"
1911
+ },
1912
+ ExpressionAttributeValues: {
1913
+ ":active": "active",
1914
+ ":faulted": "faulted",
1915
+ ":generation": query.generation,
1916
+ ":registrationId": query.registrationId,
1917
+ ":fromVersion": fromVersion,
1918
+ ":pendingDelivery": faultDelivery,
1919
+ ":updatedAt": Date.now()
1920
+ }
1921
+ })
1922
+ );
1923
+ } catch (error2) {
1924
+ if (isConditionalFailure(error2)) {
1925
+ throw new RetryableSubscriptionError(
1926
+ `Subscription ${connectionId}/${subscriptionId} changed concurrently`
1927
+ );
1928
+ }
1929
+ throw error2;
1930
+ }
1931
+ continue;
1932
+ }
1933
+ try {
1934
+ await docClient.send(
1935
+ new UpdateCommand({
1936
+ TableName: queriesTable,
1937
+ Key: { pk: query.pk, sk: query.sk },
1938
+ UpdateExpression: "SET lastResult = :lastResult, #version = :toVersion, pendingDelivery = :pendingDelivery, updatedAt = :updatedAt",
1939
+ ConditionExpression: "#status = :active AND #generation = :generation AND registrationId = :registrationId AND #version = :fromVersion AND attribute_not_exists(pendingDelivery)",
1940
+ ExpressionAttributeNames: {
1941
+ "#status": "status",
1942
+ "#generation": "generation",
1943
+ "#version": "version"
1944
+ },
1945
+ ExpressionAttributeValues: {
1946
+ ":active": "active",
1947
+ ":generation": query.generation,
1948
+ ":registrationId": query.registrationId,
1949
+ ":fromVersion": fromVersion,
1950
+ ":toVersion": toVersion,
1951
+ ":lastResult": newResult,
1952
+ ":pendingDelivery": pendingDelivery,
1953
+ ":updatedAt": Date.now()
1954
+ }
1955
+ })
1956
+ );
1957
+ } catch (error2) {
1958
+ if (isConditionalFailure(error2)) {
1959
+ throw new RetryableSubscriptionError(
1960
+ `Subscription ${connectionId}/${subscriptionId} changed concurrently`
1961
+ );
1962
+ }
1963
+ throw error2;
1964
+ }
1965
+ await sendDelivery(query, pendingDelivery);
1966
+ await clearDelivery(query, pendingDelivery);
1967
+ info("[stream] Delivery completed");
1968
+ return;
1197
1969
  }
1198
- await sendPatch(connectionId, subscriptionId, patches);
1199
- } catch (error$1) {
1200
- if (error$1 instanceof GoneException) {
1970
+ throw new RetryableSubscriptionError(
1971
+ `Subscription ${connectionId}/${subscriptionId} delivery did not settle`
1972
+ );
1973
+ } catch (error2) {
1974
+ if (error2 instanceof GoneException) {
1201
1975
  await cleanupConnection(connectionId);
1202
- } else {
1203
- error(
1204
- `Error processing subscription ${connectionId}/${subscriptionId}:`,
1205
- error$1
1206
- );
1976
+ return;
1207
1977
  }
1978
+ throw error2;
1208
1979
  }
1209
1980
  }
1210
1981
  async function cleanupConnection(connectionId) {
1211
- info("Cleaning up disconnected connection:", connectionId);
1212
1982
  try {
1213
- const subsResponse = await docClient.send(
1983
+ await docClient.send(
1984
+ new UpdateCommand({
1985
+ TableName: connectionsTable,
1986
+ Key: { connectionId },
1987
+ UpdateExpression: "SET #status = :closed",
1988
+ ConditionExpression: "attribute_exists(connectionId)",
1989
+ ExpressionAttributeNames: { "#status": "status" },
1990
+ ExpressionAttributeValues: { ":closed": "closed" }
1991
+ })
1992
+ );
1993
+ } catch (error2) {
1994
+ if (!isConditionalFailure(error2)) throw error2;
1995
+ }
1996
+ let lastKey;
1997
+ do {
1998
+ const response = await docClient.send(
1214
1999
  new QueryCommand({
1215
2000
  TableName: queriesTable,
1216
2001
  KeyConditionExpression: "pk = :pk",
1217
- ExpressionAttributeValues: { ":pk": connectionId }
2002
+ ExpressionAttributeValues: { ":pk": connectionId },
2003
+ ConsistentRead: true,
2004
+ ExclusiveStartKey: lastKey
1218
2005
  })
1219
2006
  );
1220
- const subscriptions = subsResponse.Items ?? [];
1221
- await Promise.all(
1222
- subscriptions.map(async (sub) => {
1223
- const deps = Array.isArray(sub.dependencies) ? sub.dependencies : [];
1224
- await Promise.all([
1225
- ...deps.map(
1226
- (depKey) => docClient.send(
1227
- new DeleteCommand({
1228
- TableName: dependenciesTable,
1229
- Key: { pk: depKey, sk: `${connectionId}#${sub.sk}` }
1230
- })
1231
- )
1232
- ),
1233
- docClient.send(
1234
- new DeleteCommand({
2007
+ for (const item of response.Items ?? []) {
2008
+ const query = item;
2009
+ if (query.status !== "cancelled") {
2010
+ try {
2011
+ await docClient.send(
2012
+ new UpdateCommand({
1235
2013
  TableName: queriesTable,
1236
- Key: { pk: connectionId, sk: sub.sk }
2014
+ Key: { pk: query.pk, sk: query.sk },
2015
+ UpdateExpression: "SET #status = :cancelled, updatedAt = :updatedAt REMOVE pendingDelivery",
2016
+ ConditionExpression: "#generation = :generation AND registrationId = :registrationId",
2017
+ ExpressionAttributeNames: {
2018
+ "#status": "status",
2019
+ "#generation": "generation"
2020
+ },
2021
+ ExpressionAttributeValues: {
2022
+ ":cancelled": "cancelled",
2023
+ ":generation": query.generation,
2024
+ ":registrationId": query.registrationId,
2025
+ ":updatedAt": Date.now()
2026
+ }
1237
2027
  })
1238
- )
1239
- ]);
1240
- })
1241
- );
1242
- await docClient.send(
1243
- new DeleteCommand({
1244
- TableName: connectionsTable,
1245
- Key: { connectionId }
1246
- })
1247
- );
1248
- info(
1249
- "Connection cleaned up:",
1250
- connectionId,
1251
- `(${subscriptions.length} subscriptions removed)`
1252
- );
1253
- } catch (error$1) {
1254
- error("Error cleaning up connection:", error$1);
1255
- }
2028
+ );
2029
+ } catch (error2) {
2030
+ if (!isConditionalFailure(error2)) throw error2;
2031
+ continue;
2032
+ }
2033
+ }
2034
+ await cleanupSubscriptionDependencies(query);
2035
+ }
2036
+ lastKey = response.LastEvaluatedKey;
2037
+ } while (lastKey);
1256
2038
  }
1257
2039
  return {
1258
2040
  findAffectedSubscriptions,
1259
2041
  executeQueryFromMetadata,
1260
- sendPatch,
2042
+ sendDelivery,
1261
2043
  processSubscription,
1262
2044
  cleanupConnection
1263
2045
  };
1264
2046
  }
1265
2047
 
1266
2048
  // ../server/src/handlers/stream-handler.ts
1267
- function createStreamHandler(config) {
1268
- const connectionsTable = config.connectionsTableName ?? SystemTableNames.connections;
1269
- const dependenciesTable = config.dependenciesTableName ?? SystemTableNames.dependencies;
1270
- const queriesTable = config.queriesTableName ?? SystemTableNames.queries;
1271
- const ddbClient = new DynamoDBClient({
1272
- region: config.dbConfig?.region ?? process.env.AWS_REGION
1273
- });
1274
- const docClient = DynamoDBDocumentClient.from(ddbClient);
1275
- const utils = createStreamUtils({
1276
- ddbClient,
1277
- docClient,
1278
- apiGatewayEndpoint: config.apiGatewayEndpoint,
1279
- connectionsTable,
1280
- dependenciesTable,
1281
- queriesTable
1282
- });
1283
- async function handler(event) {
1284
- info(
1285
- `[stream] Processing ${event.Records.length} records from DynamoDB stream`
1286
- );
1287
- const affectedSubscriptions = /* @__PURE__ */ new Map();
1288
- const affectedKeys = /* @__PURE__ */ new Set();
1289
- for (const record of event.Records) {
1290
- if (!record.dynamodb) continue;
1291
- const tableName = extractTableName(record);
1292
- if (!tableName) continue;
1293
- const newImage = record.dynamodb.NewImage ? unmarshall(record.dynamodb.NewImage) : null;
1294
- const oldImage = record.dynamodb.OldImage ? unmarshall(record.dynamodb.OldImage) : null;
1295
- if (newImage) {
1296
- for (const key of extractAffectedKeys(tableName, newImage)) {
1297
- affectedKeys.add(key);
1298
- }
1299
- }
1300
- if (oldImage) {
1301
- for (const key of extractAffectedKeys(tableName, oldImage)) {
1302
- affectedKeys.add(key);
1303
- }
1304
- }
1305
- }
1306
- debug("[stream] Affected keys:", affectedKeys.size);
1307
- const lookupResults = await Promise.all(
1308
- [...affectedKeys].map((key) => utils.findAffectedSubscriptions(key))
1309
- );
1310
- for (const subscriptions of lookupResults) {
1311
- for (const sub of subscriptions) {
1312
- if (!affectedSubscriptions.has(sub.connectionId)) {
1313
- affectedSubscriptions.set(sub.connectionId, /* @__PURE__ */ new Set());
1314
- }
1315
- affectedSubscriptions.get(sub.connectionId).add(sub.subscriptionId);
2049
+ var MAX_FANOUT_CONCURRENCY = 10;
2050
+ var DYNAMODB_PARTITION_KEY_MAX_BYTES3 = 2048;
2051
+ function assertConnectionId(connectionId) {
2052
+ if (typeof connectionId !== "string" || connectionId.length === 0 || Buffer.byteLength(connectionId, "utf8") > DYNAMODB_PARTITION_KEY_MAX_BYTES3) {
2053
+ throw new Error("connectionId is not a valid DynamoDB partition key");
2054
+ }
2055
+ }
2056
+ async function mapWithConcurrency(values, concurrency, mapper) {
2057
+ const results = new Array(values.length);
2058
+ let nextIndex = 0;
2059
+ const workers = Array.from(
2060
+ { length: Math.min(concurrency, values.length) },
2061
+ async () => {
2062
+ while (nextIndex < values.length) {
2063
+ const index = nextIndex++;
2064
+ results[index] = await mapper(values[index], index);
1316
2065
  }
1317
2066
  }
1318
- let totalSubs = 0;
1319
- for (const [, subs] of affectedSubscriptions) {
1320
- totalSubs += subs.size;
2067
+ );
2068
+ await Promise.all(workers);
2069
+ return results;
2070
+ }
2071
+ async function processRecord(record, utils) {
2072
+ const tableName = extractTableName(record);
2073
+ if (!tableName || !record.dynamodb) {
2074
+ throw new Error("Malformed DynamoDB stream record");
2075
+ }
2076
+ const affectedKeys = new Set(extractAffectedKeys(tableName, {}));
2077
+ for (const image of [record.dynamodb.NewImage, record.dynamodb.OldImage]) {
2078
+ if (!image) continue;
2079
+ const item = unmarshall(image);
2080
+ for (const key of extractAffectedKeys(tableName, item)) affectedKeys.add(key);
2081
+ }
2082
+ const subscriptions = /* @__PURE__ */ new Map();
2083
+ const lookupResults = await mapWithConcurrency(
2084
+ [...affectedKeys],
2085
+ MAX_FANOUT_CONCURRENCY,
2086
+ (key) => utils.findAffectedSubscriptions(key)
2087
+ );
2088
+ for (const result of lookupResults) {
2089
+ for (const subscription of result) {
2090
+ subscriptions.set(
2091
+ `${subscription.connectionId}\0${subscription.subscriptionId}`,
2092
+ subscription
2093
+ );
1321
2094
  }
1322
- info(
1323
- `[stream] Found ${affectedSubscriptions.size} affected connections, ${totalSubs} subscriptions`
1324
- );
1325
- const sendPromises = [];
1326
- for (const [connectionId, subscriptionIds] of affectedSubscriptions) {
1327
- for (const subscriptionId of subscriptionIds) {
1328
- sendPromises.push(
1329
- utils.processSubscription(connectionId, subscriptionId)
1330
- );
2095
+ }
2096
+ await mapWithConcurrency(
2097
+ [...subscriptions.values()],
2098
+ MAX_FANOUT_CONCURRENCY,
2099
+ ({ connectionId, subscriptionId }) => utils.processSubscription(connectionId, subscriptionId)
2100
+ );
2101
+ }
2102
+ async function processStreamEvent(event, utils) {
2103
+ const results = await mapWithConcurrency(
2104
+ event.Records,
2105
+ MAX_FANOUT_CONCURRENCY,
2106
+ async (record) => {
2107
+ try {
2108
+ await processRecord(record, utils);
2109
+ return null;
2110
+ } catch (error2) {
2111
+ error("[stream] Record failed:", error2);
2112
+ const itemIdentifier = record.dynamodb?.SequenceNumber;
2113
+ if (!itemIdentifier) {
2114
+ throw new Error(
2115
+ "Failed DynamoDB stream record has no sequence number",
2116
+ { cause: error2 }
2117
+ );
2118
+ }
2119
+ return {
2120
+ itemIdentifier
2121
+ };
1331
2122
  }
1332
2123
  }
1333
- await Promise.allSettled(sendPromises);
1334
- info("[stream] Processing complete");
1335
- }
1336
- return { handler };
2124
+ );
2125
+ return {
2126
+ batchItemFailures: results.filter(
2127
+ (failure) => failure !== null
2128
+ )
2129
+ };
1337
2130
  }
1338
- function createConnectHandler(config) {
1339
- const connectionsTable = config.connectionsTableName ?? SystemTableNames.connections;
1340
- const ddbClient = new DynamoDBClient({
1341
- region: config.dbConfig?.region ?? process.env.AWS_REGION
2131
+ function clients(config) {
2132
+ const ddbClient = config.dbConfig?.client ?? new DynamoDBClient({
2133
+ region: config.dbConfig?.region ?? process.env.AWS_REGION,
2134
+ endpoint: config.dbConfig?.endpoint
2135
+ });
2136
+ const docClient = config.dbConfig?.docClient ?? DynamoDBDocumentClient.from(ddbClient, {
2137
+ marshallOptions: { removeUndefinedValues: true }
1342
2138
  });
1343
- const docClient = DynamoDBDocumentClient.from(ddbClient);
2139
+ return { ddbClient, docClient };
2140
+ }
2141
+ function createConnectHandler(config) {
2142
+ const connectionsTable = config.connectionsTableName;
2143
+ const { docClient } = clients(config);
1344
2144
  return async function handler(event) {
1345
2145
  const connectionId = event.requestContext.connectionId;
1346
2146
  const now = Date.now();
1347
- const ttl = Math.floor(now / 1e3) + 3600;
1348
2147
  try {
2148
+ assertConnectionId(connectionId);
2149
+ const authorizerContext = jsonSafeRecord(event.requestContext.authorizer);
2150
+ const requestIdentity = jsonSafeRecord(event.requestContext.identity);
1349
2151
  const connectionEntry = {
1350
2152
  connectionId,
1351
- context: event.requestContext.authorizer,
2153
+ status: "open",
2154
+ ...authorizerContext ? { authorizerContext } : {},
2155
+ ...requestIdentity ? { requestIdentity } : {},
1352
2156
  connectedAt: now,
1353
- ttl
2157
+ ttl: Math.floor(now / 1e3) + DEFAULT_CONNECTION_LEASE_SECONDS
1354
2158
  };
1355
2159
  await docClient.send(
1356
- new PutCommand({
1357
- TableName: connectionsTable,
1358
- Item: connectionEntry
1359
- })
2160
+ new PutCommand({ TableName: connectionsTable, Item: connectionEntry })
1360
2161
  );
1361
- debug("[stream] Connection established:", connectionId);
2162
+ debug("[stream] Connection established");
1362
2163
  return { statusCode: 200 };
1363
- } catch (error$1) {
1364
- error("Error creating connection:", error$1);
2164
+ } catch (error2) {
2165
+ error("Error creating connection:", error2);
1365
2166
  return { statusCode: 500 };
1366
2167
  }
1367
2168
  };
1368
2169
  }
2170
+ function jsonSafeRecord(value) {
2171
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
2172
+ try {
2173
+ const encoded = JSON.stringify(value, (_key, item) => {
2174
+ if (typeof item === "bigint") return item.toString();
2175
+ if (typeof item === "function" || typeof item === "symbol" || item === void 0) {
2176
+ return void 0;
2177
+ }
2178
+ return item;
2179
+ });
2180
+ if (!encoded) return void 0;
2181
+ return JSON.parse(encoded);
2182
+ } catch {
2183
+ return void 0;
2184
+ }
2185
+ }
1369
2186
  function createDisconnectHandler(config) {
1370
- const connectionsTable = config.connectionsTableName ?? SystemTableNames.connections;
1371
- const dependenciesTable = config.dependenciesTableName ?? SystemTableNames.dependencies;
1372
- const queriesTable = config.queriesTableName ?? SystemTableNames.queries;
1373
- const ddbClient = new DynamoDBClient({
1374
- region: config.dbConfig?.region ?? process.env.AWS_REGION
1375
- });
1376
- const docClient = DynamoDBDocumentClient.from(ddbClient);
2187
+ const connectionsTable = config.connectionsTableName;
2188
+ const dependenciesTable = config.dependenciesTableName;
2189
+ const queriesTable = config.queriesTableName;
2190
+ const { ddbClient, docClient } = clients(config);
1377
2191
  const utils = createStreamUtils({
1378
2192
  ddbClient,
1379
2193
  docClient,
2194
+ apiClient: config.apiClient,
1380
2195
  apiGatewayEndpoint: config.apiGatewayEndpoint,
1381
2196
  connectionsTable,
1382
2197
  dependenciesTable,
1383
2198
  queriesTable
1384
2199
  });
1385
2200
  return async function handler(event) {
1386
- const connectionId = event.requestContext.connectionId;
1387
2201
  try {
2202
+ const connectionId = event.requestContext.connectionId;
2203
+ assertConnectionId(connectionId);
1388
2204
  await utils.cleanupConnection(connectionId);
1389
2205
  return { statusCode: 200 };
1390
- } catch (error$1) {
1391
- error("Error removing connection:", error$1);
2206
+ } catch (error2) {
2207
+ error("Error removing connection:", error2);
1392
2208
  return { statusCode: 500 };
1393
2209
  }
1394
2210
  };
1395
2211
  }
1396
- function createLambdaHandlers() {
1397
- const connectionsTable = process.env.CONNECTIONS_TABLE ?? SystemTableNames.connections;
1398
- const dependenciesTable = process.env.DEPENDENCIES_TABLE ?? SystemTableNames.dependencies;
1399
- const queriesTable = process.env.QUERIES_TABLE ?? SystemTableNames.queries;
1400
- const wsEndpoint = process.env.WEBSOCKET_ENDPOINT ?? "";
1401
- const ddbClient = new DynamoDBClient({
1402
- region: process.env.AWS_REGION
2212
+
2213
+ // ../server/src/handlers/lambda-handlers.ts
2214
+ function requiredTableName(configured, environmentName) {
2215
+ const value = configured ?? process.env[environmentName];
2216
+ if (!value) throw new Error(`${environmentName} must name a system table`);
2217
+ return value;
2218
+ }
2219
+ function assertRequestKey(name, value, maxBytes) {
2220
+ if (typeof value !== "string" || value.length === 0 || Buffer.byteLength(value, "utf8") > maxBytes) {
2221
+ throw new Error(`${name} is not a valid DynamoDB key`);
2222
+ }
2223
+ return value;
2224
+ }
2225
+ function createLambdaHandlers(config = {}) {
2226
+ const connectionsTable = requiredTableName(
2227
+ config.connectionsTableName,
2228
+ "CONNECTIONS_TABLE"
2229
+ );
2230
+ const dependenciesTable = requiredTableName(
2231
+ config.dependenciesTableName,
2232
+ "DEPENDENCIES_TABLE"
2233
+ );
2234
+ const queriesTable = requiredTableName(
2235
+ config.queriesTableName,
2236
+ "QUERIES_TABLE"
2237
+ );
2238
+ const apiGatewayEndpoint = config.apiGatewayEndpoint ?? process.env.WEBSOCKET_ENDPOINT ?? "";
2239
+ const ddbClient = config.dbConfig?.client ?? new DynamoDBClient({
2240
+ region: config.dbConfig?.region ?? process.env.AWS_REGION,
2241
+ endpoint: config.dbConfig?.endpoint
1403
2242
  });
1404
- const docClient = DynamoDBDocumentClient.from(ddbClient);
1405
- const utils = createStreamUtils({
2243
+ const docClient = config.dbConfig?.docClient ?? DynamoDBDocumentClient.from(ddbClient, {
2244
+ marshallOptions: { removeUndefinedValues: true }
2245
+ });
2246
+ const apiClient = config.apiClient ?? new ApiGatewayManagementApiClient({ endpoint: apiGatewayEndpoint });
2247
+ const sharedConfig = {
2248
+ dbConfig: { ...config.dbConfig, client: ddbClient, docClient },
2249
+ apiClient,
2250
+ apiGatewayEndpoint,
2251
+ connectionsTableName: connectionsTable,
2252
+ dependenciesTableName: dependenciesTable,
2253
+ queriesTableName: queriesTable
2254
+ };
2255
+ const connect = createConnectHandler(sharedConfig);
2256
+ const disconnect = createDisconnectHandler(sharedConfig);
2257
+ const streamUtils = createStreamUtils({
1406
2258
  ddbClient,
1407
2259
  docClient,
1408
- apiGatewayEndpoint: wsEndpoint,
2260
+ apiClient,
2261
+ apiGatewayEndpoint,
1409
2262
  connectionsTable,
1410
2263
  dependenciesTable,
1411
2264
  queriesTable
1412
2265
  });
1413
- const getApiClient = () => new ApiGatewayManagementApiClient({
1414
- endpoint: wsEndpoint
1415
- });
1416
2266
  async function connectHandler(event) {
1417
- const connectionId = event.requestContext.connectionId;
1418
- const now = Date.now();
1419
- const ttl = Math.floor(now / 1e3) + 3600;
1420
- try {
1421
- const connectionEntry = {
1422
- connectionId,
1423
- context: event.requestContext.authorizer,
1424
- connectedAt: now,
1425
- ttl
1426
- };
1427
- await docClient.send(
1428
- new PutCommand({
1429
- TableName: connectionsTable,
1430
- Item: connectionEntry
1431
- })
1432
- );
1433
- info("Connection established:", connectionId);
1434
- return { statusCode: 200, body: "Connected" };
1435
- } catch (error$1) {
1436
- error("Error creating connection:", error$1);
1437
- return { statusCode: 500, body: "Failed to connect" };
1438
- }
2267
+ const result = await connect({
2268
+ requestContext: {
2269
+ connectionId: event.requestContext.connectionId,
2270
+ authorizer: event.requestContext.authorizer,
2271
+ identity: event.requestContext.identity
2272
+ }
2273
+ });
2274
+ return {
2275
+ statusCode: result.statusCode,
2276
+ body: result.statusCode === 200 ? "Connected" : "Failed to connect"
2277
+ };
1439
2278
  }
1440
2279
  async function disconnectHandler(event) {
1441
- const connectionId = event.requestContext.connectionId;
1442
- try {
1443
- await utils.cleanupConnection(connectionId);
1444
- return { statusCode: 200, body: "Disconnected" };
1445
- } catch (error$1) {
1446
- error("Error removing connection:", error$1);
1447
- return { statusCode: 500, body: "Failed to disconnect" };
1448
- }
2280
+ const result = await disconnect({
2281
+ requestContext: { connectionId: event.requestContext.connectionId }
2282
+ });
2283
+ return {
2284
+ statusCode: result.statusCode,
2285
+ body: result.statusCode === 200 ? "Disconnected" : "Failed to disconnect"
2286
+ };
1449
2287
  }
1450
2288
  async function messageHandler(event) {
1451
2289
  const connectionId = event.requestContext.connectionId;
1452
2290
  try {
2291
+ assertRequestKey("connectionId", connectionId, 2048);
1453
2292
  const body = JSON.parse(event.body ?? "{}");
1454
- const { type, subscriptionId } = body;
1455
2293
  let response;
1456
- switch (type) {
1457
- case "init": {
1458
- response = {
1459
- type: "connected",
1460
- connectionId
1461
- };
1462
- break;
1463
- }
1464
- case "ping": {
1465
- response = {
1466
- type: "pong"
1467
- };
1468
- break;
1469
- }
1470
- case "unsubscribe": {
1471
- const subResponse = await docClient.send(
1472
- new GetCommand({
1473
- TableName: queriesTable,
1474
- Key: { pk: connectionId, sk: subscriptionId }
1475
- })
1476
- );
1477
- if (subResponse.Item) {
1478
- const queryEntry = subResponse.Item;
1479
- await Promise.all(
1480
- queryEntry.dependencies.map(
1481
- (key) => docClient.send(
1482
- new DeleteCommand({
1483
- TableName: dependenciesTable,
1484
- Key: { pk: key, sk: `${connectionId}#${subscriptionId}` }
1485
- })
1486
- )
1487
- )
1488
- );
1489
- }
1490
- await docClient.send(
1491
- new DeleteCommand({
1492
- TableName: queriesTable,
1493
- Key: { pk: connectionId, sk: subscriptionId }
1494
- })
1495
- );
1496
- response = { type: "result", data: { success: true } };
1497
- break;
1498
- }
1499
- default:
1500
- response = {
1501
- type: "error",
1502
- message: `Message type '${type}' should be handled by the app API route`
1503
- };
2294
+ if (body.type === "init") {
2295
+ response = { type: "connected", connectionId };
2296
+ } else if (body.type === "ping") {
2297
+ const now = Math.floor(Date.now() / 1e3);
2298
+ await docClient.send(
2299
+ new UpdateCommand({
2300
+ TableName: connectionsTable,
2301
+ Key: { connectionId },
2302
+ UpdateExpression: "SET #ttl = :ttl",
2303
+ ConditionExpression: "#status = :open AND #ttl > :now",
2304
+ ExpressionAttributeNames: { "#status": "status", "#ttl": "ttl" },
2305
+ ExpressionAttributeValues: {
2306
+ ":open": "open",
2307
+ ":now": now,
2308
+ ":ttl": now + DEFAULT_CONNECTION_LEASE_SECONDS
2309
+ }
2310
+ })
2311
+ );
2312
+ response = { type: "pong" };
2313
+ } else {
2314
+ response = {
2315
+ type: "error",
2316
+ message: "Unsupported WebSocket message type"
2317
+ };
1504
2318
  }
1505
- const apiClient = getApiClient();
1506
2319
  await apiClient.send(
1507
2320
  new PostToConnectionCommand({
1508
2321
  ConnectionId: connectionId,
@@ -1510,183 +2323,17 @@ function createLambdaHandlers() {
1510
2323
  })
1511
2324
  );
1512
2325
  return { statusCode: 200, body: "OK" };
1513
- } catch (error$1) {
1514
- error("Error handling message:", error$1);
2326
+ } catch (error2) {
2327
+ error("Error handling message:", error2);
1515
2328
  return { statusCode: 500, body: "Internal server error" };
1516
2329
  }
1517
2330
  }
1518
2331
  async function streamHandler(event) {
1519
- info(
1520
- `[stream] Processing ${event.Records.length} records from DynamoDB stream`
1521
- );
1522
- const affectedSubscriptions = /* @__PURE__ */ new Map();
1523
- const affectedKeys = /* @__PURE__ */ new Set();
1524
- for (const record of event.Records) {
1525
- if (!record.dynamodb) continue;
1526
- const tableName = extractTableName(record);
1527
- if (!tableName) continue;
1528
- const newImage = record.dynamodb.NewImage ? unmarshall(record.dynamodb.NewImage) : null;
1529
- const oldImage = record.dynamodb.OldImage ? unmarshall(record.dynamodb.OldImage) : null;
1530
- if (newImage) {
1531
- for (const key of extractAffectedKeys(tableName, newImage)) {
1532
- affectedKeys.add(key);
1533
- }
1534
- }
1535
- if (oldImage) {
1536
- for (const key of extractAffectedKeys(tableName, oldImage)) {
1537
- affectedKeys.add(key);
1538
- }
1539
- }
1540
- }
1541
- const lookupResults = await Promise.all(
1542
- [...affectedKeys].map((key) => utils.findAffectedSubscriptions(key))
1543
- );
1544
- for (const subscriptions of lookupResults) {
1545
- for (const sub of subscriptions) {
1546
- if (!affectedSubscriptions.has(sub.connectionId)) {
1547
- affectedSubscriptions.set(sub.connectionId, /* @__PURE__ */ new Set());
1548
- }
1549
- affectedSubscriptions.get(sub.connectionId).add(sub.subscriptionId);
1550
- }
1551
- }
1552
- let totalSubs = 0;
1553
- for (const [, subs] of affectedSubscriptions) {
1554
- totalSubs += subs.size;
1555
- }
1556
- info(
1557
- `[stream] Found ${affectedSubscriptions.size} affected connections, ${totalSubs} subscriptions`
1558
- );
1559
- const sendPromises = [];
1560
- for (const [connectionId, subscriptionIds] of affectedSubscriptions) {
1561
- for (const subscriptionId of subscriptionIds) {
1562
- sendPromises.push(
1563
- utils.processSubscription(connectionId, subscriptionId)
1564
- );
1565
- }
1566
- }
1567
- await Promise.allSettled(sendPromises);
1568
- info("[stream] Processing complete");
1569
- }
1570
- return {
1571
- connectHandler,
1572
- disconnectHandler,
1573
- messageHandler,
1574
- streamHandler
1575
- };
1576
- }
1577
- function evaluateFilter(filter, record) {
1578
- switch (filter.type) {
1579
- case "comparison":
1580
- return evaluateComparison(filter, record);
1581
- case "logical":
1582
- return evaluateLogical(filter, record);
1583
- case "function":
1584
- return evaluateFunction(filter, record);
1585
- default:
1586
- warn(`Unknown filter type: ${filter.type}`);
1587
- return false;
1588
- }
1589
- }
1590
- function evaluateFilters(filters, record) {
1591
- if (filters.length === 0) return true;
1592
- return filters.every((filter) => evaluateFilter(filter, record));
1593
- }
1594
- function evaluateComparison(filter, record) {
1595
- const { operator, field, value, value2 } = filter;
1596
- if (!field || !operator) return false;
1597
- const fieldValue = getFieldValue(record, field);
1598
- switch (operator) {
1599
- case "eq":
1600
- return fieldValue === value;
1601
- case "ne":
1602
- return fieldValue !== value;
1603
- case "gt":
1604
- return compareValues(fieldValue, value) > 0;
1605
- case "gte":
1606
- return compareValues(fieldValue, value) >= 0;
1607
- case "lt":
1608
- return compareValues(fieldValue, value) < 0;
1609
- case "lte":
1610
- return compareValues(fieldValue, value) <= 0;
1611
- case "between":
1612
- return compareValues(fieldValue, value) >= 0 && compareValues(fieldValue, value2) <= 0;
1613
- default:
1614
- warn(`Unknown comparison operator: ${operator}`);
1615
- return false;
2332
+ return processStreamEvent(event, streamUtils);
1616
2333
  }
1617
- }
1618
- function evaluateLogical(filter, record) {
1619
- const { operator, conditions } = filter;
1620
- if (!operator || !conditions) return false;
1621
- switch (operator) {
1622
- case "and":
1623
- return conditions.every((c) => evaluateFilter(c, record));
1624
- case "or":
1625
- return conditions.some((c) => evaluateFilter(c, record));
1626
- case "not":
1627
- return conditions.length > 0 && !evaluateFilter(conditions[0], record);
1628
- default:
1629
- warn(`Unknown logical operator: ${operator}`);
1630
- return false;
1631
- }
1632
- }
1633
- function evaluateFunction(filter, record) {
1634
- const { operator, field, value } = filter;
1635
- if (!field || !operator) return false;
1636
- const fieldValue = getFieldValue(record, field);
1637
- switch (operator) {
1638
- case "beginsWith":
1639
- return typeof fieldValue === "string" && typeof value === "string" && fieldValue.startsWith(value);
1640
- case "contains":
1641
- return typeof fieldValue === "string" && typeof value === "string" && fieldValue.includes(value);
1642
- default:
1643
- warn(`Unknown function operator: ${operator}`);
1644
- return false;
1645
- }
1646
- }
1647
- function getFieldValue(record, field) {
1648
- const parts = field.split(".");
1649
- let value = record;
1650
- for (const part of parts) {
1651
- if (value === null || value === void 0) return void 0;
1652
- if (typeof value !== "object") return void 0;
1653
- value = value[part];
1654
- }
1655
- return value;
1656
- }
1657
- function compareValues(a, b) {
1658
- if (a === null || a === void 0) {
1659
- return b === null || b === void 0 ? 0 : -1;
1660
- }
1661
- if (b === null || b === void 0) {
1662
- return 1;
1663
- }
1664
- if (typeof a === "number" && typeof b === "number") {
1665
- return a - b;
1666
- }
1667
- if (typeof a === "string" && typeof b === "string") {
1668
- return a.localeCompare(b);
1669
- }
1670
- if (typeof a === "boolean" && typeof b === "boolean") {
1671
- return a === b ? 0 : a ? 1 : -1;
1672
- }
1673
- return String(a).localeCompare(String(b));
1674
- }
1675
- function sortRecords(records, sortField, sortOrder = "asc") {
1676
- if (!sortField) return records;
1677
- return [...records].sort((a, b) => {
1678
- const aValue = getFieldValue(a, sortField);
1679
- const bValue = getFieldValue(b, sortField);
1680
- const comparison = compareValues(aValue, bValue);
1681
- return sortOrder === "desc" ? -comparison : comparison;
1682
- });
1683
- }
1684
- function getRecordKey(record, pkField, skField) {
1685
- const pk = record[pkField];
1686
- const sk = skField ? record[skField] : void 0;
1687
- return sk !== void 0 ? `${pk}#${sk}` : String(pk);
2334
+ return { connectHandler, disconnectHandler, messageHandler, streamHandler };
1688
2335
  }
1689
2336
 
1690
- export { DependencyTracker, ProcedureBuilder, QueryBuilderImpl, Router, applyPatches, batchPatches, buildDeleteStatement, buildGetStatement, buildInsertStatement, buildSelectStatement, buildUpdateStatement, createConnectHandler, createDbContext, createDependencyKey, createDisconnectHandler, createFilterBuilder, createLambdaHandlers, createReactiveHandler, createRouter, createStreamHandler, evaluateFilter, evaluateFilters, executeProcedure, extractAffectedKeys, extractDependencies, generatePatches, getRecordKey, hasChanges, initReactive, isProcedure, mergeRouters, operationToQueryMetadata, optimizePatches, parseDependencyKey, sortRecords };
2337
+ export { InvalidInputError, SafeRequestError, createDbContext, createLambdaHandlers, createReactiveHandler, initReactive, isSafeRequestError };
1691
2338
  //# sourceMappingURL=server.js.map
1692
2339
  //# sourceMappingURL=server.js.map