@delali/sirannon-db 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -969
- package/dist/backup-scheduler/index.d.ts +1 -1
- package/dist/backup-scheduler/index.mjs +2 -2
- package/dist/baseline-Br77Fnhb.d.ts +6 -0
- package/dist/{change-tracker-CbmaMO-N.d.ts → change-tracker-DKRVUC3l.d.ts} +5 -8
- package/dist/{chunk-DJLX6CAE.mjs → chunk-4ISB7XMA.mjs} +2 -1
- package/dist/chunk-5NOIGN5Y.mjs +18 -0
- package/dist/chunk-67M7KAH6.mjs +90 -0
- package/dist/{chunk-GEZUUIKV.mjs → chunk-D7LAYTKN.mjs} +5 -2
- package/dist/{chunk-4IGMIJQK.mjs → chunk-EBJXPQQO.mjs} +59 -20
- package/dist/{chunk-BNUTBHHH.mjs → chunk-FHWTZFI4.mjs} +1 -1
- package/dist/chunk-GVCNMPOS.mjs +130 -0
- package/dist/chunk-H237TXZW.mjs +1407 -0
- package/dist/{chunk-HHRMRFFR.mjs → chunk-H6PIVVDN.mjs} +353 -142
- package/dist/chunk-HR5CWTLC.mjs +661 -0
- package/dist/chunk-JU64Y7HM.mjs +299 -0
- package/dist/{chunk-CW6S3WL5.mjs → chunk-JZGINXTN.mjs} +7 -5
- package/dist/{chunk-CJLYFDP5.mjs → chunk-LFZ37BSX.mjs} +1 -1
- package/dist/chunk-LNY2VVHE.mjs +18 -0
- package/dist/chunk-NVQS53NT.mjs +665 -0
- package/dist/{chunk-H5AB6NIR.mjs → chunk-O7SLN3GI.mjs} +2 -15
- package/dist/chunk-OQVZBEBY.mjs +283 -0
- package/dist/{chunk-VLTICJOD.mjs → chunk-PIKHN33N.mjs} +1 -1
- package/dist/chunk-TJF5GZSV.mjs +187 -0
- package/dist/chunk-TUD5CJ76.mjs +126 -0
- package/dist/{chunk-YPYVQJ4C.mjs → chunk-UC3SCMIN.mjs} +9 -1
- package/dist/client/index.d.ts +216 -321
- package/dist/client/index.mjs +1192 -1342
- package/dist/client/topology.d.ts +61 -0
- package/dist/client/topology.mjs +682 -0
- package/dist/client-base-CLWmH5Ln.d.ts +234 -0
- package/dist/codegen/cli.d.ts +2 -0
- package/dist/codegen/cli.mjs +11 -0
- package/dist/codegen/index.d.ts +30 -0
- package/dist/codegen/index.mjs +5 -0
- package/dist/core/index.d.ts +37 -16
- package/dist/core/index.mjs +2322 -298
- package/dist/core/writer-worker.mjs +28 -6
- package/dist/{database-DuGp0Rtr.d.ts → database-BY0L5Q2n.d.ts} +59 -14
- package/dist/driver/better-sqlite3.d.ts +1 -1
- package/dist/driver/better-sqlite3.mjs +6 -6
- package/dist/driver/node.d.ts +1 -1
- package/dist/driver/node.mjs +6 -6
- package/dist/driver/wa-sqlite.d.ts +1 -136
- package/dist/{errors-5Nf5ZAEC.d.ts → errors-Bw5MdNCu.d.ts} +5 -1
- package/dist/file-migrations/index.d.ts +7 -5
- package/dist/file-migrations/index.mjs +42 -37
- package/dist/operation-registry-9DcvxcE5.d.ts +45 -0
- package/dist/primary-wins-DPAm2AKG.d.ts +61 -0
- package/dist/protocol-6KrSq2Hy.d.ts +66 -0
- package/dist/react/index.d.ts +20 -0
- package/dist/react/index.mjs +137 -0
- package/dist/replication/coordinator/etcd.d.ts +5 -3
- package/dist/replication/coordinator/etcd.mjs +554 -462
- package/dist/replication/index.d.ts +51 -105
- package/dist/replication/index.mjs +1415 -2061
- package/dist/server/index.d.ts +111 -87
- package/dist/server/index.mjs +1917 -489
- package/dist/server-options-1JHu8pid.d.ts +191 -0
- package/dist/{sirannon-4SspRvP5.d.ts → sirannon-DaQSyhbJ.d.ts} +7 -2
- package/dist/transport/grpc.d.ts +7 -4
- package/dist/transport/grpc.mjs +191 -97
- package/dist/transport/memory.d.ts +5 -134
- package/dist/transport/memory.mjs +3 -1
- package/dist/{types-BsjobKbl.d.ts → types-B7gmEsZW.d.ts} +8 -61
- package/dist/{types-BEu1I_9_.d.ts → types-BsVabqSI.d.ts} +1 -1
- package/dist/types-CL6piSnD.d.ts +43 -0
- package/dist/types-C_D8IhpO.d.ts +60 -0
- package/dist/{types-D_hQW1hr.d.ts → types-zhnRXrsb.d.ts} +55 -165
- package/package.json +44 -6
- package/dist/chunk-TKGHYWQ6.mjs +0 -35
- package/dist/types-D4p4UyDK.d.ts +0 -26
package/dist/server/index.mjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import '../chunk-
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
1
|
+
import { dumpSchema, tablesInFkOrder, dumpTablePages } from '../chunk-OQVZBEBY.mjs';
|
|
2
|
+
import { ChangeTracker, SubscriptionManager, ensureCdcEpoch, migrationChecksum, needsResync, filteredChange, TransactionGrouper, isBulkLoadDurability, PrimedSubscription } from '../chunk-H6PIVVDN.mjs';
|
|
3
|
+
import { SEQ_STRING_RE, highestMigrationVersion, selectCountTableRows, selectTableExists, selectAppliedMigrations, PkResolver, ensureDeviceCursorsTable, upsertDeviceCursor, selectMaxChangeSeq, canonicaliseForChecksum, selectDeviceCursors, selectMinForeignChangeSeqSql, IDENTIFIER_RE, deleteDeviceCursorsUpdatedBefore } from '../chunk-NVQS53NT.mjs';
|
|
4
|
+
import '../chunk-5NOIGN5Y.mjs';
|
|
5
|
+
import { encodeTaggedValues, MIGRATIONS_TABLE, decodeTaggedValues, encodeWireRowsInPlace, CHANGES_TABLE, DEVICE_CURSORS_TABLE, INTERNAL_TABLE_PREFIX } from '../chunk-D7LAYTKN.mjs';
|
|
6
|
+
import { createOperationSource, operationRegistryDigest, findRead, isRefusal, resolveArguments, findWrite } from '../chunk-TUD5CJ76.mjs';
|
|
7
|
+
import '../chunk-GVCNMPOS.mjs';
|
|
8
|
+
import '../chunk-LFZ37BSX.mjs';
|
|
9
|
+
import { SQLITE_USER_VERSION_MAX } from '../chunk-67M7KAH6.mjs';
|
|
10
|
+
import { SirannonError, WriteOverloadError } from '../chunk-UC3SCMIN.mjs';
|
|
5
11
|
import uWS from 'uWebSockets.js';
|
|
6
|
-
import {
|
|
12
|
+
import { createHash } from 'crypto';
|
|
7
13
|
|
|
8
14
|
// src/server/protocol.ts
|
|
9
15
|
function toExecuteResponse(result) {
|
|
@@ -124,102 +130,8 @@ function paramsBatchValidationError(value) {
|
|
|
124
130
|
return null;
|
|
125
131
|
}
|
|
126
132
|
|
|
127
|
-
// src/server/cors.ts
|
|
128
|
-
function resolveCors(cors) {
|
|
129
|
-
if (!cors) return null;
|
|
130
|
-
if (cors === true) {
|
|
131
|
-
return {
|
|
132
|
-
origin: "*",
|
|
133
|
-
methods: "GET, POST, OPTIONS",
|
|
134
|
-
headers: "Content-Type, Authorization"
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
return {
|
|
138
|
-
origin: cors.origin ?? "*",
|
|
139
|
-
methods: cors.methods?.join(", ") ?? "GET, POST, OPTIONS",
|
|
140
|
-
headers: cors.headers?.join(", ") ?? "Content-Type, Authorization"
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
function matchOrigin(cors, requestOrigin) {
|
|
144
|
-
if (typeof cors.origin === "string") return cors.origin;
|
|
145
|
-
if (cors.origin.includes(requestOrigin)) return requestOrigin;
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
function writeCorsOrigin(res, cors, requestOrigin) {
|
|
149
|
-
const allowed = matchOrigin(cors, requestOrigin);
|
|
150
|
-
if (!allowed) return;
|
|
151
|
-
res.writeHeader("Access-Control-Allow-Origin", allowed);
|
|
152
|
-
if (allowed !== "*") {
|
|
153
|
-
res.writeHeader("Vary", "Origin");
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// src/server/health.ts
|
|
158
|
-
function handleLiveness() {
|
|
159
|
-
const payload = JSON.stringify({ status: "ok" });
|
|
160
|
-
return (res) => {
|
|
161
|
-
res.cork(() => {
|
|
162
|
-
res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
|
|
163
|
-
});
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
function handleReadiness(sirannon, getReplicationStatus) {
|
|
167
|
-
return (res) => {
|
|
168
|
-
const dbs = sirannon.databases();
|
|
169
|
-
const databases = [];
|
|
170
|
-
let degraded = false;
|
|
171
|
-
for (const [id, db] of dbs) {
|
|
172
|
-
const entry = {
|
|
173
|
-
id,
|
|
174
|
-
readOnly: db.readOnly,
|
|
175
|
-
closed: db.closed
|
|
176
|
-
};
|
|
177
|
-
databases.push(entry);
|
|
178
|
-
if (db.closed) degraded = true;
|
|
179
|
-
}
|
|
180
|
-
const body = {
|
|
181
|
-
status: degraded ? "degraded" : "ok",
|
|
182
|
-
databases
|
|
183
|
-
};
|
|
184
|
-
if (getReplicationStatus) {
|
|
185
|
-
const replStatus = getReplicationStatus();
|
|
186
|
-
if (replStatus) {
|
|
187
|
-
body.replication = {
|
|
188
|
-
role: replStatus.role,
|
|
189
|
-
writeForwarding: replStatus.writeForwarding,
|
|
190
|
-
peers: replStatus.peers,
|
|
191
|
-
localSeq: replStatus.localSeq.toString(),
|
|
192
|
-
replicationGroupId: replStatus.replicationGroupId,
|
|
193
|
-
primaryTerm: replStatus.primaryTerm?.toString(),
|
|
194
|
-
currentPrimary: replStatus.currentPrimary,
|
|
195
|
-
coordinator: replStatus.coordinator,
|
|
196
|
-
controller: replStatus.controller,
|
|
197
|
-
inSyncReplicas: replStatus.inSyncReplicas,
|
|
198
|
-
laggingReplicas: replStatus.laggingReplicas,
|
|
199
|
-
syncState: replStatus.syncState,
|
|
200
|
-
readAvailability: replStatus.readAvailability,
|
|
201
|
-
writeAvailability: replStatus.writeAvailability
|
|
202
|
-
};
|
|
203
|
-
body.status = readinessStatusForReplication(replStatus, body.status);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
const payload = JSON.stringify(body);
|
|
207
|
-
res.cork(() => {
|
|
208
|
-
res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
|
|
209
|
-
});
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
function readinessStatusForReplication(replication, current) {
|
|
213
|
-
if (replication.syncState === "syncing" || replication.syncState === "catching-up") return "syncing";
|
|
214
|
-
if (replication.controller?.state === "active" && replication.writeAvailability === "unavailable")
|
|
215
|
-
return "failing_over";
|
|
216
|
-
if (replication.readAvailability === "unavailable" && replication.writeAvailability === "unavailable")
|
|
217
|
-
return "unavailable";
|
|
218
|
-
if ((replication.laggingReplicas?.length ?? 0) > 0) return "degraded";
|
|
219
|
-
return current;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
133
|
// src/server/http-common.ts
|
|
134
|
+
var SQL_NOT_ACCEPTED_MESSAGE = "This server does not accept SQL statements over the wire; call a registered operation by name";
|
|
223
135
|
function initAbortHandler(res) {
|
|
224
136
|
const listeners = [];
|
|
225
137
|
let aborted = false;
|
|
@@ -312,6 +224,8 @@ function sendError(res, status, code, message, details, headers) {
|
|
|
312
224
|
});
|
|
313
225
|
}
|
|
314
226
|
function httpStatusForError(err) {
|
|
227
|
+
const explicit = err.status;
|
|
228
|
+
if (typeof explicit === "number") return explicit;
|
|
315
229
|
switch (err.code) {
|
|
316
230
|
case "DATABASE_NOT_FOUND":
|
|
317
231
|
return 404;
|
|
@@ -322,6 +236,7 @@ function httpStatusForError(err) {
|
|
|
322
236
|
case "TRANSACTION_ERROR":
|
|
323
237
|
case "INVALID_DURABILITY":
|
|
324
238
|
case "INVALID_SYNCHRONOUS":
|
|
239
|
+
case "BATCH_VALIDATION_ERROR":
|
|
325
240
|
return 400;
|
|
326
241
|
case "STALE_PRIMARY":
|
|
327
242
|
case "PROTOCOL_VERSION_MISMATCH":
|
|
@@ -393,6 +308,251 @@ function parseWriteConcern(res, value) {
|
|
|
393
308
|
return validation;
|
|
394
309
|
}
|
|
395
310
|
|
|
311
|
+
// src/server/capabilities.ts
|
|
312
|
+
var REQUIRED_DEVICE_SYNC_CAPABILITIES = [
|
|
313
|
+
"sync.push",
|
|
314
|
+
"sync.echo-suppression",
|
|
315
|
+
"sync.ack",
|
|
316
|
+
"sync.resume",
|
|
317
|
+
"sync.snapshot",
|
|
318
|
+
"sync.migrations",
|
|
319
|
+
"sync.schema-gate",
|
|
320
|
+
"sync.stream-apply"
|
|
321
|
+
];
|
|
322
|
+
var STAGED_STREAM_CAPABILITY = "sync.staged-stream";
|
|
323
|
+
var SERVER_CAPABILITIES = [...REQUIRED_DEVICE_SYNC_CAPABILITIES, STAGED_STREAM_CAPABILITY];
|
|
324
|
+
var NAMED_QUERY_CAPABILITY = "query.named";
|
|
325
|
+
var SQL_QUERY_CAPABILITY = "query.sql";
|
|
326
|
+
function buildCapabilitiesResponse(options) {
|
|
327
|
+
const capabilities = [...SERVER_CAPABILITIES];
|
|
328
|
+
if (options?.registryDigest !== void 0) capabilities.push(NAMED_QUERY_CAPABILITY);
|
|
329
|
+
if (options?.acceptSql !== false) capabilities.push(SQL_QUERY_CAPABILITY);
|
|
330
|
+
const response = { capabilities };
|
|
331
|
+
if (options?.registryDigest !== void 0) response.registry = { digest: options.registryDigest };
|
|
332
|
+
return response;
|
|
333
|
+
}
|
|
334
|
+
function handleCapabilities(options) {
|
|
335
|
+
const response = buildCapabilitiesResponse(options);
|
|
336
|
+
return (res) => {
|
|
337
|
+
sendJson(res, response);
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/server/cors.ts
|
|
342
|
+
function resolveCors(cors) {
|
|
343
|
+
if (!cors) return null;
|
|
344
|
+
if (cors === true) {
|
|
345
|
+
return {
|
|
346
|
+
origin: "*",
|
|
347
|
+
methods: "GET, POST, OPTIONS",
|
|
348
|
+
headers: "Content-Type, Authorization"
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
origin: cors.origin ?? "*",
|
|
353
|
+
methods: cors.methods?.join(", ") ?? "GET, POST, OPTIONS",
|
|
354
|
+
headers: cors.headers?.join(", ") ?? "Content-Type, Authorization"
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function matchOrigin(cors, requestOrigin) {
|
|
358
|
+
if (typeof cors.origin === "string") return cors.origin;
|
|
359
|
+
if (cors.origin.includes(requestOrigin)) return requestOrigin;
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
function writeCorsOrigin(res, cors, requestOrigin) {
|
|
363
|
+
const allowed = matchOrigin(cors, requestOrigin);
|
|
364
|
+
if (!allowed) return;
|
|
365
|
+
res.writeHeader("Access-Control-Allow-Origin", allowed);
|
|
366
|
+
if (allowed !== "*") {
|
|
367
|
+
res.writeHeader("Vary", "Origin");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/server/health.ts
|
|
372
|
+
function handleLiveness() {
|
|
373
|
+
const payload = JSON.stringify({ status: "ok" });
|
|
374
|
+
return (res) => {
|
|
375
|
+
res.cork(() => {
|
|
376
|
+
res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function handleReadiness(sirannon, getReplicationStatus) {
|
|
381
|
+
return (res) => {
|
|
382
|
+
const dbs = sirannon.databases();
|
|
383
|
+
const databases = [];
|
|
384
|
+
let degraded = false;
|
|
385
|
+
for (const [id, db] of dbs) {
|
|
386
|
+
const entry = {
|
|
387
|
+
id,
|
|
388
|
+
readOnly: db.readOnly,
|
|
389
|
+
closed: db.closed
|
|
390
|
+
};
|
|
391
|
+
databases.push(entry);
|
|
392
|
+
if (db.closed) degraded = true;
|
|
393
|
+
}
|
|
394
|
+
const body = {
|
|
395
|
+
status: degraded ? "degraded" : "ok",
|
|
396
|
+
databases
|
|
397
|
+
};
|
|
398
|
+
if (getReplicationStatus) {
|
|
399
|
+
const replStatus = getReplicationStatus();
|
|
400
|
+
if (replStatus) {
|
|
401
|
+
body.replication = {
|
|
402
|
+
role: replStatus.role,
|
|
403
|
+
writeForwarding: replStatus.writeForwarding,
|
|
404
|
+
peers: replStatus.peers,
|
|
405
|
+
localSeq: replStatus.localSeq.toString(),
|
|
406
|
+
replicationGroupId: replStatus.replicationGroupId,
|
|
407
|
+
primaryTerm: replStatus.primaryTerm?.toString(),
|
|
408
|
+
currentPrimary: replStatus.currentPrimary,
|
|
409
|
+
coordinator: replStatus.coordinator,
|
|
410
|
+
controller: replStatus.controller,
|
|
411
|
+
inSyncReplicas: replStatus.inSyncReplicas,
|
|
412
|
+
laggingReplicas: replStatus.laggingReplicas,
|
|
413
|
+
syncState: replStatus.syncState,
|
|
414
|
+
healthReason: replStatus.health.reason,
|
|
415
|
+
readAvailability: replStatus.health.canRead ? "available" : "unavailable",
|
|
416
|
+
writeAvailability: replStatus.health.canWrite ? "available" : "unavailable"
|
|
417
|
+
};
|
|
418
|
+
body.status = readinessStatusForReplication(replStatus, body.status);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const payload = JSON.stringify(body);
|
|
422
|
+
res.cork(() => {
|
|
423
|
+
res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
|
|
424
|
+
});
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function readinessStatusForReplication(replication, current) {
|
|
428
|
+
if (replication.health.state !== "healthy") return replication.health.state;
|
|
429
|
+
return current;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// src/server/sync-protocol.ts
|
|
433
|
+
var MAX_SYNC_BATCH_CHANGES = 1e3;
|
|
434
|
+
var NODE_ID_RE = /^[0-9a-f]{32}$/;
|
|
435
|
+
var TABLE_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
436
|
+
var OPERATIONS = /* @__PURE__ */ new Set(["insert", "update", "delete"]);
|
|
437
|
+
function isValidDeviceId(candidate) {
|
|
438
|
+
return typeof candidate === "string" && NODE_ID_RE.test(candidate);
|
|
439
|
+
}
|
|
440
|
+
function isValidSchemaVersion(candidate) {
|
|
441
|
+
return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate >= 0 && candidate <= SQLITE_USER_VERSION_MAX;
|
|
442
|
+
}
|
|
443
|
+
function schemaVersionValidationError(candidate) {
|
|
444
|
+
if (candidate === void 0) return null;
|
|
445
|
+
if (!isValidSchemaVersion(candidate)) {
|
|
446
|
+
return `"schemaVersion" must be an integer from 0 to ${SQLITE_USER_VERSION_MAX}`;
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
function schemaVersionGateRefusal(deviceVersion, serverVersion) {
|
|
451
|
+
if (deviceVersion < serverVersion) {
|
|
452
|
+
return {
|
|
453
|
+
code: "MIGRATION_REQUIRED",
|
|
454
|
+
message: `Device schema version ${deviceVersion} is behind server version ${serverVersion}; apply the missing migrations before syncing`
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
if (deviceVersion > serverVersion) {
|
|
458
|
+
return {
|
|
459
|
+
code: "SCHEMA_AHEAD",
|
|
460
|
+
message: `Device schema version ${deviceVersion} is ahead of server version ${serverVersion}; the server must be migrated before this device can sync`
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
function isPlainObject(value) {
|
|
466
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
467
|
+
}
|
|
468
|
+
function changeValidationError(change, index) {
|
|
469
|
+
if (!isPlainObject(change)) return `changes[${index}] must be an object`;
|
|
470
|
+
if (typeof change.table !== "string" || !TABLE_RE.test(change.table)) {
|
|
471
|
+
return `changes[${index}].table must be a valid table name`;
|
|
472
|
+
}
|
|
473
|
+
if (typeof change.operation !== "string" || !OPERATIONS.has(change.operation)) {
|
|
474
|
+
return `changes[${index}].operation must be one of insert, update, delete`;
|
|
475
|
+
}
|
|
476
|
+
if (typeof change.rowId !== "string" || change.rowId.length === 0) {
|
|
477
|
+
return `changes[${index}].rowId must be a non-empty string`;
|
|
478
|
+
}
|
|
479
|
+
if (!isPlainObject(change.primaryKey)) return `changes[${index}].primaryKey must be an object`;
|
|
480
|
+
if (typeof change.hlc !== "string" || change.hlc.length === 0) {
|
|
481
|
+
return `changes[${index}].hlc must be a non-empty string`;
|
|
482
|
+
}
|
|
483
|
+
if (typeof change.txId !== "string" || change.txId.length === 0) {
|
|
484
|
+
return `changes[${index}].txId must be a non-empty string`;
|
|
485
|
+
}
|
|
486
|
+
if (!isValidDeviceId(change.nodeId)) return `changes[${index}].nodeId must be a 32-hex node id`;
|
|
487
|
+
if (change.newData !== null && !isPlainObject(change.newData)) {
|
|
488
|
+
return `changes[${index}].newData must be an object or null`;
|
|
489
|
+
}
|
|
490
|
+
if (change.oldData !== null && !isPlainObject(change.oldData)) {
|
|
491
|
+
return `changes[${index}].oldData must be an object or null`;
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
function syncBatchValidationError(raw) {
|
|
496
|
+
if (!isPlainObject(raw)) return 'Field "batch" is required and must be an object';
|
|
497
|
+
if (!isValidDeviceId(raw.sourceNodeId)) return '"batch.sourceNodeId" must be a 32-hex node id';
|
|
498
|
+
if (typeof raw.batchId !== "string" || raw.batchId.length === 0 || raw.batchId.length > 128) {
|
|
499
|
+
return '"batch.batchId" must be a non-empty string of at most 128 characters';
|
|
500
|
+
}
|
|
501
|
+
if (typeof raw.fromSeq !== "string" || !SEQ_STRING_RE.test(raw.fromSeq)) {
|
|
502
|
+
return '"batch.fromSeq" must be a positive integer string';
|
|
503
|
+
}
|
|
504
|
+
if (typeof raw.toSeq !== "string" || !SEQ_STRING_RE.test(raw.toSeq)) {
|
|
505
|
+
return '"batch.toSeq" must be a positive integer string';
|
|
506
|
+
}
|
|
507
|
+
if (BigInt(raw.fromSeq) > BigInt(raw.toSeq)) return '"batch.fromSeq" must not exceed "batch.toSeq"';
|
|
508
|
+
if (!isPlainObject(raw.hlcRange) || typeof raw.hlcRange.min !== "string" || typeof raw.hlcRange.max !== "string") {
|
|
509
|
+
return '"batch.hlcRange" must carry string "min" and "max" fields';
|
|
510
|
+
}
|
|
511
|
+
if (!Array.isArray(raw.changes) || raw.changes.length === 0) {
|
|
512
|
+
return '"batch.changes" must be a non-empty array';
|
|
513
|
+
}
|
|
514
|
+
if (raw.changes.length > MAX_SYNC_BATCH_CHANGES) {
|
|
515
|
+
return `"batch.changes" must not exceed ${MAX_SYNC_BATCH_CHANGES} changes`;
|
|
516
|
+
}
|
|
517
|
+
if (typeof raw.checksum !== "string" || raw.checksum.length === 0) {
|
|
518
|
+
return '"batch.checksum" must be a non-empty string';
|
|
519
|
+
}
|
|
520
|
+
for (let i = 0; i < raw.changes.length; i++) {
|
|
521
|
+
const changeError = changeValidationError(raw.changes[i], i);
|
|
522
|
+
if (changeError !== null) return changeError;
|
|
523
|
+
const change = raw.changes[i];
|
|
524
|
+
if (change.nodeId !== raw.sourceNodeId) {
|
|
525
|
+
return `changes[${i}].nodeId must match "batch.sourceNodeId"`;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
function decodeSyncBatch(body) {
|
|
531
|
+
const changes = body.changes.map((change) => ({
|
|
532
|
+
table: change.table,
|
|
533
|
+
operation: change.operation,
|
|
534
|
+
rowId: change.rowId,
|
|
535
|
+
primaryKey: decodeTaggedValues(change.primaryKey),
|
|
536
|
+
hlc: change.hlc,
|
|
537
|
+
txId: change.txId,
|
|
538
|
+
nodeId: change.nodeId,
|
|
539
|
+
newData: change.newData === null ? null : decodeTaggedValues(change.newData),
|
|
540
|
+
oldData: change.oldData === null ? null : decodeTaggedValues(change.oldData)
|
|
541
|
+
}));
|
|
542
|
+
return {
|
|
543
|
+
sourceNodeId: body.sourceNodeId,
|
|
544
|
+
batchId: body.batchId,
|
|
545
|
+
fromSeq: BigInt(body.fromSeq),
|
|
546
|
+
toSeq: BigInt(body.toSeq),
|
|
547
|
+
hlcRange: { min: body.hlcRange.min, max: body.hlcRange.max },
|
|
548
|
+
changes,
|
|
549
|
+
checksum: body.checksum
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function toChangesResponse(result) {
|
|
553
|
+
return { applied: result.applied, skipped: result.skipped, conflicts: result.conflicts };
|
|
554
|
+
}
|
|
555
|
+
|
|
396
556
|
// src/server/wire-rows.ts
|
|
397
557
|
async function queryWireRows(target, sql, params, options) {
|
|
398
558
|
if (target.queryForWire) {
|
|
@@ -589,10 +749,76 @@ function handleLoad(sirannon, resolveTarget) {
|
|
|
589
749
|
}
|
|
590
750
|
};
|
|
591
751
|
}
|
|
592
|
-
function
|
|
593
|
-
return (res, dbId) => {
|
|
594
|
-
|
|
595
|
-
|
|
752
|
+
function handleChanges(sirannon, resolveTarget) {
|
|
753
|
+
return async (res, dbId, rawBody, abort) => {
|
|
754
|
+
const body = parseBody(res, rawBody);
|
|
755
|
+
if (!body) return;
|
|
756
|
+
const batchError = syncBatchValidationError(body.batch);
|
|
757
|
+
if (batchError !== null) {
|
|
758
|
+
sendError(res, 400, "INVALID_REQUEST", batchError);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const schemaVersionError = schemaVersionValidationError(body.schemaVersion);
|
|
762
|
+
if (schemaVersionError !== null) {
|
|
763
|
+
sendError(res, 400, "INVALID_REQUEST", schemaVersionError);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
|
|
767
|
+
if (!target) return;
|
|
768
|
+
const appliedMigrations = target.appliedMigrations;
|
|
769
|
+
if (typeof appliedMigrations === "function") {
|
|
770
|
+
try {
|
|
771
|
+
const serverVersion = highestMigrationVersion(await appliedMigrations.call(target));
|
|
772
|
+
const refusal = schemaVersionGateRefusal(body.schemaVersion ?? 0, serverVersion);
|
|
773
|
+
if (refusal !== null) {
|
|
774
|
+
if (abort.aborted) return;
|
|
775
|
+
sendError(res, 409, refusal.code, refusal.message, { serverVersion });
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
} catch (err) {
|
|
779
|
+
sendCaughtError(res, abort, err);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const applyChanges = target.applyChanges;
|
|
784
|
+
if (typeof applyChanges !== "function") {
|
|
785
|
+
sendError(
|
|
786
|
+
res,
|
|
787
|
+
501,
|
|
788
|
+
"SYNC_UNSUPPORTED",
|
|
789
|
+
"The execution target for this database does not support applying sync changes"
|
|
790
|
+
);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
const result = await applyChanges.call(target, decodeSyncBatch(body.batch));
|
|
795
|
+
if (abort.aborted) return;
|
|
796
|
+
sendJson(res, toChangesResponse(result));
|
|
797
|
+
} catch (err) {
|
|
798
|
+
sendCaughtError(res, abort, err);
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
function sendClusterUnavailable(res, dbId) {
|
|
803
|
+
sendError(res, 404, "NOT_FOUND", `Cluster status is not available for database '${dbId}'`);
|
|
804
|
+
}
|
|
805
|
+
function handleClusterStatus(getClusterStatus, authorizeClusterStatus) {
|
|
806
|
+
return async (res, dbId, ctx, abort) => {
|
|
807
|
+
if (!getClusterStatus || !authorizeClusterStatus) {
|
|
808
|
+
sendClusterUnavailable(res, dbId);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
let allowed;
|
|
812
|
+
try {
|
|
813
|
+
allowed = await authorizeClusterStatus(ctx);
|
|
814
|
+
} catch {
|
|
815
|
+
if (abort.aborted) return;
|
|
816
|
+
sendError(res, 500, "HOOK_ERROR", "authorizeClusterStatus threw an error");
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (abort.aborted) return;
|
|
820
|
+
if (!allowed) {
|
|
821
|
+
sendClusterUnavailable(res, dbId);
|
|
596
822
|
return;
|
|
597
823
|
}
|
|
598
824
|
const status = getClusterStatus(dbId);
|
|
@@ -615,38 +841,514 @@ function toClusterStatusResponse(status) {
|
|
|
615
841
|
};
|
|
616
842
|
}
|
|
617
843
|
|
|
618
|
-
// src/server/
|
|
619
|
-
function
|
|
620
|
-
return
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
844
|
+
// src/server/http-migrations.ts
|
|
845
|
+
function handleMigrationList(sirannon) {
|
|
846
|
+
return async (res, dbId, rawBody, abort) => {
|
|
847
|
+
const body = parseBody(res, rawBody);
|
|
848
|
+
if (!body) return;
|
|
849
|
+
const afterError = schemaVersionValidationError(body.after);
|
|
850
|
+
if (afterError !== null) {
|
|
851
|
+
sendError(res, 400, "INVALID_REQUEST", afterError.replace('"schemaVersion"', '"after"'));
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
const after = body.after ?? 0;
|
|
855
|
+
let database;
|
|
856
|
+
try {
|
|
857
|
+
database = await sirannon.resolve(dbId);
|
|
858
|
+
} catch (err) {
|
|
859
|
+
sendCaughtError(res, abort, err);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (abort.aborted) return;
|
|
863
|
+
if (!database) {
|
|
864
|
+
sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
try {
|
|
868
|
+
const applied = await database.appliedMigrations();
|
|
869
|
+
const registry = new Map((await sirannon.registryMigrations()).map((migration) => [migration.version, migration]));
|
|
870
|
+
const migrations = applied.map((row) => {
|
|
871
|
+
const entry = { version: row.version, name: row.name, checksum: row.checksum };
|
|
872
|
+
if (row.version > after && row.checksum !== null) {
|
|
873
|
+
const source = registry.get(row.version);
|
|
874
|
+
if (source !== void 0 && typeof source.up === "string" && migrationChecksum(source.up) === row.checksum) {
|
|
875
|
+
entry.up = source.up;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return entry;
|
|
879
|
+
});
|
|
880
|
+
if (abort.aborted) return;
|
|
881
|
+
const response = { serverVersion: highestMigrationVersion(applied), migrations };
|
|
882
|
+
sendJson(res, response);
|
|
883
|
+
} catch (err) {
|
|
884
|
+
sendCaughtError(res, abort, err);
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// src/server/http-operations.ts
|
|
890
|
+
function parseOperationBody(res, rawBody) {
|
|
891
|
+
if (rawBody.length === 0) return {};
|
|
892
|
+
return parseBody(res, rawBody);
|
|
893
|
+
}
|
|
894
|
+
function decodeArguments(res, raw) {
|
|
895
|
+
if (raw === void 0) return {};
|
|
896
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
897
|
+
sendError(res, 400, "INVALID_REQUEST", 'Field "args" must be an object');
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
const decoded = decodeBoundParams(raw, "args");
|
|
901
|
+
if (!decoded.ok) {
|
|
902
|
+
sendError(res, 400, "INVALID_REQUEST", decoded.message);
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
return decoded.value ?? {};
|
|
906
|
+
}
|
|
907
|
+
function handleOperationQuery(sirannon, registry, resolveTarget) {
|
|
908
|
+
return async (res, dbId, name, identity, rawBody, abort) => {
|
|
909
|
+
const operation = findRead(registry, dbId, name);
|
|
910
|
+
if (isRefusal(operation)) {
|
|
911
|
+
sendError(res, operation.status, operation.code, operation.message);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
const body = parseOperationBody(res, rawBody);
|
|
915
|
+
if (!body) return;
|
|
916
|
+
const readConcern = parseReadConcern(res, body.readConcern);
|
|
917
|
+
if (!readConcern.ok) return;
|
|
918
|
+
const supplied = decodeArguments(res, body.args);
|
|
919
|
+
if (!supplied) return;
|
|
920
|
+
const args = resolveArguments(operation, supplied, identity);
|
|
921
|
+
if (!args.ok) {
|
|
922
|
+
sendError(res, args.refusal.status, args.refusal.code, args.refusal.message);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
let statement;
|
|
926
|
+
try {
|
|
927
|
+
statement = operation.statement(args.value);
|
|
928
|
+
} catch (err) {
|
|
929
|
+
sendCaughtError(res, abort, err);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
|
|
933
|
+
if (!target) return;
|
|
934
|
+
try {
|
|
935
|
+
const rows = await queryWireRows(
|
|
936
|
+
target,
|
|
937
|
+
statement.sql,
|
|
938
|
+
statement.params,
|
|
939
|
+
readConcern.value ? { readConcern: readConcern.value } : void 0
|
|
940
|
+
);
|
|
941
|
+
if (abort.aborted) return;
|
|
942
|
+
sendJson(res, { rows });
|
|
943
|
+
} catch (err) {
|
|
944
|
+
sendCaughtError(res, abort, err);
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
function handleOperationExecute(sirannon, registry, resolveTarget) {
|
|
949
|
+
return async (res, dbId, name, identity, rawBody, abort) => {
|
|
950
|
+
const operation = findWrite(registry, dbId, name);
|
|
951
|
+
if (isRefusal(operation)) {
|
|
952
|
+
sendError(res, operation.status, operation.code, operation.message);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const body = parseOperationBody(res, rawBody);
|
|
956
|
+
if (!body) return;
|
|
957
|
+
const writeConcern = parseWriteConcern(res, body.writeConcern);
|
|
958
|
+
if (!writeConcern.ok) return;
|
|
959
|
+
const supplied = decodeArguments(res, body.args);
|
|
960
|
+
if (!supplied) return;
|
|
961
|
+
const args = resolveArguments(operation, supplied, identity);
|
|
962
|
+
if (!args.ok) {
|
|
963
|
+
sendError(res, args.refusal.status, args.refusal.code, args.refusal.message);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
let statements;
|
|
967
|
+
try {
|
|
968
|
+
const produced = operation.statements(args.value);
|
|
969
|
+
statements = Array.isArray(produced) ? produced : [produced];
|
|
970
|
+
} catch (err) {
|
|
971
|
+
sendCaughtError(res, abort, err);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (statements.length === 0) {
|
|
975
|
+
sendError(res, 500, "INTERNAL_ERROR", `Operation '${name}' produced no statements`);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
|
|
979
|
+
if (!target) return;
|
|
980
|
+
const txOptions = writeConcern.value ? { writeConcern: writeConcern.value } : void 0;
|
|
981
|
+
try {
|
|
982
|
+
const results = target.executeTransaction ? await target.executeTransaction(statements, txOptions) : await target.transaction(async (tx) => {
|
|
983
|
+
const txResults = [];
|
|
984
|
+
for (const statement of statements) {
|
|
985
|
+
txResults.push(await tx.execute(statement.sql, statement.params));
|
|
986
|
+
}
|
|
987
|
+
return txResults;
|
|
988
|
+
}, txOptions);
|
|
989
|
+
if (abort.aborted) return;
|
|
990
|
+
sendJson(res, { results: results.map(toExecuteResponse) });
|
|
991
|
+
} catch (err) {
|
|
992
|
+
sendCaughtError(res, abort, err);
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// src/server/snapshot-protocol.ts
|
|
998
|
+
var SNAPSHOT_MAX_PAGE_ROWS = 1e3;
|
|
999
|
+
var SNAPSHOT_DEFAULT_PAGE_ROWS = 500;
|
|
1000
|
+
var SNAPSHOT_PAGE_BYTE_CAP = 8 * 1024 * 1024;
|
|
1001
|
+
function isDumpableTableName(candidate) {
|
|
1002
|
+
return typeof candidate === "string" && IDENTIFIER_RE.test(candidate) && !candidate.startsWith(INTERNAL_TABLE_PREFIX) && !candidate.toLowerCase().startsWith("sqlite_");
|
|
1003
|
+
}
|
|
1004
|
+
function snapshotPageValidationError(raw) {
|
|
1005
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
1006
|
+
return "Request body must be an object";
|
|
1007
|
+
}
|
|
1008
|
+
const record = raw;
|
|
1009
|
+
if (!isDumpableTableName(record.table)) {
|
|
1010
|
+
return 'Field "table" is required and must be a dumpable table name';
|
|
1011
|
+
}
|
|
1012
|
+
if (record.afterKey !== void 0) {
|
|
1013
|
+
if (!Array.isArray(record.afterKey) || record.afterKey.length === 0 || record.afterKey.length > 16) {
|
|
1014
|
+
return '"afterKey" must be a non-empty array of at most 16 key values';
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (record.limit !== void 0) {
|
|
1018
|
+
if (typeof record.limit !== "number" || !Number.isInteger(record.limit) || record.limit < 1) {
|
|
1019
|
+
return '"limit" must be a positive integer';
|
|
1020
|
+
}
|
|
1021
|
+
if (record.limit > SNAPSHOT_MAX_PAGE_ROWS) {
|
|
1022
|
+
return `"limit" must not exceed ${SNAPSHOT_MAX_PAGE_ROWS}`;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// src/server/http-snapshot.ts
|
|
1029
|
+
async function resolveSnapshotDatabase(res, abort, sirannon, dbId) {
|
|
1030
|
+
let database;
|
|
626
1031
|
try {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
1032
|
+
database = await sirannon.resolve(dbId);
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
if (abort.aborted) return null;
|
|
1035
|
+
sendCaughtError(res, abort, err);
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
if (abort.aborted) return null;
|
|
1039
|
+
if (!database) {
|
|
1040
|
+
sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
if (database.path === ":memory:") {
|
|
1044
|
+
sendError(res, 400, "SNAPSHOT_UNSUPPORTED", "Snapshots require file-based databases");
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
return database;
|
|
1048
|
+
}
|
|
1049
|
+
async function changeLogStartSeq(conn) {
|
|
1050
|
+
if (!await selectTableExists(conn, CHANGES_TABLE)) return 0n;
|
|
1051
|
+
return selectMaxChangeSeq(conn);
|
|
1052
|
+
}
|
|
1053
|
+
function handleSnapshotManifest(sirannon) {
|
|
1054
|
+
return async (res, dbId, _rawBody, abort) => {
|
|
1055
|
+
const database = await resolveSnapshotDatabase(res, abort, sirannon, dbId);
|
|
1056
|
+
if (!database) return;
|
|
1057
|
+
try {
|
|
1058
|
+
let epoch = "";
|
|
1059
|
+
await database.runCdcMaintenance(async (writer) => {
|
|
1060
|
+
epoch = await ensureCdcEpoch(writer);
|
|
1061
|
+
});
|
|
1062
|
+
const conn = await sirannon.driver.open(database.path, { walMode: true });
|
|
1063
|
+
try {
|
|
1064
|
+
const startSeq = await changeLogStartSeq(conn);
|
|
1065
|
+
const schema = await dumpSchema(conn);
|
|
1066
|
+
const tableNames = await tablesInFkOrder(conn);
|
|
1067
|
+
const tables = [];
|
|
1068
|
+
for (const name of tableNames) {
|
|
1069
|
+
tables.push({ name, rowCount: await selectCountTableRows(conn, name) });
|
|
1070
|
+
}
|
|
1071
|
+
const migrations = await selectTableExists(conn, MIGRATIONS_TABLE) ? await selectAppliedMigrations(conn) : [];
|
|
1072
|
+
if (abort.aborted) return;
|
|
1073
|
+
const manifest = {
|
|
1074
|
+
databaseId: dbId,
|
|
1075
|
+
startSeq: startSeq.toString(),
|
|
1076
|
+
epoch,
|
|
1077
|
+
schema,
|
|
1078
|
+
tables,
|
|
1079
|
+
migrations
|
|
1080
|
+
};
|
|
1081
|
+
sendJson(res, manifest);
|
|
1082
|
+
} finally {
|
|
1083
|
+
await conn.close();
|
|
1084
|
+
}
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
sendCaughtError(res, abort, err);
|
|
631
1087
|
}
|
|
632
|
-
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
function trimPageToByteCap(rows, checksum, isLast) {
|
|
1091
|
+
let kept = rows;
|
|
1092
|
+
let keptChecksum = checksum;
|
|
1093
|
+
let keptIsLast = isLast;
|
|
1094
|
+
let canonical = canonicaliseForChecksum(kept);
|
|
1095
|
+
while (canonical.length > SNAPSHOT_PAGE_BYTE_CAP && kept.length > 1) {
|
|
1096
|
+
kept = kept.slice(0, Math.ceil(kept.length / 2));
|
|
1097
|
+
canonical = canonicaliseForChecksum(kept);
|
|
1098
|
+
keptChecksum = createHash("sha256").update(canonical).digest("hex");
|
|
1099
|
+
keptIsLast = false;
|
|
1100
|
+
}
|
|
1101
|
+
return { rows: kept, checksum: keptChecksum, isLast: keptIsLast };
|
|
1102
|
+
}
|
|
1103
|
+
function handleSnapshotPage(sirannon) {
|
|
1104
|
+
return async (res, dbId, rawBody, abort) => {
|
|
1105
|
+
const body = parseBody(res, rawBody);
|
|
1106
|
+
if (!body) return;
|
|
1107
|
+
const validationError = snapshotPageValidationError(body);
|
|
1108
|
+
if (validationError !== null) {
|
|
1109
|
+
sendError(res, 400, "INVALID_REQUEST", validationError);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
const database = await resolveSnapshotDatabase(res, abort, sirannon, dbId);
|
|
1113
|
+
if (!database) return;
|
|
1114
|
+
try {
|
|
1115
|
+
const conn = await sirannon.driver.open(database.path, { walMode: true });
|
|
1116
|
+
try {
|
|
1117
|
+
if (!await selectTableExists(conn, body.table)) {
|
|
1118
|
+
sendError(res, 404, "TABLE_NOT_FOUND", `Table '${body.table}' not found`);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const pkResolver = new PkResolver(conn);
|
|
1122
|
+
const pkColumns = await pkResolver.forTableOnConnection(conn, body.table);
|
|
1123
|
+
let startAfter;
|
|
1124
|
+
if (body.afterKey !== void 0) {
|
|
1125
|
+
if (body.afterKey.length !== pkColumns.length) {
|
|
1126
|
+
sendError(
|
|
1127
|
+
res,
|
|
1128
|
+
400,
|
|
1129
|
+
"INVALID_REQUEST",
|
|
1130
|
+
`"afterKey" must carry ${pkColumns.length} value(s) for table '${body.table}'`
|
|
1131
|
+
);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
startAfter = decodeTaggedValues(body.afterKey);
|
|
1135
|
+
}
|
|
1136
|
+
const limit = body.limit ?? SNAPSHOT_DEFAULT_PAGE_ROWS;
|
|
1137
|
+
const pages = dumpTablePages(conn, pkResolver, body.table, limit, startAfter);
|
|
1138
|
+
const first = await pages.next();
|
|
1139
|
+
await pages.return(void 0);
|
|
1140
|
+
if (abort.aborted) return;
|
|
1141
|
+
if (first.done || first.value === void 0) {
|
|
1142
|
+
const empty = { rows: [], checksum: "", done: true, nextKey: null };
|
|
1143
|
+
sendJson(res, empty);
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
const page = trimPageToByteCap(first.value.rows, first.value.checksum, first.value.isLast);
|
|
1147
|
+
let nextKey = null;
|
|
1148
|
+
if (!page.isLast && page.rows.length > 0) {
|
|
1149
|
+
const lastRow = page.rows[page.rows.length - 1];
|
|
1150
|
+
nextKey = encodeTaggedValues(pkColumns.map((column) => lastRow[column]));
|
|
1151
|
+
}
|
|
1152
|
+
const response = {
|
|
1153
|
+
rows: encodeWireRowsInPlace(page.rows),
|
|
1154
|
+
checksum: page.checksum,
|
|
1155
|
+
done: page.isLast,
|
|
1156
|
+
nextKey
|
|
1157
|
+
};
|
|
1158
|
+
sendJson(res, response);
|
|
1159
|
+
} finally {
|
|
1160
|
+
await conn.close();
|
|
1161
|
+
}
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
sendCaughtError(res, abort, err);
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// src/core/ws-handshake.ts
|
|
1169
|
+
var SIRANNON_WS_SUBPROTOCOL = "sirannon.v1";
|
|
1170
|
+
var WS_CLOSE_UNAUTHENTICATED = 4401;
|
|
1171
|
+
var WS_CLOSE_FORBIDDEN = 4403;
|
|
1172
|
+
function selectSubprotocol(header) {
|
|
1173
|
+
const offered = header.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
|
|
1174
|
+
if (offered.length === 0) return { ok: true, protocol: "" };
|
|
1175
|
+
if (offered.includes(SIRANNON_WS_SUBPROTOCOL)) return { ok: true, protocol: SIRANNON_WS_SUBPROTOCOL };
|
|
1176
|
+
return { ok: false };
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// src/server/request-hook.ts
|
|
1180
|
+
function decodeRemoteAddress(res) {
|
|
1181
|
+
return Buffer.from(res.getRemoteAddressAsText()).toString();
|
|
1182
|
+
}
|
|
1183
|
+
var CLOSE_REASON_MAX_BYTES = 123;
|
|
1184
|
+
var RETURNED_REFUSAL_MESSAGE = "The authenticate hook returned a refusal object rather than an identity. A hook refuses a request by throwing a SirannonError; every value it returns becomes the caller identity.";
|
|
1185
|
+
function looksLikeRefusal(value) {
|
|
1186
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1187
|
+
const candidate = value;
|
|
1188
|
+
return typeof candidate.status === "number" && typeof candidate.code === "string" && typeof candidate.message === "string";
|
|
1189
|
+
}
|
|
1190
|
+
async function invokeAuthenticate(ctx, hook) {
|
|
1191
|
+
try {
|
|
1192
|
+
const identity = await hook(ctx);
|
|
1193
|
+
if (looksLikeRefusal(identity)) return { status: "returned-refusal" };
|
|
1194
|
+
return { status: "identity", identity };
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
return { status: "threw", error };
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
function sendRefusal(res, abort, outcome) {
|
|
1200
|
+
if (!abort.claim()) return;
|
|
1201
|
+
if (outcome.status === "returned-refusal") {
|
|
1202
|
+
sendError(res, 500, "HOOK_ERROR", RETURNED_REFUSAL_MESSAGE);
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
if (outcome.status === "threw" && outcome.error instanceof SirannonError) {
|
|
1206
|
+
sendCaughtError(res, abort, outcome.error);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
sendError(res, 500, "HOOK_ERROR", "authenticate hook threw an error");
|
|
1210
|
+
}
|
|
1211
|
+
function truncateCloseReason(reason) {
|
|
1212
|
+
if (Buffer.byteLength(reason, "utf-8") <= CLOSE_REASON_MAX_BYTES) return reason;
|
|
1213
|
+
let truncated = reason.slice(0, CLOSE_REASON_MAX_BYTES);
|
|
1214
|
+
while (truncated.length > 0 && Buffer.byteLength(truncated, "utf-8") > CLOSE_REASON_MAX_BYTES) {
|
|
1215
|
+
truncated = truncated.slice(0, -1);
|
|
1216
|
+
}
|
|
1217
|
+
return truncated;
|
|
1218
|
+
}
|
|
1219
|
+
function upgradeRefusalFor(error) {
|
|
1220
|
+
if (!(error instanceof SirannonError)) return void 0;
|
|
1221
|
+
const status = httpStatusForError(error);
|
|
1222
|
+
if (status !== 401 && status !== 403) return void 0;
|
|
1223
|
+
return {
|
|
1224
|
+
code: status === 403 ? WS_CLOSE_FORBIDDEN : WS_CLOSE_UNAUTHENTICATED,
|
|
1225
|
+
reason: truncateCloseReason(`${error.code}: ${error.message}`)
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
async function runAuthenticate(res, abort, ctx, hook) {
|
|
1229
|
+
const outcome = await invokeAuthenticate(ctx, hook);
|
|
1230
|
+
if (outcome.status === "identity") return { ok: true, identity: outcome.identity };
|
|
1231
|
+
sendRefusal(res, abort, outcome);
|
|
1232
|
+
return { ok: false };
|
|
1233
|
+
}
|
|
1234
|
+
async function authenticateUpgrade(res, abort, ctx, hook) {
|
|
1235
|
+
const outcome = await invokeAuthenticate(ctx, hook);
|
|
1236
|
+
if (outcome.status === "identity") return { ok: true, identity: outcome.identity };
|
|
1237
|
+
if (outcome.status === "threw") {
|
|
1238
|
+
const refusal = upgradeRefusalFor(outcome.error);
|
|
1239
|
+
if (refusal) return { ok: false, refusal };
|
|
1240
|
+
}
|
|
1241
|
+
sendRefusal(res, abort, outcome);
|
|
1242
|
+
return { ok: false, refusal: void 0 };
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// src/server/operation-route.ts
|
|
1246
|
+
function decodePathSegment(value) {
|
|
1247
|
+
try {
|
|
1248
|
+
return decodeURIComponent(value);
|
|
633
1249
|
} catch {
|
|
634
|
-
|
|
635
|
-
return false;
|
|
1250
|
+
return null;
|
|
636
1251
|
}
|
|
637
1252
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
1253
|
+
function wrapOperationRoute(deps, handler) {
|
|
1254
|
+
return (res, req) => {
|
|
1255
|
+
const dbId = req.getParameter(0) ?? "";
|
|
1256
|
+
const rawName = req.getParameter(1) ?? "";
|
|
1257
|
+
const method = req.getMethod();
|
|
1258
|
+
const path = req.getUrl();
|
|
1259
|
+
if (deps.cors) writeCorsOrigin(res, deps.cors, req.getHeader("origin"));
|
|
1260
|
+
const name = decodePathSegment(rawName);
|
|
1261
|
+
if (name === null) {
|
|
1262
|
+
sendError(res, 400, "INVALID_REQUEST", `Operation name '${rawName}' is not valid percent-encoding`);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const headers = {};
|
|
1266
|
+
req.forEach((key, value) => {
|
|
1267
|
+
headers[key] = value;
|
|
1268
|
+
});
|
|
1269
|
+
const abort = initAbortHandler(res);
|
|
1270
|
+
const bodyPromise = readBody(res, deps.maxBodyBytes, abort);
|
|
1271
|
+
const ctx = {
|
|
1272
|
+
headers,
|
|
1273
|
+
method,
|
|
1274
|
+
path,
|
|
1275
|
+
databaseId: dbId,
|
|
1276
|
+
remoteAddress: decodeRemoteAddress(res)
|
|
1277
|
+
};
|
|
1278
|
+
const run = async () => {
|
|
1279
|
+
const rawBody = await bodyPromise;
|
|
1280
|
+
let identity;
|
|
1281
|
+
if (deps.authenticateHook) {
|
|
1282
|
+
const authenticated = await runAuthenticate(res, abort, ctx, deps.authenticateHook);
|
|
1283
|
+
if (!authenticated.ok) return;
|
|
1284
|
+
identity = authenticated.identity;
|
|
1285
|
+
}
|
|
1286
|
+
if (!abort.claim()) return;
|
|
1287
|
+
try {
|
|
1288
|
+
await handler(res, dbId, name, identity, rawBody, abort);
|
|
1289
|
+
} catch {
|
|
1290
|
+
if (!abort.aborted) {
|
|
1291
|
+
sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
};
|
|
1295
|
+
run().catch(() => {
|
|
1296
|
+
});
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// src/server/device-cursors.ts
|
|
1301
|
+
var DEFAULT_DEVICE_CURSOR_RETENTION_MS = 30 * 24 * 36e5;
|
|
1302
|
+
async function upsertDeviceAck(conn, deviceId, seq) {
|
|
1303
|
+
await ensureDeviceCursorsTable(conn);
|
|
1304
|
+
await upsertDeviceCursor(conn, deviceId, seq, Date.now() / 1e3);
|
|
1305
|
+
}
|
|
1306
|
+
function evictStaleDeviceCursors(conn, retentionMs) {
|
|
1307
|
+
return deleteDeviceCursorsUpdatedBefore(conn, Date.now() / 1e3 - retentionMs / 1e3);
|
|
1308
|
+
}
|
|
1309
|
+
async function effectiveMinDeviceCursor(conn, retentionMs, changesTable = CHANGES_TABLE) {
|
|
1310
|
+
if (!await selectTableExists(conn, DEVICE_CURSORS_TABLE)) return null;
|
|
1311
|
+
await evictStaleDeviceCursors(conn, retentionMs);
|
|
1312
|
+
const cursors = await selectDeviceCursors(conn);
|
|
1313
|
+
if (cursors.length === 0) return null;
|
|
1314
|
+
const nextForeignStmt = await conn.prepare(selectMinForeignChangeSeqSql(changesTable));
|
|
1315
|
+
const maxSeq = await selectMaxChangeSeq(conn, changesTable);
|
|
1316
|
+
let min = null;
|
|
1317
|
+
for (const cursor of cursors) {
|
|
1318
|
+
const acked = cursor.ackedSeq;
|
|
1319
|
+
const foreignRow = await nextForeignStmt.get(acked.toString(), cursor.deviceId);
|
|
1320
|
+
const nextForeign = foreignRow?.seq === null || foreignRow?.seq === void 0 ? null : BigInt(foreignRow.seq);
|
|
1321
|
+
const effective = nextForeign === null ? maxSeq : nextForeign - 1n;
|
|
1322
|
+
if (min === null || effective < min) {
|
|
1323
|
+
min = effective;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return min;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// src/server/ws-ack.ts
|
|
1330
|
+
async function handleAckMessage(deps, conn, state, msg, id) {
|
|
1331
|
+
if (!isValidDeviceId(msg.deviceId)) {
|
|
1332
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", '"deviceId" must be a 32-hex device id');
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (typeof msg.seq !== "string" || !SEQ_STRING_RE.test(msg.seq)) {
|
|
1336
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", '"seq" must be a non-negative integer string');
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
const deviceId = msg.deviceId;
|
|
1340
|
+
const seq = BigInt(msg.seq);
|
|
1341
|
+
try {
|
|
1342
|
+
await state.database.runCdcMaintenance((writer) => upsertDeviceAck(writer, deviceId, seq));
|
|
1343
|
+
for (const stream of state.deviceStreams.values()) {
|
|
1344
|
+
if (stream.deviceId === deviceId) {
|
|
1345
|
+
stream.onAck(seq);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
deps.sendResult(conn, id, { acked: true, seq: seq.toString() });
|
|
1349
|
+
} catch (err) {
|
|
1350
|
+
deps.sendSirannonError(conn, id, err);
|
|
648
1351
|
}
|
|
649
|
-
return value;
|
|
650
1352
|
}
|
|
651
1353
|
|
|
652
1354
|
// src/server/ws-cdc.ts
|
|
@@ -655,12 +1357,14 @@ var CLEANUP_INTERVAL_TICKS = 100;
|
|
|
655
1357
|
var CdcContextRegistry = class {
|
|
656
1358
|
sirannon;
|
|
657
1359
|
retentionMs;
|
|
1360
|
+
deviceCursorRetentionMs;
|
|
658
1361
|
contexts = /* @__PURE__ */ new Map();
|
|
659
1362
|
pending = /* @__PURE__ */ new Map();
|
|
660
1363
|
closed = false;
|
|
661
|
-
constructor(sirannon, retentionMs) {
|
|
1364
|
+
constructor(sirannon, retentionMs, deviceCursorRetentionMs) {
|
|
662
1365
|
this.sirannon = sirannon;
|
|
663
1366
|
this.retentionMs = retentionMs;
|
|
1367
|
+
this.deviceCursorRetentionMs = deviceCursorRetentionMs ?? DEFAULT_DEVICE_CURSOR_RETENTION_MS;
|
|
664
1368
|
}
|
|
665
1369
|
async ensure(databaseId, database) {
|
|
666
1370
|
const existing = this.contexts.get(databaseId);
|
|
@@ -742,11 +1446,20 @@ var CdcContextRegistry = class {
|
|
|
742
1446
|
if (events.length > 0) {
|
|
743
1447
|
manager.dispatch(events);
|
|
744
1448
|
}
|
|
1449
|
+
manager.endBatch(tracker.pollEndedAtTxBoundary);
|
|
745
1450
|
consecutiveErrors = 0;
|
|
746
1451
|
tickCount++;
|
|
747
1452
|
if (tickCount >= CLEANUP_INTERVAL_TICKS) {
|
|
748
1453
|
tickCount = 0;
|
|
749
|
-
await database.runCdcMaintenance(
|
|
1454
|
+
await database.runCdcMaintenance(async (writer) => {
|
|
1455
|
+
const minCursor = await effectiveMinDeviceCursor(writer, this.deviceCursorRetentionMs);
|
|
1456
|
+
if (minCursor === null) {
|
|
1457
|
+
tracker.clearPruneBoundary();
|
|
1458
|
+
} else {
|
|
1459
|
+
tracker.setPruneBoundary(minCursor);
|
|
1460
|
+
}
|
|
1461
|
+
await tracker.cleanup(writer);
|
|
1462
|
+
}).catch(() => {
|
|
750
1463
|
});
|
|
751
1464
|
}
|
|
752
1465
|
} catch {
|
|
@@ -764,273 +1477,965 @@ var CdcContextRegistry = class {
|
|
|
764
1477
|
}
|
|
765
1478
|
};
|
|
766
1479
|
|
|
767
|
-
// src/server/ws-
|
|
768
|
-
var
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
1480
|
+
// src/server/ws-connection.ts
|
|
1481
|
+
var WS_CLOSE_OVERLOADED = 4290;
|
|
1482
|
+
|
|
1483
|
+
// src/server/ws-device-frames.ts
|
|
1484
|
+
var DEVICE_FRAME_TARGET_BYTES = 65536;
|
|
1485
|
+
function wireChangeEvent(event) {
|
|
1486
|
+
return {
|
|
1487
|
+
type: event.type,
|
|
1488
|
+
table: event.table,
|
|
1489
|
+
row: encodeTaggedValues(event.row),
|
|
1490
|
+
oldRow: event.oldRow === void 0 ? void 0 : encodeTaggedValues(event.oldRow),
|
|
1491
|
+
seq: event.seq.toString(),
|
|
1492
|
+
timestamp: event.timestamp,
|
|
1493
|
+
...event.hlc !== void 0 ? { hlc: event.hlc } : {},
|
|
1494
|
+
...event.origin !== void 0 ? { origin: event.origin } : {},
|
|
1495
|
+
...event.rowId !== void 0 ? { rowId: event.rowId } : {},
|
|
1496
|
+
...event.txId !== void 0 ? { txId: event.txId } : {},
|
|
1497
|
+
...event.txEnd === true ? { txEnd: true } : {}
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
var DeviceFramePacker = class {
|
|
1501
|
+
constructor(subscriptionId, sendText, targetBytes = DEVICE_FRAME_TARGET_BYTES) {
|
|
1502
|
+
this.subscriptionId = subscriptionId;
|
|
1503
|
+
this.sendText = sendText;
|
|
1504
|
+
this.targetBytes = targetBytes;
|
|
1505
|
+
}
|
|
1506
|
+
parts = [];
|
|
1507
|
+
bytes = 0;
|
|
1508
|
+
append(event) {
|
|
1509
|
+
let encoded;
|
|
1510
|
+
try {
|
|
1511
|
+
encoded = JSON.stringify(wireChangeEvent(event));
|
|
1512
|
+
} catch {
|
|
1513
|
+
return "dropped";
|
|
800
1514
|
}
|
|
801
|
-
|
|
802
|
-
if (this.
|
|
803
|
-
this.
|
|
804
|
-
|
|
805
|
-
return;
|
|
1515
|
+
let outcome = "queued";
|
|
1516
|
+
if (this.parts.length > 0 && this.bytes + encoded.length > this.targetBytes) {
|
|
1517
|
+
outcome = this.flush();
|
|
1518
|
+
if (outcome === "dropped") return outcome;
|
|
806
1519
|
}
|
|
807
|
-
this.
|
|
808
|
-
this.
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
let cursor = this.sinceSeq;
|
|
812
|
-
while (this.mode === "priming" && cursor < boundary) {
|
|
813
|
-
const events = await tracker.readSince(conn, table, cursor, boundary, REPLAY_BATCH_SIZE);
|
|
814
|
-
if (events.length === 0) break;
|
|
815
|
-
for (const event of events) {
|
|
816
|
-
if (filter && !changeMatchesFilter(event, filter)) continue;
|
|
817
|
-
if (!this.emit(event)) return;
|
|
818
|
-
}
|
|
819
|
-
const lastEvent = events[events.length - 1];
|
|
820
|
-
if (lastEvent === void 0) break;
|
|
821
|
-
cursor = lastEvent.seq;
|
|
822
|
-
if (events.length < REPLAY_BATCH_SIZE) break;
|
|
1520
|
+
this.parts.push(encoded);
|
|
1521
|
+
this.bytes += encoded.length;
|
|
1522
|
+
if (this.bytes >= this.targetBytes) {
|
|
1523
|
+
outcome = worstOutcome(outcome, this.flush());
|
|
823
1524
|
}
|
|
1525
|
+
return outcome;
|
|
824
1526
|
}
|
|
825
|
-
|
|
826
|
-
if (this.
|
|
827
|
-
const
|
|
828
|
-
this.
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1527
|
+
flush() {
|
|
1528
|
+
if (this.parts.length === 0) return "queued";
|
|
1529
|
+
const frame = `{"type":"changes","id":${JSON.stringify(this.subscriptionId)},"events":[${this.parts.join(",")}]}`;
|
|
1530
|
+
this.parts = [];
|
|
1531
|
+
this.bytes = 0;
|
|
1532
|
+
return this.sendText(frame);
|
|
1533
|
+
}
|
|
1534
|
+
clear() {
|
|
1535
|
+
this.parts = [];
|
|
1536
|
+
this.bytes = 0;
|
|
1537
|
+
}
|
|
1538
|
+
get pendingEvents() {
|
|
1539
|
+
return this.parts.length;
|
|
1540
|
+
}
|
|
1541
|
+
};
|
|
1542
|
+
var OUTCOME_SEVERITY = {
|
|
1543
|
+
queued: 0,
|
|
1544
|
+
sent: 1,
|
|
1545
|
+
buffered: 2,
|
|
1546
|
+
dropped: 3
|
|
1547
|
+
};
|
|
1548
|
+
function worstOutcome(a, b) {
|
|
1549
|
+
return OUTCOME_SEVERITY[a] >= OUTCOME_SEVERITY[b] ? a : b;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// src/server/ws-device-stream.ts
|
|
1553
|
+
var DEFAULT_MAX_UNACKNOWLEDGED_CHANGES = 1e3;
|
|
1554
|
+
var CATCHUP_READ_BATCH = 1e3;
|
|
1555
|
+
var DeviceChangeStream = class {
|
|
1556
|
+
constructor(deps, baselineSeq, startMode) {
|
|
1557
|
+
this.deps = deps;
|
|
1558
|
+
this.mode = startMode;
|
|
1559
|
+
this.catchupFrom = baselineSeq;
|
|
1560
|
+
this.highestQueuedSeq = baselineSeq;
|
|
1561
|
+
this.processedSeq = baselineSeq;
|
|
1562
|
+
this.ackedSeq = baselineSeq;
|
|
1563
|
+
}
|
|
1564
|
+
mode;
|
|
1565
|
+
grouper = null;
|
|
1566
|
+
catchupFrom;
|
|
1567
|
+
highestQueuedSeq;
|
|
1568
|
+
processedSeq;
|
|
1569
|
+
heldSeq = null;
|
|
1570
|
+
ackedSeq;
|
|
1571
|
+
lastQueuedTxId;
|
|
1572
|
+
midTransaction = false;
|
|
1573
|
+
draining = false;
|
|
1574
|
+
socketWait = false;
|
|
1575
|
+
halted = false;
|
|
1576
|
+
get deviceId() {
|
|
1577
|
+
return this.deps.deviceId;
|
|
1578
|
+
}
|
|
1579
|
+
get stopped() {
|
|
1580
|
+
return this.halted;
|
|
1581
|
+
}
|
|
1582
|
+
get catchingUp() {
|
|
1583
|
+
return this.mode === "catchup";
|
|
1584
|
+
}
|
|
1585
|
+
start() {
|
|
1586
|
+
if (this.mode === "catchup") {
|
|
1587
|
+
void this.drain();
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
receiveLive(event) {
|
|
1591
|
+
if (this.halted || this.mode !== "live") return;
|
|
1592
|
+
this.ensureGrouper().receive(event);
|
|
1593
|
+
}
|
|
1594
|
+
onBatchEnd(atTxBoundary) {
|
|
1595
|
+
if (this.halted || this.mode !== "live") return;
|
|
1596
|
+
if (this.grouper !== null && !this.grouper.flush(atTxBoundary)) return;
|
|
1597
|
+
this.settleFrame(this.deps.packer?.flush() ?? "queued");
|
|
1598
|
+
}
|
|
1599
|
+
onAck(seq) {
|
|
1600
|
+
if (this.halted) return;
|
|
1601
|
+
if (seq > this.ackedSeq) {
|
|
1602
|
+
this.ackedSeq = seq;
|
|
1603
|
+
}
|
|
1604
|
+
if (this.mode === "catchup") {
|
|
1605
|
+
void this.drain();
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
onSocketDrain() {
|
|
1609
|
+
if (this.halted) return;
|
|
1610
|
+
this.socketWait = false;
|
|
1611
|
+
if (this.mode === "catchup") {
|
|
1612
|
+
void this.drain();
|
|
834
1613
|
}
|
|
835
1614
|
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1615
|
+
stop() {
|
|
1616
|
+
this.halted = true;
|
|
1617
|
+
this.grouper = null;
|
|
1618
|
+
this.deps.packer?.clear();
|
|
1619
|
+
}
|
|
1620
|
+
ensureGrouper() {
|
|
1621
|
+
this.grouper ??= new TransactionGrouper((event) => this.deliver(event));
|
|
1622
|
+
return this.grouper;
|
|
1623
|
+
}
|
|
1624
|
+
windowClosed() {
|
|
1625
|
+
return this.highestQueuedSeq - this.ackedSeq > BigInt(this.deps.maxUnacknowledgedChanges);
|
|
1626
|
+
}
|
|
1627
|
+
deliver(event) {
|
|
1628
|
+
if (this.halted) return false;
|
|
1629
|
+
const startsTransaction = event.txId === void 0 || event.txId !== this.lastQueuedTxId;
|
|
1630
|
+
if (this.windowClosed() && (this.deps.pacing === "perEvent" || startsTransaction)) {
|
|
1631
|
+
this.enterCatchup();
|
|
1632
|
+
return false;
|
|
1633
|
+
}
|
|
1634
|
+
const outcome = this.deps.packer === null ? this.deps.sendEvent(event) : this.deps.packer.append(event);
|
|
840
1635
|
if (outcome === "dropped") {
|
|
841
1636
|
this.stop();
|
|
1637
|
+
this.deps.onOverload();
|
|
1638
|
+
return false;
|
|
1639
|
+
}
|
|
1640
|
+
this.lastQueuedTxId = event.txId;
|
|
1641
|
+
if (event.seq > this.highestQueuedSeq) {
|
|
1642
|
+
this.highestQueuedSeq = event.seq;
|
|
1643
|
+
}
|
|
1644
|
+
if (event.seq > this.processedSeq) {
|
|
1645
|
+
this.processedSeq = event.seq;
|
|
1646
|
+
}
|
|
1647
|
+
this.midTransaction = event.txEnd !== true && event.txId !== void 0;
|
|
1648
|
+
if (outcome === "buffered") {
|
|
1649
|
+
this.socketWait = true;
|
|
1650
|
+
this.enterCatchup();
|
|
842
1651
|
return false;
|
|
843
1652
|
}
|
|
844
1653
|
return true;
|
|
845
1654
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1655
|
+
/**
|
|
1656
|
+
* Abandons the in-flight stream state and falls back to the change log.
|
|
1657
|
+
* The grouper's held event was never queued and its seq is above both
|
|
1658
|
+
* watermarks, so dropping it loses nothing. The catch-up read resumes
|
|
1659
|
+
* after the last position fully settled: `highestQueuedSeq` is the last
|
|
1660
|
+
* event that reached the socket, and `processedSeq` may run further ahead
|
|
1661
|
+
* of it over a span whose every event was suppressed, so a long run of
|
|
1662
|
+
* the device's own echoes is not re-read on every pause.
|
|
1663
|
+
*/
|
|
1664
|
+
enterCatchup() {
|
|
1665
|
+
const flushed = this.deps.packer?.flush() ?? "queued";
|
|
1666
|
+
if (flushed === "dropped") {
|
|
1667
|
+
this.stop();
|
|
1668
|
+
this.deps.onOverload();
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
if (flushed === "buffered") {
|
|
1672
|
+
this.socketWait = true;
|
|
1673
|
+
}
|
|
1674
|
+
this.mode = "catchup";
|
|
1675
|
+
this.grouper = null;
|
|
1676
|
+
this.heldSeq = null;
|
|
1677
|
+
this.catchupFrom = this.processedSeq > this.highestQueuedSeq ? this.processedSeq : this.highestQueuedSeq;
|
|
850
1678
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1679
|
+
/**
|
|
1680
|
+
* A closed window stops the catch-up read only where the window may pace
|
|
1681
|
+
* the stream: anywhere under `perEvent` pacing, and at a transaction
|
|
1682
|
+
* boundary under `perTransaction` pacing. Mid-transaction the read keeps
|
|
1683
|
+
* going, because a `perTransaction` device acknowledges only applied
|
|
1684
|
+
* whole transactions, and holding the rest of an open transaction back
|
|
1685
|
+
* would wait for an acknowledgement that can never arrive.
|
|
1686
|
+
*/
|
|
1687
|
+
readGateClosed() {
|
|
1688
|
+
if (!this.windowClosed()) return false;
|
|
1689
|
+
return this.deps.pacing === "perEvent" || !this.midTransaction;
|
|
1690
|
+
}
|
|
1691
|
+
async drain() {
|
|
1692
|
+
if (this.draining || this.halted) return;
|
|
1693
|
+
this.draining = true;
|
|
1694
|
+
try {
|
|
1695
|
+
while (!this.halted && this.mode === "catchup") {
|
|
1696
|
+
if (this.socketWait || this.readGateClosed()) return;
|
|
1697
|
+
const upTo = this.deps.logCursor();
|
|
1698
|
+
if (this.catchupFrom >= upTo) {
|
|
1699
|
+
this.goLive();
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
let events;
|
|
1703
|
+
try {
|
|
1704
|
+
events = await this.deps.readLog(this.catchupFrom, upTo, CATCHUP_READ_BATCH);
|
|
1705
|
+
} catch (err) {
|
|
1706
|
+
this.halted = true;
|
|
1707
|
+
this.deps.onFault(err);
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
if (this.halted || this.mode !== "catchup") return;
|
|
1711
|
+
if (events.length === 0) {
|
|
1712
|
+
this.catchupFrom = upTo;
|
|
1713
|
+
this.goLive();
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const grouper = this.ensureGrouper();
|
|
1717
|
+
for (const event of events) {
|
|
1718
|
+
this.catchupFrom = event.seq;
|
|
1719
|
+
const delivered = this.deps.transform(event);
|
|
1720
|
+
if (delivered === null) {
|
|
1721
|
+
if (this.heldSeq === null && event.seq > this.processedSeq) {
|
|
1722
|
+
this.processedSeq = event.seq;
|
|
1723
|
+
}
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
if (!grouper.receive(delivered)) return;
|
|
1727
|
+
const settledBefore = event.seq - 1n;
|
|
1728
|
+
if (settledBefore > this.processedSeq) {
|
|
1729
|
+
this.processedSeq = settledBefore;
|
|
1730
|
+
}
|
|
1731
|
+
this.heldSeq = delivered.txId === void 0 ? null : event.seq;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
} finally {
|
|
1735
|
+
this.draining = false;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Rejoins the live feed. Runs synchronously right after a log read, so no
|
|
1740
|
+
* poller tick can dispatch between the caught-up check and the mode flip.
|
|
1741
|
+
* The grouper survives the transition: the event it holds is released by
|
|
1742
|
+
* the boundary flush when the poller stopped at a transaction boundary,
|
|
1743
|
+
* exactly as a resuming ordinary subscription does.
|
|
1744
|
+
*/
|
|
1745
|
+
goLive() {
|
|
1746
|
+
this.mode = "live";
|
|
1747
|
+
const grouper = this.ensureGrouper();
|
|
1748
|
+
const atBoundary = this.deps.logCursorAtTxBoundary();
|
|
1749
|
+
if (!grouper.flush(atBoundary)) return;
|
|
1750
|
+
if (atBoundary) {
|
|
1751
|
+
this.heldSeq = null;
|
|
1752
|
+
}
|
|
1753
|
+
this.settleFrame(this.deps.packer?.flush() ?? "queued");
|
|
1754
|
+
}
|
|
1755
|
+
settleFrame(outcome) {
|
|
1756
|
+
if (this.halted) return;
|
|
1757
|
+
if (outcome === "dropped") {
|
|
1758
|
+
this.stop();
|
|
1759
|
+
this.deps.onOverload();
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
if (outcome === "buffered") {
|
|
1763
|
+
this.socketWait = true;
|
|
1764
|
+
if (this.mode === "live") {
|
|
1765
|
+
this.enterCatchup();
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
|
|
1771
|
+
// src/server/ws-operations.ts
|
|
1772
|
+
function isValidParams(params) {
|
|
1773
|
+
if (params === void 0 || params === null) return true;
|
|
1774
|
+
return typeof params === "object";
|
|
1775
|
+
}
|
|
1776
|
+
function decodeBatchParams2(ctx, id, raw) {
|
|
1777
|
+
const decoded = [];
|
|
1778
|
+
for (const entry of raw) {
|
|
1779
|
+
const result = decodeBoundParams(entry, "paramsBatch");
|
|
1780
|
+
if (!result.ok) {
|
|
1781
|
+
ctx.sendError(id, "INVALID_MESSAGE", result.message);
|
|
1782
|
+
return null;
|
|
1783
|
+
}
|
|
1784
|
+
decoded.push(result.value);
|
|
1785
|
+
}
|
|
1786
|
+
return decoded;
|
|
1787
|
+
}
|
|
1788
|
+
async function handleQueryMessage(ctx, msg, id) {
|
|
1789
|
+
if (typeof msg.sql !== "string") {
|
|
1790
|
+
ctx.sendError(id, "INVALID_MESSAGE", 'Query message requires a "sql" string field');
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
if (!isValidParams(msg.params)) {
|
|
1794
|
+
ctx.sendError(id, "INVALID_MESSAGE", '"params" must be an object or array');
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
const params = decodeBoundParams(msg.params, "params");
|
|
1798
|
+
if (!params.ok) {
|
|
1799
|
+
ctx.sendError(id, "INVALID_MESSAGE", params.message);
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
const readConcern = validateReadConcern(msg.readConcern);
|
|
1803
|
+
if (!readConcern.ok) {
|
|
1804
|
+
ctx.sendError(id, "INVALID_MESSAGE", readConcern.message);
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
try {
|
|
1808
|
+
const rows = await queryWireRows(ctx.target, msg.sql, params.value, toReadOptions(readConcern.value));
|
|
1809
|
+
ctx.sendResult(id, { rows });
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
ctx.sendCaughtError(id, err);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
function toReadOptions(readConcern) {
|
|
1815
|
+
return readConcern ? { readConcern } : void 0;
|
|
1816
|
+
}
|
|
1817
|
+
async function handleExecuteMessage(ctx, msg, id) {
|
|
1818
|
+
if (typeof msg.sql !== "string") {
|
|
1819
|
+
ctx.sendError(id, "INVALID_MESSAGE", 'Execute message requires a "sql" string field');
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
if (!isValidParams(msg.params)) {
|
|
1823
|
+
ctx.sendError(id, "INVALID_MESSAGE", '"params" must be an object or array');
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
const params = decodeBoundParams(msg.params, "params");
|
|
1827
|
+
if (!params.ok) {
|
|
1828
|
+
ctx.sendError(id, "INVALID_MESSAGE", params.message);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
try {
|
|
1832
|
+
const result = await ctx.target.execute(msg.sql, params.value);
|
|
1833
|
+
ctx.sendResult(id, toExecuteResponse(result));
|
|
1834
|
+
} catch (err) {
|
|
1835
|
+
ctx.sendCaughtError(id, err);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
async function handleTransactionMessage(ctx, msg, id) {
|
|
1839
|
+
const statementsError = transactionStatementsValidationError(msg.statements);
|
|
1840
|
+
if (statementsError !== null) {
|
|
1841
|
+
ctx.sendError(id, "INVALID_MESSAGE", statementsError);
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
const writeConcern = validateWriteConcern(msg.writeConcern);
|
|
1845
|
+
if (!writeConcern.ok) {
|
|
1846
|
+
ctx.sendError(id, "INVALID_MESSAGE", writeConcern.message);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
const validStatements = msg.statements;
|
|
1850
|
+
const statements = [];
|
|
1851
|
+
for (const stmt of validStatements) {
|
|
1852
|
+
const params = decodeBoundParams(stmt.params, "params");
|
|
1853
|
+
if (!params.ok) {
|
|
1854
|
+
ctx.sendError(id, "INVALID_MESSAGE", params.message);
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
statements.push({ sql: stmt.sql, params: params.value });
|
|
1858
|
+
}
|
|
1859
|
+
const txOptions = writeConcern.value ? { writeConcern: writeConcern.value } : void 0;
|
|
1860
|
+
try {
|
|
1861
|
+
const results = ctx.target.executeTransaction ? await ctx.target.executeTransaction(statements, txOptions) : await ctx.target.transaction(async (tx) => {
|
|
1862
|
+
const txResults = [];
|
|
1863
|
+
for (const stmt of statements) {
|
|
1864
|
+
txResults.push(await tx.execute(stmt.sql, stmt.params));
|
|
1865
|
+
}
|
|
1866
|
+
return txResults;
|
|
1867
|
+
}, txOptions);
|
|
1868
|
+
ctx.sendResult(id, { results: results.map(toExecuteResponse) });
|
|
1869
|
+
} catch (err) {
|
|
1870
|
+
ctx.sendCaughtError(id, err);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
async function handleBatchMessage(ctx, msg, id) {
|
|
1874
|
+
if (typeof msg.sql !== "string") {
|
|
1875
|
+
ctx.sendError(id, "INVALID_MESSAGE", 'Batch message requires a "sql" string field');
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
const paramsBatchError = paramsBatchValidationError(msg.paramsBatch);
|
|
1879
|
+
if (paramsBatchError !== null) {
|
|
1880
|
+
ctx.sendError(id, "INVALID_MESSAGE", paramsBatchError);
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
const writeConcern = validateWriteConcern(msg.writeConcern);
|
|
1884
|
+
if (!writeConcern.ok) {
|
|
1885
|
+
ctx.sendError(id, "INVALID_MESSAGE", writeConcern.message);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
const sql = msg.sql;
|
|
1889
|
+
const paramsBatch = decodeBatchParams2(ctx, id, msg.paramsBatch);
|
|
1890
|
+
if (paramsBatch === null) return;
|
|
1891
|
+
try {
|
|
1892
|
+
const results = await ctx.target.transaction(
|
|
1893
|
+
async (tx) => tx.executeBatch(sql, paramsBatch),
|
|
1894
|
+
writeConcern.value ? { writeConcern: writeConcern.value } : void 0
|
|
1895
|
+
);
|
|
1896
|
+
ctx.sendResult(id, { results: results.map(toExecuteResponse) });
|
|
1897
|
+
} catch (err) {
|
|
1898
|
+
ctx.sendCaughtError(id, err);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
async function handleLoadMessage(ctx, msg, id) {
|
|
1902
|
+
if (typeof msg.sql !== "string") {
|
|
1903
|
+
ctx.sendError(id, "INVALID_MESSAGE", 'Load message requires a "sql" string field');
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
const paramsBatchError = paramsBatchValidationError(msg.paramsBatch);
|
|
1907
|
+
if (paramsBatchError !== null) {
|
|
1908
|
+
ctx.sendError(id, "INVALID_MESSAGE", paramsBatchError);
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
const durabilityError = loadDurabilityValidationError(msg.durability);
|
|
1912
|
+
if (durabilityError !== null) {
|
|
1913
|
+
ctx.sendError(id, "INVALID_MESSAGE", durabilityError);
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
const checkpointError = loadCheckpointValidationError(msg.checkpoint);
|
|
1917
|
+
if (checkpointError !== null) {
|
|
1918
|
+
ctx.sendError(id, "INVALID_MESSAGE", checkpointError);
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
const bulkLoad = ctx.target.bulkLoad;
|
|
1922
|
+
if (typeof bulkLoad !== "function") {
|
|
1923
|
+
ctx.sendError(id, "BULK_LOAD_UNSUPPORTED", "The execution target for this database does not support bulk load");
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
const sql = msg.sql;
|
|
1927
|
+
const paramsBatch = decodeBatchParams2(ctx, id, msg.paramsBatch);
|
|
1928
|
+
if (paramsBatch === null) return;
|
|
1929
|
+
const options = toBulkLoadOptions({
|
|
1930
|
+
durability: msg.durability,
|
|
1931
|
+
checkpoint: msg.checkpoint
|
|
1932
|
+
});
|
|
1933
|
+
try {
|
|
1934
|
+
const summary = await bulkLoad.call(ctx.target, sql, paramsBatch, options);
|
|
1935
|
+
ctx.sendResult(id, summary);
|
|
1936
|
+
} catch (err) {
|
|
1937
|
+
ctx.sendCaughtError(id, err);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// src/server/ws-named.ts
|
|
1942
|
+
function readArguments(raw) {
|
|
1943
|
+
if (raw === void 0) return { ok: true, value: {} };
|
|
1944
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
1945
|
+
return { ok: false, message: '"args" must be an object' };
|
|
1946
|
+
}
|
|
1947
|
+
const decoded = decodeBoundParams(raw, "args");
|
|
1948
|
+
if (!decoded.ok) return { ok: false, message: decoded.message };
|
|
1949
|
+
return { ok: true, value: decoded.value ?? {} };
|
|
1950
|
+
}
|
|
1951
|
+
async function handleNamedQueryMessage(ctx, msg, id, name) {
|
|
1952
|
+
const args = readArguments(msg.args);
|
|
1953
|
+
if (!args.ok) {
|
|
1954
|
+
ctx.sendError(id, "INVALID_MESSAGE", args.message);
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
const readConcern = validateReadConcern(msg.readConcern);
|
|
1958
|
+
if (!readConcern.ok) {
|
|
1959
|
+
ctx.sendError(id, "INVALID_MESSAGE", readConcern.message);
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
try {
|
|
1963
|
+
const resolved = ctx.operations.resolve("read", ctx.databaseId, name, args.value, ctx.identity);
|
|
1964
|
+
if (!resolved.ok) {
|
|
1965
|
+
ctx.sendError(id, resolved.refusal.code, resolved.refusal.message);
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
const statement = resolved.statements[0];
|
|
1969
|
+
const rows = await queryWireRows(ctx.target, statement.sql, statement.params, toReadOptions(readConcern.value));
|
|
1970
|
+
ctx.sendResult(id, { rows });
|
|
1971
|
+
} catch (err) {
|
|
1972
|
+
ctx.sendCaughtError(id, err);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
async function handleNamedExecuteMessage(ctx, msg, id, name) {
|
|
1976
|
+
const args = readArguments(msg.args);
|
|
1977
|
+
if (!args.ok) {
|
|
1978
|
+
ctx.sendError(id, "INVALID_MESSAGE", args.message);
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
const writeConcern = validateWriteConcern(msg.writeConcern);
|
|
1982
|
+
if (!writeConcern.ok) {
|
|
1983
|
+
ctx.sendError(id, "INVALID_MESSAGE", writeConcern.message);
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
try {
|
|
1987
|
+
const resolved = ctx.operations.resolve("write", ctx.databaseId, name, args.value, ctx.identity);
|
|
1988
|
+
if (!resolved.ok) {
|
|
1989
|
+
ctx.sendError(id, resolved.refusal.code, resolved.refusal.message);
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
const statements = resolved.statements;
|
|
1993
|
+
const options = writeConcern.value ? { writeConcern: writeConcern.value } : void 0;
|
|
1994
|
+
const results = ctx.target.executeTransaction ? await ctx.target.executeTransaction(statements, options) : await ctx.target.transaction(async (tx) => {
|
|
1995
|
+
const collected = [];
|
|
1996
|
+
for (const statement of statements) {
|
|
1997
|
+
collected.push(await tx.execute(statement.sql, statement.params));
|
|
1998
|
+
}
|
|
1999
|
+
return collected;
|
|
2000
|
+
}, options);
|
|
2001
|
+
ctx.sendResult(id, { results: results.map(toExecuteResponse) });
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
ctx.sendCaughtError(id, err);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// src/server/ws-live.ts
|
|
2008
|
+
var LIVE_ONLY_FIELDS = ["table", "tables", "filter", "sinceSeq", "epoch", "deviceId", "schemaVersion"];
|
|
2009
|
+
async function handleLiveSubscribeMessage(deps, conn, state, msg, id, name) {
|
|
2010
|
+
const rejected = LIVE_ONLY_FIELDS.find((field) => msg[field] !== void 0);
|
|
2011
|
+
if (rejected !== void 0) {
|
|
2012
|
+
deps.sendError(
|
|
2013
|
+
conn,
|
|
2014
|
+
id,
|
|
2015
|
+
"INVALID_MESSAGE",
|
|
2016
|
+
`A subscription naming a registered read carries no "${rejected}"; the server holds the result`
|
|
2017
|
+
);
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
if (state.subscriptions.has(id)) {
|
|
2021
|
+
deps.sendError(conn, id, "DUPLICATE_SUBSCRIPTION", `Subscription '${id}' already exists on this connection`);
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
if (state.database.readOnly) {
|
|
2025
|
+
deps.sendError(conn, id, "READ_ONLY", "Live queries are not available on read-only databases");
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
if (state.database.path === ":memory:") {
|
|
2029
|
+
deps.sendError(conn, id, "CDC_UNSUPPORTED", "Live queries require file-based databases");
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
if (msg.registryDigest !== void 0) {
|
|
2033
|
+
if (typeof msg.registryDigest !== "string") {
|
|
2034
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", '"registryDigest" must be a string');
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
if (msg.registryDigest !== deps.operations.digest) {
|
|
2038
|
+
deps.sendError(
|
|
2039
|
+
conn,
|
|
2040
|
+
id,
|
|
2041
|
+
"REGISTRY_MISMATCH",
|
|
2042
|
+
"This server runs a different operation registry than the one the client generated against; re-read GET /capabilities"
|
|
2043
|
+
);
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
const args = readArguments(msg.args);
|
|
2048
|
+
if (!args.ok) {
|
|
2049
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", args.message);
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
let sql;
|
|
2053
|
+
let params;
|
|
2054
|
+
try {
|
|
2055
|
+
const resolved = deps.operations.resolve("read", state.databaseId, name, args.value, state.identity);
|
|
2056
|
+
if (!resolved.ok) {
|
|
2057
|
+
deps.sendError(conn, id, resolved.refusal.code, resolved.refusal.message);
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
sql = resolved.statements[0].sql;
|
|
2061
|
+
params = resolved.statements[0].params;
|
|
2062
|
+
} catch (err) {
|
|
2063
|
+
deps.sendSirannonError(conn, id, err);
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
let cancelled = false;
|
|
2067
|
+
state.subscriptions.set(id, {
|
|
2068
|
+
unsubscribe: () => {
|
|
2069
|
+
cancelled = true;
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
let query;
|
|
2073
|
+
try {
|
|
2074
|
+
query = await state.database.live(sql, params);
|
|
2075
|
+
} catch (err) {
|
|
2076
|
+
state.subscriptions.delete(id);
|
|
2077
|
+
if (!cancelled) deps.sendSirannonError(conn, id, err);
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
if (cancelled) {
|
|
2081
|
+
await query.close().catch(() => {
|
|
2082
|
+
});
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
let stop = null;
|
|
2086
|
+
const teardown = () => {
|
|
2087
|
+
state.subscriptions.delete(id);
|
|
2088
|
+
stop?.();
|
|
2089
|
+
stop = null;
|
|
2090
|
+
query.close().catch(() => {
|
|
2091
|
+
});
|
|
2092
|
+
};
|
|
2093
|
+
stop = query.subscribe((update) => {
|
|
2094
|
+
deliver(deps, conn, id, query, update, teardown);
|
|
2095
|
+
});
|
|
2096
|
+
state.subscriptions.set(id, { unsubscribe: teardown });
|
|
2097
|
+
const opened = query.getState();
|
|
2098
|
+
if (opened.status !== "ready") {
|
|
2099
|
+
teardown();
|
|
2100
|
+
deps.sendError(conn, id, "INTERNAL_ERROR", "The live query produced no first result");
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
deps.sendSubscribedRows(conn, id, encodeRows(opened.rows));
|
|
2104
|
+
}
|
|
2105
|
+
function deliver(deps, conn, id, query, update, teardown) {
|
|
2106
|
+
if (update.kind === "ops") {
|
|
2107
|
+
deps.sendLive(conn, { type: "live", id, ops: update.ops.map(encodeOp) });
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
if (update.kind === "revalidating") {
|
|
2111
|
+
deps.sendLive(conn, { type: "live", id, revalidating: true });
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
const state = query.getState();
|
|
2115
|
+
if (state.status === "ready") {
|
|
2116
|
+
deps.sendLive(conn, { type: "live", id, rows: encodeRows(state.rows) });
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
if (state.status === "error") {
|
|
2120
|
+
teardown();
|
|
2121
|
+
deps.sendError(conn, id, "CDC_ERROR", state.error.message);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
function encodeRows(rows) {
|
|
2125
|
+
return rows.map((row) => encodeTaggedValues(row));
|
|
2126
|
+
}
|
|
2127
|
+
function encodeOp(op) {
|
|
2128
|
+
if (op.op === "delete") return { op: "delete", index: op.index };
|
|
2129
|
+
return {
|
|
2130
|
+
op: op.op === "insert" ? "insert" : "update",
|
|
2131
|
+
index: op.index,
|
|
2132
|
+
row: encodeTaggedValues(op.row)
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// src/server/ws-cdc-resume.ts
|
|
2137
|
+
var PrimedSubscription2 = class extends PrimedSubscription {
|
|
2138
|
+
constructor(sinceSeq, deliver2, onOverload, bufferLimit, bufferByteLimit) {
|
|
2139
|
+
super(sinceSeq, (event) => deliver2(event) !== "dropped", onOverload, bufferLimit, bufferByteLimit);
|
|
2140
|
+
}
|
|
2141
|
+
};
|
|
2142
|
+
|
|
2143
|
+
// src/server/ws-device-subscribe.ts
|
|
2144
|
+
async function subscribeDevice(deps, conn, state, request) {
|
|
2145
|
+
const { id, tables, filter, sinceSeq, clientEpoch, deviceId, stagedStream } = request;
|
|
2146
|
+
let stream;
|
|
2147
|
+
let boundary;
|
|
2148
|
+
let resync;
|
|
2149
|
+
let epoch;
|
|
2150
|
+
try {
|
|
2151
|
+
const ctx = await deps.cdc.ensure(state.databaseId, state.database);
|
|
2152
|
+
for (const table of tables) {
|
|
2153
|
+
await ctx.tracker.watch(ctx.cdcConn, table);
|
|
2154
|
+
}
|
|
2155
|
+
await state.database.ensureChangeStamping();
|
|
2156
|
+
epoch = ctx.epoch;
|
|
2157
|
+
boundary = ctx.tracker.cursor;
|
|
2158
|
+
if (sinceSeq === void 0) {
|
|
2159
|
+
resync = false;
|
|
2160
|
+
} else {
|
|
2161
|
+
const minSeq = await ctx.tracker.getMinSeq(ctx.cdcConn);
|
|
2162
|
+
const foreignEpoch = clientEpoch !== void 0 && clientEpoch !== ctx.epoch;
|
|
2163
|
+
resync = foreignEpoch || needsResync(sinceSeq, minSeq, boundary);
|
|
868
2164
|
}
|
|
869
|
-
|
|
2165
|
+
const suppress = (event) => event.origin === deviceId || event.origin === void 0;
|
|
2166
|
+
const resumeFrom = resync || sinceSeq === void 0 ? boundary : sinceSeq;
|
|
2167
|
+
stream = new DeviceChangeStream(
|
|
2168
|
+
{
|
|
2169
|
+
deviceId,
|
|
2170
|
+
maxUnacknowledgedChanges: deps.maxUnacknowledgedChanges,
|
|
2171
|
+
pacing: stagedStream ? "perEvent" : "perTransaction",
|
|
2172
|
+
packer: stagedStream ? new DeviceFramePacker(id, (data) => deps.sendText(conn, data)) : null,
|
|
2173
|
+
sendEvent: (event) => deps.sendChange(conn, id, event),
|
|
2174
|
+
onOverload: () => deps.handleOverload(conn),
|
|
2175
|
+
onFault: () => deps.closeFaulted(conn),
|
|
2176
|
+
readLog: (afterSeq, upToSeq, limit) => ctx.tracker.readSinceTables(ctx.cdcConn, tables, afterSeq, upToSeq, limit),
|
|
2177
|
+
logCursor: () => ctx.tracker.cursor,
|
|
2178
|
+
logCursorAtTxBoundary: () => ctx.tracker.pollEndedAtTxBoundary,
|
|
2179
|
+
transform: (event) => {
|
|
2180
|
+
if (suppress(event)) return null;
|
|
2181
|
+
return filter === void 0 ? event : filteredChange(event, filter);
|
|
2182
|
+
}
|
|
2183
|
+
},
|
|
2184
|
+
resumeFrom,
|
|
2185
|
+
resumeFrom < boundary ? "catchup" : "live"
|
|
2186
|
+
);
|
|
2187
|
+
const subscriptions = tables.map(
|
|
2188
|
+
(table) => ctx.manager.subscribe(table, filter, (event) => {
|
|
2189
|
+
if (suppress(event)) return;
|
|
2190
|
+
stream.receiveLive(event);
|
|
2191
|
+
})
|
|
2192
|
+
);
|
|
2193
|
+
const removeBatchEnd = ctx.manager.addBatchEndListener((atTxBoundary) => stream.onBatchEnd(atTxBoundary));
|
|
2194
|
+
state.subscriptions.set(id, {
|
|
2195
|
+
unsubscribe: () => {
|
|
2196
|
+
removeBatchEnd();
|
|
2197
|
+
stream.stop();
|
|
2198
|
+
state.deviceStreams.delete(id);
|
|
2199
|
+
for (const subscription of subscriptions) {
|
|
2200
|
+
subscription.unsubscribe();
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
});
|
|
2204
|
+
state.deviceStreams.set(id, stream);
|
|
2205
|
+
} catch (err) {
|
|
2206
|
+
deps.cdc.maybeCleanup(state.databaseId);
|
|
2207
|
+
deps.sendSirannonError(conn, id, err);
|
|
2208
|
+
return;
|
|
870
2209
|
}
|
|
871
|
-
|
|
2210
|
+
deps.sendSubscribed(conn, id, boundary.toString(), epoch, resync, deps.maxUnacknowledgedChanges);
|
|
2211
|
+
stream.start();
|
|
872
2212
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
2213
|
+
|
|
2214
|
+
// src/server/ws-subscribe.ts
|
|
2215
|
+
var MAX_SUBSCRIBED_TABLES = 500;
|
|
2216
|
+
async function handleSubscribeMessage(deps, conn, state, msg, id) {
|
|
2217
|
+
const tables = readTableSet(msg);
|
|
2218
|
+
if (typeof tables === "string") {
|
|
2219
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", tables);
|
|
876
2220
|
return;
|
|
877
2221
|
}
|
|
878
|
-
if (
|
|
879
|
-
|
|
2222
|
+
if (state.subscriptions.has(id)) {
|
|
2223
|
+
deps.sendError(conn, id, "DUPLICATE_SUBSCRIPTION", `Subscription '${id}' already exists on this connection`);
|
|
880
2224
|
return;
|
|
881
2225
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
ctx.sendError(id, "INVALID_MESSAGE", params.message);
|
|
2226
|
+
if (state.database.readOnly) {
|
|
2227
|
+
deps.sendError(conn, id, "READ_ONLY", "Subscriptions are not available on read-only databases");
|
|
885
2228
|
return;
|
|
886
2229
|
}
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
ctx.sendResult(id, { rows });
|
|
890
|
-
} catch (err) {
|
|
891
|
-
ctx.sendCaughtError(id, err);
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
async function handleExecuteMessage(ctx, msg, id) {
|
|
895
|
-
if (typeof msg.sql !== "string") {
|
|
896
|
-
ctx.sendError(id, "INVALID_MESSAGE", 'Execute message requires a "sql" string field');
|
|
2230
|
+
if (state.database.path === ":memory:") {
|
|
2231
|
+
deps.sendError(conn, id, "CDC_UNSUPPORTED", "CDC subscriptions require file-based databases");
|
|
897
2232
|
return;
|
|
898
2233
|
}
|
|
899
|
-
if (
|
|
900
|
-
|
|
2234
|
+
if (msg.filter !== void 0 && msg.filter !== null && (typeof msg.filter !== "object" || Array.isArray(msg.filter))) {
|
|
2235
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", '"filter" must be a plain object');
|
|
901
2236
|
return;
|
|
902
2237
|
}
|
|
903
|
-
const
|
|
904
|
-
if (!
|
|
905
|
-
|
|
2238
|
+
const decodedFilter = decodeBoundParams(msg.filter, "filter");
|
|
2239
|
+
if (!decodedFilter.ok) {
|
|
2240
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", decodedFilter.message);
|
|
906
2241
|
return;
|
|
907
2242
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
const statementsError = transactionStatementsValidationError(msg.statements);
|
|
917
|
-
if (statementsError !== null) {
|
|
918
|
-
ctx.sendError(id, "INVALID_MESSAGE", statementsError);
|
|
919
|
-
return;
|
|
2243
|
+
const filter = decodedFilter.value;
|
|
2244
|
+
let sinceSeq;
|
|
2245
|
+
if (msg.sinceSeq !== void 0) {
|
|
2246
|
+
if (typeof msg.sinceSeq !== "string" || !/^\d+$/.test(msg.sinceSeq)) {
|
|
2247
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", '"sinceSeq" must be a non-negative integer string');
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
sinceSeq = BigInt(msg.sinceSeq);
|
|
920
2251
|
}
|
|
921
|
-
|
|
922
|
-
if (
|
|
923
|
-
|
|
924
|
-
|
|
2252
|
+
let clientEpoch;
|
|
2253
|
+
if (msg.epoch !== void 0) {
|
|
2254
|
+
if (typeof msg.epoch !== "string") {
|
|
2255
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", '"epoch" must be a string');
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
clientEpoch = msg.epoch;
|
|
925
2259
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
if (!params.ok) {
|
|
931
|
-
ctx.sendError(id, "INVALID_MESSAGE", params.message);
|
|
2260
|
+
let deviceId;
|
|
2261
|
+
if (msg.deviceId !== void 0) {
|
|
2262
|
+
if (!isValidDeviceId(msg.deviceId)) {
|
|
2263
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", '"deviceId" must be a 32-hex device id');
|
|
932
2264
|
return;
|
|
933
2265
|
}
|
|
934
|
-
|
|
2266
|
+
deviceId = msg.deviceId;
|
|
935
2267
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
2268
|
+
if (deviceId !== void 0) {
|
|
2269
|
+
if (msg.schemaVersion !== void 0 && !isValidSchemaVersion(msg.schemaVersion)) {
|
|
2270
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", '"schemaVersion" must be a non-negative integer');
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
let refusal;
|
|
2274
|
+
try {
|
|
2275
|
+
const serverVersion = highestMigrationVersion(await state.database.appliedMigrations());
|
|
2276
|
+
refusal = schemaVersionGateRefusal(msg.schemaVersion ?? 0, serverVersion);
|
|
2277
|
+
} catch (err) {
|
|
2278
|
+
deps.sendSirannonError(conn, id, err);
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
if (refusal !== null) {
|
|
2282
|
+
deps.sendError(conn, id, refusal.code, refusal.message);
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
948
2285
|
}
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
if (typeof msg.sql !== "string") {
|
|
952
|
-
ctx.sendError(id, "INVALID_MESSAGE", 'Batch message requires a "sql" string field');
|
|
2286
|
+
if (msg.stagedStream !== void 0 && typeof msg.stagedStream !== "boolean") {
|
|
2287
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", '"stagedStream" must be a boolean');
|
|
953
2288
|
return;
|
|
954
2289
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
2290
|
+
if (deviceId !== void 0) {
|
|
2291
|
+
await subscribeDevice(deps, conn, state, {
|
|
2292
|
+
id,
|
|
2293
|
+
tables,
|
|
2294
|
+
filter,
|
|
2295
|
+
sinceSeq,
|
|
2296
|
+
clientEpoch,
|
|
2297
|
+
deviceId,
|
|
2298
|
+
stagedStream: msg.stagedStream === true
|
|
2299
|
+
});
|
|
958
2300
|
return;
|
|
959
2301
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
ctx.sendError(id, "INVALID_MESSAGE", writeConcern.message);
|
|
2302
|
+
if (msg.stagedStream !== void 0) {
|
|
2303
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", '"stagedStream" requires a "deviceId"');
|
|
963
2304
|
return;
|
|
964
2305
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
if (paramsBatch === null) return;
|
|
968
|
-
try {
|
|
969
|
-
const results = await ctx.target.transaction(
|
|
970
|
-
async (tx) => tx.executeBatch(sql, paramsBatch),
|
|
971
|
-
writeConcern.value ? { writeConcern: writeConcern.value } : void 0
|
|
972
|
-
);
|
|
973
|
-
ctx.sendResult(id, { results: results.map(toExecuteResponse) });
|
|
974
|
-
} catch (err) {
|
|
975
|
-
ctx.sendCaughtError(id, err);
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
async function handleLoadMessage(ctx, msg, id) {
|
|
979
|
-
if (typeof msg.sql !== "string") {
|
|
980
|
-
ctx.sendError(id, "INVALID_MESSAGE", 'Load message requires a "sql" string field');
|
|
2306
|
+
if (tables.length > 1) {
|
|
2307
|
+
deps.sendError(conn, id, "INVALID_MESSAGE", '"tables" requires a "deviceId"');
|
|
981
2308
|
return;
|
|
982
2309
|
}
|
|
983
|
-
const
|
|
984
|
-
if (
|
|
985
|
-
|
|
2310
|
+
const table = tables[0];
|
|
2311
|
+
if (sinceSeq === void 0) {
|
|
2312
|
+
await subscribeLive(deps, conn, state, id, table, filter);
|
|
986
2313
|
return;
|
|
987
2314
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
2315
|
+
await subscribeResuming(deps, conn, state, id, table, filter, sinceSeq, clientEpoch);
|
|
2316
|
+
}
|
|
2317
|
+
function readTableSet(msg) {
|
|
2318
|
+
if (msg.tables !== void 0) {
|
|
2319
|
+
if (!Array.isArray(msg.tables) || msg.tables.length === 0) {
|
|
2320
|
+
return '"tables" must be a non-empty array of table names';
|
|
2321
|
+
}
|
|
2322
|
+
if (msg.tables.length > MAX_SUBSCRIBED_TABLES) {
|
|
2323
|
+
return `"tables" must hold at most ${MAX_SUBSCRIBED_TABLES} table names`;
|
|
2324
|
+
}
|
|
2325
|
+
for (const table of msg.tables) {
|
|
2326
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
2327
|
+
return '"tables" must hold non-empty table names';
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
return msg.tables;
|
|
992
2331
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
ctx.sendError(id, "INVALID_MESSAGE", checkpointError);
|
|
996
|
-
return;
|
|
2332
|
+
if (typeof msg.table !== "string") {
|
|
2333
|
+
return 'Subscribe message requires a "table" string field';
|
|
997
2334
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
2335
|
+
return [msg.table];
|
|
2336
|
+
}
|
|
2337
|
+
async function subscribeLive(deps, conn, state, id, table, filter) {
|
|
2338
|
+
try {
|
|
2339
|
+
const ctx = await deps.cdc.ensure(state.databaseId, state.database);
|
|
2340
|
+
await ctx.tracker.watch(ctx.cdcConn, table);
|
|
2341
|
+
await state.database.ensureChangeStamping();
|
|
2342
|
+
const grouper = new TransactionGrouper((event) => deps.sendChange(conn, id, event) !== "dropped");
|
|
2343
|
+
const boundary = ctx.tracker.cursor;
|
|
2344
|
+
const sub = ctx.manager.subscribe(table, filter, (event) => {
|
|
2345
|
+
grouper.receive(event);
|
|
2346
|
+
});
|
|
2347
|
+
const removeBatchEnd = ctx.manager.addBatchEndListener((atTxBoundary) => {
|
|
2348
|
+
grouper.flush(atTxBoundary);
|
|
2349
|
+
});
|
|
2350
|
+
state.subscriptions.set(id, {
|
|
2351
|
+
unsubscribe: () => {
|
|
2352
|
+
removeBatchEnd();
|
|
2353
|
+
sub.unsubscribe();
|
|
2354
|
+
}
|
|
2355
|
+
});
|
|
2356
|
+
deps.sendSubscribed(conn, id, boundary.toString(), ctx.epoch, false);
|
|
2357
|
+
} catch (err) {
|
|
2358
|
+
deps.cdc.maybeCleanup(state.databaseId);
|
|
2359
|
+
deps.sendSirannonError(conn, id, err);
|
|
1002
2360
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
2361
|
+
}
|
|
2362
|
+
async function subscribeResuming(deps, conn, state, id, table, filter, sinceSeq, clientEpoch) {
|
|
2363
|
+
let ctx;
|
|
2364
|
+
let primed;
|
|
2365
|
+
let boundary;
|
|
2366
|
+
let resync;
|
|
2367
|
+
let goLive;
|
|
1010
2368
|
try {
|
|
1011
|
-
|
|
1012
|
-
ctx.
|
|
2369
|
+
ctx = await deps.cdc.ensure(state.databaseId, state.database);
|
|
2370
|
+
await ctx.tracker.watch(ctx.cdcConn, table);
|
|
2371
|
+
await state.database.ensureChangeStamping();
|
|
2372
|
+
const grouper = new TransactionGrouper((event) => deps.sendChange(conn, id, event) !== "dropped");
|
|
2373
|
+
const deliver2 = (event) => grouper.receive(event) ? "sent" : "dropped";
|
|
2374
|
+
boundary = ctx.tracker.cursor;
|
|
2375
|
+
const boundaryEndsTransaction = ctx.tracker.pollEndedAtTxBoundary;
|
|
2376
|
+
primed = new PrimedSubscription2(sinceSeq, deliver2, () => deps.handleOverload(conn));
|
|
2377
|
+
const sub = ctx.manager.subscribe(table, filter, (event) => primed.onLiveEvent(event));
|
|
2378
|
+
let removeBatchEnd = () => {
|
|
2379
|
+
};
|
|
2380
|
+
let cancelled = false;
|
|
2381
|
+
goLive = () => {
|
|
2382
|
+
if (cancelled) return;
|
|
2383
|
+
grouper.flush(boundaryEndsTransaction);
|
|
2384
|
+
primed.goLive();
|
|
2385
|
+
removeBatchEnd = ctx.manager.addBatchEndListener((atTxBoundary) => {
|
|
2386
|
+
grouper.flush(atTxBoundary);
|
|
2387
|
+
});
|
|
2388
|
+
};
|
|
2389
|
+
state.subscriptions.set(id, {
|
|
2390
|
+
unsubscribe: () => {
|
|
2391
|
+
cancelled = true;
|
|
2392
|
+
removeBatchEnd();
|
|
2393
|
+
sub.unsubscribe();
|
|
2394
|
+
}
|
|
2395
|
+
});
|
|
2396
|
+
const minSeq = await ctx.tracker.getMinSeq(ctx.cdcConn);
|
|
2397
|
+
const foreignEpoch = clientEpoch !== void 0 && clientEpoch !== ctx.epoch;
|
|
2398
|
+
resync = foreignEpoch || needsResync(sinceSeq, minSeq, boundary);
|
|
2399
|
+
deps.sendSubscribed(conn, id, boundary.toString(), ctx.epoch, resync);
|
|
1013
2400
|
} catch (err) {
|
|
1014
|
-
|
|
2401
|
+
deps.cdc.maybeCleanup(state.databaseId);
|
|
2402
|
+
deps.sendSirannonError(conn, id, err);
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
if (!resync) {
|
|
2406
|
+
try {
|
|
2407
|
+
await primed.replay(ctx.tracker, ctx.cdcConn, table, filter, boundary);
|
|
2408
|
+
} catch {
|
|
2409
|
+
deps.sendSubscribed(conn, id, boundary.toString(), ctx.epoch, true);
|
|
2410
|
+
}
|
|
1015
2411
|
}
|
|
2412
|
+
goLive();
|
|
1016
2413
|
}
|
|
1017
2414
|
|
|
1018
2415
|
// src/server/ws-handler.ts
|
|
1019
2416
|
var DEFAULT_MAX_PAYLOAD_LENGTH = 1048576;
|
|
2417
|
+
var SQL_MESSAGE_TYPES = /* @__PURE__ */ new Set(["query", "execute", "transaction", "batch", "load"]);
|
|
2418
|
+
var NAMED_MESSAGE_TYPES = /* @__PURE__ */ new Set(["query", "execute", "subscribe"]);
|
|
1020
2419
|
var WSHandler = class {
|
|
1021
2420
|
sirannon;
|
|
1022
2421
|
maxPayloadLength;
|
|
1023
2422
|
resolveExecutionTarget;
|
|
1024
2423
|
connections = /* @__PURE__ */ new Map();
|
|
1025
2424
|
cdc;
|
|
2425
|
+
maxUnacknowledgedChanges;
|
|
2426
|
+
acceptSql;
|
|
2427
|
+
operations;
|
|
1026
2428
|
closed = false;
|
|
1027
2429
|
constructor(sirannon, options) {
|
|
1028
2430
|
this.sirannon = sirannon;
|
|
2431
|
+
this.acceptSql = options?.acceptSql === true;
|
|
2432
|
+
this.operations = createOperationSource(options?.operations);
|
|
1029
2433
|
this.maxPayloadLength = options?.maxPayloadLength ?? DEFAULT_MAX_PAYLOAD_LENGTH;
|
|
2434
|
+
this.maxUnacknowledgedChanges = options?.maxUnacknowledgedChanges ?? DEFAULT_MAX_UNACKNOWLEDGED_CHANGES;
|
|
1030
2435
|
this.resolveExecutionTarget = options?.resolveExecutionTarget;
|
|
1031
|
-
this.cdc = new CdcContextRegistry(sirannon, options?.cdcRetentionMs);
|
|
2436
|
+
this.cdc = new CdcContextRegistry(sirannon, options?.cdcRetentionMs, options?.deviceCursorRetentionMs);
|
|
1032
2437
|
}
|
|
1033
|
-
async handleOpen(conn, databaseId) {
|
|
2438
|
+
async handleOpen(conn, databaseId, identity) {
|
|
1034
2439
|
if (this.closed) {
|
|
1035
2440
|
this.sendError(conn, "", "HANDLER_CLOSED", "WebSocket handler is shut down");
|
|
1036
2441
|
conn.close(1013, "Handler shutting down");
|
|
@@ -1064,7 +2469,9 @@ var WSHandler = class {
|
|
|
1064
2469
|
databaseId,
|
|
1065
2470
|
database,
|
|
1066
2471
|
executionTarget,
|
|
2472
|
+
identity,
|
|
1067
2473
|
subscriptions: /* @__PURE__ */ new Map(),
|
|
2474
|
+
deviceStreams: /* @__PURE__ */ new Map(),
|
|
1068
2475
|
overloaded: false
|
|
1069
2476
|
});
|
|
1070
2477
|
}
|
|
@@ -1107,12 +2514,23 @@ var WSHandler = class {
|
|
|
1107
2514
|
return;
|
|
1108
2515
|
}
|
|
1109
2516
|
const id = msg.id;
|
|
2517
|
+
const name = typeof msg.name === "string" ? msg.name : null;
|
|
2518
|
+
if (name !== null && !NAMED_MESSAGE_TYPES.has(msg.type)) {
|
|
2519
|
+
this.sendError(conn, id, "INVALID_MESSAGE", `A "${msg.type}" message names no registered operation`);
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
if (!this.acceptSql && SQL_MESSAGE_TYPES.has(msg.type) && name === null) {
|
|
2523
|
+
this.sendError(conn, id, "SQL_NOT_ACCEPTED", SQL_NOT_ACCEPTED_MESSAGE);
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
1110
2526
|
switch (msg.type) {
|
|
1111
2527
|
case "query":
|
|
1112
|
-
|
|
2528
|
+
if (name !== null) handleNamedQueryMessage(this.namedContext(conn, state), msg, id, name);
|
|
2529
|
+
else handleQueryMessage(this.operationContext(conn, state), msg, id);
|
|
1113
2530
|
break;
|
|
1114
2531
|
case "execute":
|
|
1115
|
-
|
|
2532
|
+
if (name !== null) handleNamedExecuteMessage(this.namedContext(conn, state), msg, id, name);
|
|
2533
|
+
else handleExecuteMessage(this.operationContext(conn, state), msg, id);
|
|
1116
2534
|
break;
|
|
1117
2535
|
case "transaction":
|
|
1118
2536
|
handleTransactionMessage(this.operationContext(conn, state), msg, id);
|
|
@@ -1124,15 +2542,26 @@ var WSHandler = class {
|
|
|
1124
2542
|
handleLoadMessage(this.operationContext(conn, state), msg, id);
|
|
1125
2543
|
break;
|
|
1126
2544
|
case "subscribe":
|
|
1127
|
-
this.
|
|
2545
|
+
if (name !== null) handleLiveSubscribeMessage(this.liveDeps(), conn, state, msg, id, name);
|
|
2546
|
+
else handleSubscribeMessage(this.subscribeDeps(), conn, state, msg, id);
|
|
1128
2547
|
break;
|
|
1129
2548
|
case "unsubscribe":
|
|
1130
2549
|
this.handleUnsubscribe(conn, state, id);
|
|
1131
2550
|
break;
|
|
2551
|
+
case "ack":
|
|
2552
|
+
handleAckMessage(this.subscribeDeps(), conn, state, msg, id);
|
|
2553
|
+
break;
|
|
1132
2554
|
default:
|
|
1133
2555
|
this.sendError(conn, id, "UNKNOWN_TYPE", `Unknown message type: '${msg.type}'`);
|
|
1134
2556
|
}
|
|
1135
2557
|
}
|
|
2558
|
+
handleSocketDrain(conn) {
|
|
2559
|
+
const state = this.connections.get(conn);
|
|
2560
|
+
if (!state) return;
|
|
2561
|
+
for (const stream of state.deviceStreams.values()) {
|
|
2562
|
+
stream.onSocketDrain();
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
1136
2565
|
handleClose(conn) {
|
|
1137
2566
|
const state = this.connections.get(conn);
|
|
1138
2567
|
if (!state) return;
|
|
@@ -1167,109 +2596,43 @@ var WSHandler = class {
|
|
|
1167
2596
|
sendCaughtError: (id, err) => this.sendSirannonError(conn, id, err)
|
|
1168
2597
|
};
|
|
1169
2598
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
this.
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
return;
|
|
1178
|
-
}
|
|
1179
|
-
if (state.database.readOnly) {
|
|
1180
|
-
this.sendError(conn, id, "READ_ONLY", "Subscriptions are not available on read-only databases");
|
|
1181
|
-
return;
|
|
1182
|
-
}
|
|
1183
|
-
if (state.database.path === ":memory:") {
|
|
1184
|
-
this.sendError(conn, id, "CDC_UNSUPPORTED", "CDC subscriptions require file-based databases");
|
|
1185
|
-
return;
|
|
1186
|
-
}
|
|
1187
|
-
if (msg.filter !== void 0 && msg.filter !== null && (typeof msg.filter !== "object" || Array.isArray(msg.filter))) {
|
|
1188
|
-
this.sendError(conn, id, "INVALID_MESSAGE", '"filter" must be a plain object');
|
|
1189
|
-
return;
|
|
1190
|
-
}
|
|
1191
|
-
const decodedFilter = decodeBoundParams(msg.filter, "filter");
|
|
1192
|
-
if (!decodedFilter.ok) {
|
|
1193
|
-
this.sendError(conn, id, "INVALID_MESSAGE", decodedFilter.message);
|
|
1194
|
-
return;
|
|
1195
|
-
}
|
|
1196
|
-
const filter = decodedFilter.value;
|
|
1197
|
-
let sinceSeq;
|
|
1198
|
-
if (msg.sinceSeq !== void 0) {
|
|
1199
|
-
if (typeof msg.sinceSeq !== "string" || !/^\d+$/.test(msg.sinceSeq)) {
|
|
1200
|
-
this.sendError(conn, id, "INVALID_MESSAGE", '"sinceSeq" must be a non-negative integer string');
|
|
1201
|
-
return;
|
|
1202
|
-
}
|
|
1203
|
-
sinceSeq = BigInt(msg.sinceSeq);
|
|
1204
|
-
}
|
|
1205
|
-
let clientEpoch;
|
|
1206
|
-
if (msg.epoch !== void 0) {
|
|
1207
|
-
if (typeof msg.epoch !== "string") {
|
|
1208
|
-
this.sendError(conn, id, "INVALID_MESSAGE", '"epoch" must be a string');
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
|
-
clientEpoch = msg.epoch;
|
|
1212
|
-
}
|
|
1213
|
-
if (sinceSeq === void 0) {
|
|
1214
|
-
await this.subscribeLive(conn, state, id, msg.table, filter);
|
|
1215
|
-
return;
|
|
1216
|
-
}
|
|
1217
|
-
await this.subscribeResuming(conn, state, id, msg.table, filter, sinceSeq, clientEpoch);
|
|
2599
|
+
namedContext(conn, state) {
|
|
2600
|
+
return {
|
|
2601
|
+
...this.operationContext(conn, state),
|
|
2602
|
+
databaseId: state.databaseId,
|
|
2603
|
+
identity: state.identity,
|
|
2604
|
+
operations: this.operations
|
|
2605
|
+
};
|
|
1218
2606
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
state.subscriptions.set(id, sub);
|
|
1228
|
-
this.send(conn, { type: "subscribed", id, seq: boundary.toString(), epoch: ctx.epoch });
|
|
1229
|
-
} catch (err) {
|
|
1230
|
-
this.cdc.maybeCleanup(state.databaseId);
|
|
1231
|
-
this.sendSirannonError(conn, id, err);
|
|
1232
|
-
}
|
|
2607
|
+
liveDeps() {
|
|
2608
|
+
return {
|
|
2609
|
+
operations: this.operations,
|
|
2610
|
+
sendSubscribedRows: (conn, id, rows) => this.send(conn, { type: "subscribed", id, rows }),
|
|
2611
|
+
sendLive: (conn, message) => this.send(conn, message),
|
|
2612
|
+
sendError: (conn, id, code, message) => this.sendError(conn, id, code, message),
|
|
2613
|
+
sendSirannonError: (conn, id, err) => this.sendSirannonError(conn, id, err)
|
|
2614
|
+
};
|
|
1233
2615
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
try {
|
|
1240
|
-
ctx = await this.cdc.ensure(state.databaseId, state.database);
|
|
1241
|
-
await ctx.tracker.watch(ctx.cdcConn, table);
|
|
1242
|
-
boundary = ctx.tracker.cursor;
|
|
1243
|
-
primed = new PrimedSubscription(
|
|
1244
|
-
sinceSeq,
|
|
1245
|
-
(event) => this.sendChange(conn, id, event),
|
|
1246
|
-
() => this.handleOverload(conn)
|
|
1247
|
-
);
|
|
1248
|
-
const sub = ctx.manager.subscribe(table, filter, (event) => primed.onLiveEvent(event));
|
|
1249
|
-
state.subscriptions.set(id, sub);
|
|
1250
|
-
const minSeq = await ctx.tracker.getMinSeq(ctx.cdcConn);
|
|
1251
|
-
const foreignEpoch = clientEpoch !== void 0 && clientEpoch !== ctx.epoch;
|
|
1252
|
-
resync = foreignEpoch || needsResync(sinceSeq, minSeq, boundary);
|
|
1253
|
-
this.send(conn, {
|
|
2616
|
+
subscribeDeps() {
|
|
2617
|
+
return {
|
|
2618
|
+
cdc: this.cdc,
|
|
2619
|
+
maxUnacknowledgedChanges: this.maxUnacknowledgedChanges,
|
|
2620
|
+
sendSubscribed: (conn, id, seq, epoch, resync, maxUnacknowledgedChanges) => this.send(conn, {
|
|
1254
2621
|
type: "subscribed",
|
|
1255
2622
|
id,
|
|
1256
|
-
seq
|
|
1257
|
-
epoch
|
|
1258
|
-
...resync ? { resync: true } : {}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
this.
|
|
1262
|
-
this.
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
this.send(conn, { type: "subscribed", id, seq: boundary.toString(), epoch: ctx.epoch, resync: true });
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
primed.goLive();
|
|
2623
|
+
seq,
|
|
2624
|
+
epoch,
|
|
2625
|
+
...resync ? { resync: true } : {},
|
|
2626
|
+
...maxUnacknowledgedChanges !== void 0 ? { maxUnacknowledgedChanges } : {}
|
|
2627
|
+
}),
|
|
2628
|
+
sendResult: (conn, id, data) => this.send(conn, { type: "result", id, data }),
|
|
2629
|
+
sendError: (conn, id, code, message) => this.sendError(conn, id, code, message),
|
|
2630
|
+
sendSirannonError: (conn, id, err) => this.sendSirannonError(conn, id, err),
|
|
2631
|
+
sendChange: (conn, id, event) => this.sendChange(conn, id, event),
|
|
2632
|
+
sendText: (conn, data) => this.sendText(conn, data),
|
|
2633
|
+
closeFaulted: (conn) => conn.close(1011, "Device stream failed"),
|
|
2634
|
+
handleOverload: (conn) => this.handleOverload(conn)
|
|
2635
|
+
};
|
|
1273
2636
|
}
|
|
1274
2637
|
handleUnsubscribe(conn, state, id) {
|
|
1275
2638
|
const sub = state.subscriptions.get(id);
|
|
@@ -1311,18 +2674,14 @@ var WSHandler = class {
|
|
|
1311
2674
|
this.sendError(conn, id, code, message);
|
|
1312
2675
|
}
|
|
1313
2676
|
sendChange(conn, subscriptionId, event) {
|
|
1314
|
-
return this.send(conn, {
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
seq: event.seq.toString(),
|
|
1323
|
-
timestamp: event.timestamp
|
|
1324
|
-
}
|
|
1325
|
-
});
|
|
2677
|
+
return this.send(conn, { type: "change", id: subscriptionId, event: wireChangeEvent(event) });
|
|
2678
|
+
}
|
|
2679
|
+
sendText(conn, data) {
|
|
2680
|
+
const outcome = conn.send(data);
|
|
2681
|
+
if (outcome === "dropped") {
|
|
2682
|
+
this.handleOverload(conn);
|
|
2683
|
+
}
|
|
2684
|
+
return outcome;
|
|
1326
2685
|
}
|
|
1327
2686
|
};
|
|
1328
2687
|
function createWSHandler(sirannon, options) {
|
|
@@ -1330,17 +2689,14 @@ function createWSHandler(sirannon, options) {
|
|
|
1330
2689
|
}
|
|
1331
2690
|
|
|
1332
2691
|
// src/server/ws-route.ts
|
|
2692
|
+
var UNSUPPORTED_SUBPROTOCOL_MESSAGE = `The upgrade offered no subprotocol this server supports. Offer '${SIRANNON_WS_SUBPROTOCOL}' alongside any credential-bearing value, or offer none at all.`;
|
|
1333
2693
|
function toSendOutcome(result) {
|
|
1334
2694
|
if (result === 2) return "dropped";
|
|
1335
2695
|
if (result === 0) return "buffered";
|
|
1336
2696
|
return "sent";
|
|
1337
2697
|
}
|
|
1338
|
-
function selectWebSocketProtocol(header) {
|
|
1339
|
-
const [firstProtocol] = header.split(",");
|
|
1340
|
-
return firstProtocol?.trim() ?? "";
|
|
1341
|
-
}
|
|
1342
2698
|
function registerWebSocketRoute(options) {
|
|
1343
|
-
const { app, wsHandler,
|
|
2699
|
+
const { app, wsHandler, authenticateHook } = options;
|
|
1344
2700
|
app.ws("/db/:id", {
|
|
1345
2701
|
maxPayloadLength: options.maxBodyBytes,
|
|
1346
2702
|
maxBackpressure: options.maxBackpressureBytes,
|
|
@@ -1352,7 +2708,7 @@ function registerWebSocketRoute(options) {
|
|
|
1352
2708
|
const method = req.getMethod();
|
|
1353
2709
|
const secWebSocketKey = req.getHeader("sec-websocket-key");
|
|
1354
2710
|
const secWebSocketProtocol = req.getHeader("sec-websocket-protocol");
|
|
1355
|
-
const
|
|
2711
|
+
const negotiated = selectSubprotocol(secWebSocketProtocol);
|
|
1356
2712
|
const secWebSocketExtensions = req.getHeader("sec-websocket-extensions");
|
|
1357
2713
|
const headers = {};
|
|
1358
2714
|
req.forEach((key, value) => {
|
|
@@ -1360,7 +2716,14 @@ function registerWebSocketRoute(options) {
|
|
|
1360
2716
|
});
|
|
1361
2717
|
const remoteAddress = decodeRemoteAddress(res);
|
|
1362
2718
|
const abort = initAbortHandler(res);
|
|
1363
|
-
if (!
|
|
2719
|
+
if (!negotiated.ok) {
|
|
2720
|
+
if (abort.claim()) {
|
|
2721
|
+
sendError(res, 400, "UNSUPPORTED_SUBPROTOCOL", UNSUPPORTED_SUBPROTOCOL_MESSAGE);
|
|
2722
|
+
}
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
const selectedWebSocketProtocol = negotiated.protocol;
|
|
2726
|
+
if (!authenticateHook) {
|
|
1364
2727
|
if (abort.claim()) {
|
|
1365
2728
|
res.upgrade(
|
|
1366
2729
|
{ databaseId: dbId },
|
|
@@ -1379,20 +2742,24 @@ function registerWebSocketRoute(options) {
|
|
|
1379
2742
|
databaseId: dbId,
|
|
1380
2743
|
remoteAddress
|
|
1381
2744
|
};
|
|
1382
|
-
|
|
1383
|
-
if (!
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
selectedWebSocketProtocol,
|
|
1388
|
-
secWebSocketExtensions,
|
|
1389
|
-
context
|
|
1390
|
-
);
|
|
2745
|
+
authenticateUpgrade(res, abort, ctx, authenticateHook).then((authenticated) => {
|
|
2746
|
+
if (!authenticated.ok && !authenticated.refusal) return;
|
|
2747
|
+
if (!abort.claim()) return;
|
|
2748
|
+
const userData = authenticated.ok ? { databaseId: dbId, identity: authenticated.identity } : { databaseId: dbId, refusal: authenticated.refusal };
|
|
2749
|
+
res.upgrade(userData, secWebSocketKey, selectedWebSocketProtocol, secWebSocketExtensions, context);
|
|
1391
2750
|
}).catch(() => {
|
|
1392
2751
|
});
|
|
1393
2752
|
},
|
|
1394
2753
|
open: (ws) => {
|
|
1395
2754
|
const userData = ws.getUserData();
|
|
2755
|
+
const refusal = userData.refusal;
|
|
2756
|
+
if (refusal) {
|
|
2757
|
+
try {
|
|
2758
|
+
ws.end(refusal.code, refusal.reason);
|
|
2759
|
+
} catch {
|
|
2760
|
+
}
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
1396
2763
|
const conn = {
|
|
1397
2764
|
send(data) {
|
|
1398
2765
|
try {
|
|
@@ -1409,7 +2776,7 @@ function registerWebSocketRoute(options) {
|
|
|
1409
2776
|
}
|
|
1410
2777
|
};
|
|
1411
2778
|
userData.conn = conn;
|
|
1412
|
-
wsHandler.handleOpen(conn, userData.databaseId).catch(() => {
|
|
2779
|
+
wsHandler.handleOpen(conn, userData.databaseId, userData.identity).catch(() => {
|
|
1413
2780
|
});
|
|
1414
2781
|
},
|
|
1415
2782
|
message: (ws, message) => {
|
|
@@ -1424,6 +2791,12 @@ function registerWebSocketRoute(options) {
|
|
|
1424
2791
|
wsHandler.handleOverload(userData.conn);
|
|
1425
2792
|
}
|
|
1426
2793
|
},
|
|
2794
|
+
drain: (ws) => {
|
|
2795
|
+
const userData = ws.getUserData();
|
|
2796
|
+
if (userData.conn) {
|
|
2797
|
+
wsHandler.handleSocketDrain(userData.conn);
|
|
2798
|
+
}
|
|
2799
|
+
},
|
|
1427
2800
|
close: (ws) => {
|
|
1428
2801
|
const userData = ws.getUserData();
|
|
1429
2802
|
if (!userData.conn) return;
|
|
@@ -1434,6 +2807,10 @@ function registerWebSocketRoute(options) {
|
|
|
1434
2807
|
}
|
|
1435
2808
|
|
|
1436
2809
|
// src/server/server.ts
|
|
2810
|
+
var SQL_ROUTES = ["/db/:id/query", "/db/:id/execute", "/db/:id/transaction", "/db/:id/batch", "/db/:id/load"];
|
|
2811
|
+
function refuseSql(res) {
|
|
2812
|
+
sendError(res, 403, "SQL_NOT_ACCEPTED", SQL_NOT_ACCEPTED_MESSAGE);
|
|
2813
|
+
}
|
|
1437
2814
|
var DEFAULT_MAX_BODY_BYTES = 1048576;
|
|
1438
2815
|
var DEFAULT_WS_BACKPRESSURE_BYTES = 16 * 1048576;
|
|
1439
2816
|
var UWS_MAX_LIMIT_BYTES = 4294967295;
|
|
@@ -1481,10 +2858,14 @@ var SirannonServer = class {
|
|
|
1481
2858
|
host;
|
|
1482
2859
|
port;
|
|
1483
2860
|
cors;
|
|
1484
|
-
|
|
2861
|
+
authenticateHook;
|
|
2862
|
+
acceptSql;
|
|
2863
|
+
operations;
|
|
2864
|
+
registryDigest;
|
|
1485
2865
|
resolveExecutionTarget;
|
|
1486
2866
|
getReplicationStatus;
|
|
1487
2867
|
getClusterStatus;
|
|
2868
|
+
authorizeClusterStatus;
|
|
1488
2869
|
sirannon;
|
|
1489
2870
|
wsHandler;
|
|
1490
2871
|
maxBodyBytes;
|
|
@@ -1494,16 +2875,24 @@ var SirannonServer = class {
|
|
|
1494
2875
|
this.host = options?.host ?? "127.0.0.1";
|
|
1495
2876
|
this.port = options?.port ?? 9876;
|
|
1496
2877
|
this.cors = resolveCors(options?.cors);
|
|
1497
|
-
this.
|
|
2878
|
+
this.authenticateHook = options?.authenticate;
|
|
2879
|
+
this.acceptSql = options?.acceptSql === true;
|
|
2880
|
+
this.operations = options?.operations;
|
|
2881
|
+
this.registryDigest = operationRegistryDigest(options?.operations);
|
|
1498
2882
|
this.resolveExecutionTarget = options?.resolveExecutionTarget;
|
|
1499
2883
|
this.getReplicationStatus = options?.getReplicationStatus;
|
|
1500
2884
|
this.getClusterStatus = options?.getClusterStatus;
|
|
2885
|
+
this.authorizeClusterStatus = options?.authorizeClusterStatus;
|
|
1501
2886
|
this.maxBodyBytes = resolveMaxBodyBytes(options?.maxBodyBytes);
|
|
1502
2887
|
this.maxWsBackpressureBytes = resolveWsBackpressure(options?.maxWebSocketBackpressureBytes, this.maxBodyBytes);
|
|
1503
2888
|
this.wsHandler = new WSHandler(sirannon, {
|
|
1504
2889
|
resolveExecutionTarget: this.resolveExecutionTarget,
|
|
1505
2890
|
maxPayloadLength: this.maxBodyBytes,
|
|
1506
|
-
cdcRetentionMs: options?.cdcRetentionMs
|
|
2891
|
+
cdcRetentionMs: options?.cdcRetentionMs,
|
|
2892
|
+
deviceCursorRetentionMs: options?.deviceCursorRetentionMs,
|
|
2893
|
+
maxUnacknowledgedChanges: options?.maxUnacknowledgedChanges,
|
|
2894
|
+
acceptSql: this.acceptSql,
|
|
2895
|
+
operations: options?.operations
|
|
1507
2896
|
});
|
|
1508
2897
|
this.app = uWS.App();
|
|
1509
2898
|
this.registerRoutes();
|
|
@@ -1546,17 +2935,42 @@ var SirannonServer = class {
|
|
|
1546
2935
|
});
|
|
1547
2936
|
});
|
|
1548
2937
|
}
|
|
2938
|
+
this.app.get(
|
|
2939
|
+
"/capabilities",
|
|
2940
|
+
this.withCors(handleCapabilities({ registryDigest: this.registryDigest, acceptSql: this.acceptSql }))
|
|
2941
|
+
);
|
|
1549
2942
|
this.app.get("/health", this.withCors(handleLiveness()));
|
|
1550
2943
|
this.app.get("/health/ready", this.withCors(handleReadiness(this.sirannon, this.getReplicationStatus)));
|
|
1551
|
-
this.app.get(
|
|
1552
|
-
|
|
1553
|
-
|
|
2944
|
+
this.app.get(
|
|
2945
|
+
"/db/:id/cluster",
|
|
2946
|
+
this.wrapDbGetRoute(handleClusterStatus(this.getClusterStatus, this.authorizeClusterStatus))
|
|
2947
|
+
);
|
|
2948
|
+
if (this.acceptSql) {
|
|
2949
|
+
this.app.post("/db/:id/query", this.wrapDbRoute(handleQuery(this.sirannon, this.resolveExecutionTarget)));
|
|
2950
|
+
this.app.post("/db/:id/execute", this.wrapDbRoute(handleExecute(this.sirannon, this.resolveExecutionTarget)));
|
|
2951
|
+
this.app.post(
|
|
2952
|
+
"/db/:id/transaction",
|
|
2953
|
+
this.wrapDbRoute(handleTransaction(this.sirannon, this.resolveExecutionTarget))
|
|
2954
|
+
);
|
|
2955
|
+
this.app.post("/db/:id/batch", this.wrapDbRoute(handleBatch(this.sirannon, this.resolveExecutionTarget)));
|
|
2956
|
+
this.app.post("/db/:id/load", this.wrapDbRoute(handleLoad(this.sirannon, this.resolveExecutionTarget)));
|
|
2957
|
+
} else {
|
|
2958
|
+
for (const route of SQL_ROUTES) {
|
|
2959
|
+
this.app.post(route, this.withCors(refuseSql));
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
this.app.post(
|
|
2963
|
+
"/db/:id/query/:name",
|
|
2964
|
+
this.wrapOperationRoute(handleOperationQuery(this.sirannon, this.operations, this.resolveExecutionTarget))
|
|
2965
|
+
);
|
|
1554
2966
|
this.app.post(
|
|
1555
|
-
"/db/:id/
|
|
1556
|
-
this.
|
|
2967
|
+
"/db/:id/execute/:name",
|
|
2968
|
+
this.wrapOperationRoute(handleOperationExecute(this.sirannon, this.operations, this.resolveExecutionTarget))
|
|
1557
2969
|
);
|
|
1558
|
-
this.app.post("/db/:id/
|
|
1559
|
-
this.app.post("/db/:id/
|
|
2970
|
+
this.app.post("/db/:id/changes", this.wrapDbRoute(handleChanges(this.sirannon, this.resolveExecutionTarget)));
|
|
2971
|
+
this.app.post("/db/:id/migrations", this.wrapDbRoute(handleMigrationList(this.sirannon)));
|
|
2972
|
+
this.app.post("/db/:id/snapshot", this.wrapDbRoute(handleSnapshotManifest(this.sirannon)));
|
|
2973
|
+
this.app.post("/db/:id/snapshot/page", this.wrapDbRoute(handleSnapshotPage(this.sirannon)));
|
|
1560
2974
|
this.registerWebSocketRoute();
|
|
1561
2975
|
this.app.any("/*", (res) => {
|
|
1562
2976
|
sendError(res, 404, "NOT_FOUND", "Route not found");
|
|
@@ -1566,7 +2980,7 @@ var SirannonServer = class {
|
|
|
1566
2980
|
registerWebSocketRoute({
|
|
1567
2981
|
app: this.app,
|
|
1568
2982
|
wsHandler: this.wsHandler,
|
|
1569
|
-
|
|
2983
|
+
authenticateHook: this.authenticateHook,
|
|
1570
2984
|
maxBodyBytes: this.maxBodyBytes,
|
|
1571
2985
|
maxBackpressureBytes: this.maxWsBackpressureBytes
|
|
1572
2986
|
});
|
|
@@ -1580,7 +2994,7 @@ var SirannonServer = class {
|
|
|
1580
2994
|
};
|
|
1581
2995
|
}
|
|
1582
2996
|
wrapDbRoute(handler) {
|
|
1583
|
-
const
|
|
2997
|
+
const authenticateHook = this.authenticateHook;
|
|
1584
2998
|
const corsHeaders = this.cors;
|
|
1585
2999
|
const maxBody = this.maxBodyBytes;
|
|
1586
3000
|
return (res, req) => {
|
|
@@ -1592,7 +3006,7 @@ var SirannonServer = class {
|
|
|
1592
3006
|
}
|
|
1593
3007
|
const abort = initAbortHandler(res);
|
|
1594
3008
|
const bodyPromise = readBody(res, maxBody, abort);
|
|
1595
|
-
if (!
|
|
3009
|
+
if (!authenticateHook) {
|
|
1596
3010
|
bodyPromise.then(async (rawBody) => {
|
|
1597
3011
|
if (!abort.claim()) return;
|
|
1598
3012
|
try {
|
|
@@ -1618,9 +3032,9 @@ var SirannonServer = class {
|
|
|
1618
3032
|
databaseId: dbId,
|
|
1619
3033
|
remoteAddress
|
|
1620
3034
|
};
|
|
1621
|
-
const hookPromise =
|
|
1622
|
-
Promise.all([bodyPromise, hookPromise]).then(async ([rawBody,
|
|
1623
|
-
if (!
|
|
3035
|
+
const hookPromise = runAuthenticate(res, abort, ctx, authenticateHook);
|
|
3036
|
+
Promise.all([bodyPromise, hookPromise]).then(async ([rawBody, authenticated]) => {
|
|
3037
|
+
if (!authenticated.ok || !abort.claim()) return;
|
|
1624
3038
|
try {
|
|
1625
3039
|
await handler(res, dbId, rawBody, abort);
|
|
1626
3040
|
} catch {
|
|
@@ -1632,8 +3046,18 @@ var SirannonServer = class {
|
|
|
1632
3046
|
});
|
|
1633
3047
|
};
|
|
1634
3048
|
}
|
|
3049
|
+
wrapOperationRoute(handler) {
|
|
3050
|
+
return wrapOperationRoute(
|
|
3051
|
+
{
|
|
3052
|
+
cors: this.cors,
|
|
3053
|
+
maxBodyBytes: this.maxBodyBytes,
|
|
3054
|
+
authenticateHook: this.authenticateHook
|
|
3055
|
+
},
|
|
3056
|
+
handler
|
|
3057
|
+
);
|
|
3058
|
+
}
|
|
1635
3059
|
wrapDbGetRoute(handler) {
|
|
1636
|
-
const
|
|
3060
|
+
const authenticateHook = this.authenticateHook;
|
|
1637
3061
|
const corsHeaders = this.cors;
|
|
1638
3062
|
return (res, req) => {
|
|
1639
3063
|
const dbId = req.getParameter(0) ?? "";
|
|
@@ -1642,10 +3066,6 @@ var SirannonServer = class {
|
|
|
1642
3066
|
if (corsHeaders) {
|
|
1643
3067
|
writeCorsOrigin(res, corsHeaders, req.getHeader("origin"));
|
|
1644
3068
|
}
|
|
1645
|
-
if (!onRequestHook) {
|
|
1646
|
-
handler(res, dbId);
|
|
1647
|
-
return;
|
|
1648
|
-
}
|
|
1649
3069
|
const headers = {};
|
|
1650
3070
|
req.forEach((key, value) => {
|
|
1651
3071
|
headers[key] = value;
|
|
@@ -1658,10 +3078,18 @@ var SirannonServer = class {
|
|
|
1658
3078
|
remoteAddress: decodeRemoteAddress(res)
|
|
1659
3079
|
};
|
|
1660
3080
|
const abort = initAbortHandler(res);
|
|
1661
|
-
|
|
1662
|
-
if (!
|
|
1663
|
-
|
|
1664
|
-
|
|
3081
|
+
const run = async () => {
|
|
3082
|
+
if (authenticateHook && !(await runAuthenticate(res, abort, ctx, authenticateHook)).ok) return;
|
|
3083
|
+
if (!abort.claim()) return;
|
|
3084
|
+
try {
|
|
3085
|
+
await handler(res, dbId, ctx, abort);
|
|
3086
|
+
} catch {
|
|
3087
|
+
if (!abort.aborted) {
|
|
3088
|
+
sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
};
|
|
3092
|
+
run().catch(() => {
|
|
1665
3093
|
});
|
|
1666
3094
|
};
|
|
1667
3095
|
}
|