dynamodb-reactive 0.1.22 → 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.
- package/README.md +378 -153
- package/dist/client-r_tnzwqi.d.ts +224 -0
- package/dist/client.cjs +1023 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +6 -0
- package/dist/client.d.ts +4 -70
- package/dist/client.js +1013 -1
- package/dist/client.js.map +1 -1
- package/dist/core.cjs +232 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +4 -0
- package/dist/core.d.ts +3 -3
- package/dist/core.js +222 -1
- package/dist/core.js.map +1 -1
- package/dist/index.cjs +232 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +157 -0
- package/dist/index.d.ts +103 -56
- package/dist/index.js +222 -1
- package/dist/index.js.map +1 -1
- package/dist/infra.cjs +623 -0
- package/dist/infra.cjs.map +1 -0
- package/dist/infra.d.cts +119 -0
- package/dist/infra.d.ts +50 -102
- package/dist/infra.js +371 -144
- package/dist/infra.js.map +1 -1
- package/dist/react.cjs +1250 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +68 -0
- package/dist/react.d.ts +68 -3
- package/dist/react.js +1237 -1
- package/dist/react.js.map +1 -1
- package/dist/server.cjs +2351 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +364 -0
- package/dist/server.d.ts +58 -313
- package/dist/server.js +1855 -1208
- package/dist/server.js.map +1 -1
- package/dist/{table-tN3D4Tm-.d.ts → table-BijJ73pX.d.ts} +2 -34
- package/dist/{types-ISMJfyIv.d.ts → types-LMLk8JVC.d.ts} +56 -30
- package/package.json +79 -41
- package/dist/chunk-2KC44Q5I.js +0 -1103
- package/dist/chunk-2KC44Q5I.js.map +0 -1
- package/dist/chunk-ZAJA4KBE.js +0 -150
- package/dist/chunk-ZAJA4KBE.js.map +0 -1
- package/dist/react-D_NLHuuO.d.ts +0 -556
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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 === "
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
222
|
+
return { dependencies, safe: dependencies.length === 1 };
|
|
169
223
|
}
|
|
170
224
|
case "logical": {
|
|
171
|
-
if (!condition.conditions || condition.conditions.length === 0) {
|
|
172
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
228
|
+
let safe = true;
|
|
229
|
+
for (const subCondition of condition.conditions) {
|
|
230
|
+
const extracted = extractFromCondition(
|
|
231
|
+
tableName,
|
|
232
|
+
subCondition
|
|
180
233
|
);
|
|
181
|
-
|
|
234
|
+
dependencies.push(...extracted.dependencies);
|
|
235
|
+
safe &&= extracted.safe;
|
|
182
236
|
}
|
|
183
|
-
|
|
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
|
|
193
|
-
const
|
|
194
|
-
let
|
|
195
|
-
|
|
196
|
-
|
|
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.
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
255
|
+
return [...unique.values()];
|
|
205
256
|
}
|
|
206
|
-
function
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
for (const
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
231
|
-
const
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
return
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
416
|
+
return { type: "comparison", operator: "eq", field: field.fieldName, value };
|
|
256
417
|
}
|
|
257
418
|
ne(field, value) {
|
|
258
|
-
return
|
|
419
|
+
return { type: "comparison", operator: "ne", field: field.fieldName, value };
|
|
259
420
|
}
|
|
260
421
|
gt(field, value) {
|
|
261
|
-
return
|
|
422
|
+
return { type: "comparison", operator: "gt", field: field.fieldName, value };
|
|
262
423
|
}
|
|
263
424
|
gte(field, value) {
|
|
264
|
-
return
|
|
425
|
+
return { type: "comparison", operator: "gte", field: field.fieldName, value };
|
|
265
426
|
}
|
|
266
427
|
lt(field, value) {
|
|
267
|
-
return
|
|
428
|
+
return { type: "comparison", operator: "lt", field: field.fieldName, value };
|
|
268
429
|
}
|
|
269
430
|
lte(field, value) {
|
|
270
|
-
return
|
|
431
|
+
return { type: "comparison", operator: "lte", field: field.fieldName, value };
|
|
271
432
|
}
|
|
272
433
|
between(field, lower, upper) {
|
|
273
|
-
return
|
|
274
|
-
"function",
|
|
275
|
-
"
|
|
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
|
|
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
|
|
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: "
|
|
459
|
+
return { type: "logical", operator: "and", conditions };
|
|
289
460
|
}
|
|
290
461
|
or(...conditions) {
|
|
291
|
-
return { type: "logical", operator: "
|
|
462
|
+
return { type: "logical", operator: "or", conditions };
|
|
292
463
|
}
|
|
293
464
|
not(condition) {
|
|
294
|
-
return { type: "logical", operator: "
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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:
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
sortField: this.
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
425
|
-
async function executeQuery(operation
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
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
|
-
|
|
488
|
-
|
|
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
|
-
|
|
494
|
-
return
|
|
1011
|
+
await docClient.send(command);
|
|
1012
|
+
return parsed;
|
|
495
1013
|
}
|
|
496
1014
|
};
|
|
497
1015
|
}
|
|
498
1016
|
|
|
499
|
-
// ../server/src/
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
1031
|
+
if (value && typeof value === "object") return { valueType: "object" };
|
|
1032
|
+
return value;
|
|
546
1033
|
}
|
|
547
|
-
function
|
|
548
|
-
return
|
|
1034
|
+
function logger(method) {
|
|
1035
|
+
return (...values) => consoleLogger[method](...values.map(sanitized));
|
|
549
1036
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
if (
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
747
|
-
const
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
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:
|
|
791
|
-
code:
|
|
792
|
-
|
|
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
|
|
797
|
-
const
|
|
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
|
-
|
|
1221
|
+
context,
|
|
800
1222
|
request.input
|
|
801
1223
|
);
|
|
802
|
-
const
|
|
803
|
-
const
|
|
804
|
-
if (!
|
|
805
|
-
|
|
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
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
lastResult:
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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:
|
|
859
|
-
|
|
1472
|
+
subscriptionId: query2.subscriptionId,
|
|
1473
|
+
generation: query2.generation,
|
|
1474
|
+
version: query2.version,
|
|
1475
|
+
data: query2.lastResult
|
|
860
1476
|
};
|
|
861
1477
|
}
|
|
862
|
-
async function
|
|
863
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
886
|
-
|
|
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
|
-
|
|
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,
|
|
901
|
-
const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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:
|
|
1562
|
+
data: await config.router.execute(request.path, context, request.input)
|
|
909
1563
|
};
|
|
910
1564
|
}
|
|
911
1565
|
async function registerConnection(connectionId, context) {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
const connectionEntry = {
|
|
1566
|
+
validateKeyString(
|
|
1567
|
+
"connectionId",
|
|
915
1568
|
connectionId,
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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:
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
1685
|
+
};
|
|
1686
|
+
function extractTableName(record) {
|
|
1687
|
+
const match = record.eventSourceARN?.match(/table\/([^/]+)/);
|
|
1688
|
+
return match?.[1] ?? null;
|
|
1061
1689
|
}
|
|
1062
|
-
function
|
|
1063
|
-
|
|
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
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
type:
|
|
1126
|
-
subscriptionId,
|
|
1127
|
-
|
|
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
|
|
1131
|
-
new
|
|
1132
|
-
|
|
1133
|
-
|
|
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 (
|
|
1137
|
-
if (
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1190
|
-
if (
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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:
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
2042
|
+
sendDelivery,
|
|
1261
2043
|
processSubscription,
|
|
1262
2044
|
cleanupConnection
|
|
1263
2045
|
};
|
|
1264
2046
|
}
|
|
1265
2047
|
|
|
1266
2048
|
// ../server/src/handlers/stream-handler.ts
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
const
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
1324
|
-
)
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
2124
|
+
);
|
|
2125
|
+
return {
|
|
2126
|
+
batchItemFailures: results.filter(
|
|
2127
|
+
(failure) => failure !== null
|
|
2128
|
+
)
|
|
2129
|
+
};
|
|
1337
2130
|
}
|
|
1338
|
-
function
|
|
1339
|
-
const
|
|
1340
|
-
|
|
1341
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2162
|
+
debug("[stream] Connection established");
|
|
1362
2163
|
return { statusCode: 200 };
|
|
1363
|
-
} catch (
|
|
1364
|
-
error("Error creating connection:",
|
|
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
|
|
1371
|
-
const dependenciesTable = config.dependenciesTableName
|
|
1372
|
-
const queriesTable = config.queriesTableName
|
|
1373
|
-
const ddbClient =
|
|
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 (
|
|
1391
|
-
error("Error removing connection:",
|
|
2206
|
+
} catch (error2) {
|
|
2207
|
+
error("Error removing connection:", error2);
|
|
1392
2208
|
return { statusCode: 500 };
|
|
1393
2209
|
}
|
|
1394
2210
|
};
|
|
1395
2211
|
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
const
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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 (
|
|
1514
|
-
error("Error handling message:",
|
|
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
|
-
|
|
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 {
|
|
2337
|
+
export { InvalidInputError, SafeRequestError, createDbContext, createLambdaHandlers, createReactiveHandler, initReactive, isSafeRequestError };
|
|
1691
2338
|
//# sourceMappingURL=server.js.map
|
|
1692
2339
|
//# sourceMappingURL=server.js.map
|