@korajs/server 1.0.0-beta.1 → 1.0.0-beta.10
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/dist/index.cjs +1671 -340
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +906 -279
- package/dist/index.d.ts +906 -279
- package/dist/index.js +1659 -323
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -12,11 +12,11 @@ var __export = (target, all) => {
|
|
|
12
12
|
for (var name in all)
|
|
13
13
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
14
14
|
};
|
|
15
|
-
var __copyProps = (to, from, except,
|
|
15
|
+
var __copyProps = (to, from, except, desc2) => {
|
|
16
16
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
17
17
|
for (let key of __getOwnPropNames(from))
|
|
18
18
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
19
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(
|
|
19
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc2 = __getOwnPropDesc(from, key)) || desc2.enumerable });
|
|
20
20
|
}
|
|
21
21
|
return to;
|
|
22
22
|
};
|
|
@@ -176,6 +176,7 @@ __export(index_exports, {
|
|
|
176
176
|
MixedAuthProvider: () => MixedAuthProvider,
|
|
177
177
|
NoAuthProvider: () => NoAuthProvider,
|
|
178
178
|
PostgresServerStore: () => PostgresServerStore,
|
|
179
|
+
RETRIABLE_REJECTION_CODES: () => RETRIABLE_REJECTION_CODES,
|
|
179
180
|
SqliteServerStore: () => SqliteServerStore,
|
|
180
181
|
TokenAuthProvider: () => TokenAuthProvider,
|
|
181
182
|
WsServerTransport: () => WsServerTransport,
|
|
@@ -186,7 +187,8 @@ __export(index_exports, {
|
|
|
186
187
|
createPrettyLogger: () => createPrettyLogger,
|
|
187
188
|
createProductionServer: () => createProductionServer,
|
|
188
189
|
createSilentLogger: () => createSilentLogger,
|
|
189
|
-
createSqliteServerStore: () => createSqliteServerStore
|
|
190
|
+
createSqliteServerStore: () => createSqliteServerStore,
|
|
191
|
+
isRetriableRejection: () => isRetriableRejection
|
|
190
192
|
});
|
|
191
193
|
module.exports = __toCommonJS(index_exports);
|
|
192
194
|
init_cjs_shims();
|
|
@@ -248,6 +250,13 @@ function createDefaultLogger() {
|
|
|
248
250
|
}
|
|
249
251
|
}
|
|
250
252
|
|
|
253
|
+
// src/apply/rejection-taxonomy.ts
|
|
254
|
+
init_cjs_shims();
|
|
255
|
+
var RETRIABLE_REJECTION_CODES = /* @__PURE__ */ new Set(["RATE_LIMIT"]);
|
|
256
|
+
function isRetriableRejection(code) {
|
|
257
|
+
return RETRIABLE_REJECTION_CODES.has(code);
|
|
258
|
+
}
|
|
259
|
+
|
|
251
260
|
// src/store/memory-server-store.ts
|
|
252
261
|
init_cjs_shims();
|
|
253
262
|
var import_core2 = require("@korajs/core");
|
|
@@ -269,6 +278,14 @@ function fieldTypeToSql(descriptor, dialect) {
|
|
|
269
278
|
return dialect === "postgres" ? "BIGINT" : "INTEGER";
|
|
270
279
|
case "array":
|
|
271
280
|
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
281
|
+
case "object":
|
|
282
|
+
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
283
|
+
case "json":
|
|
284
|
+
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
285
|
+
case "blob":
|
|
286
|
+
return "TEXT";
|
|
287
|
+
case "secret":
|
|
288
|
+
return "TEXT";
|
|
272
289
|
case "richtext":
|
|
273
290
|
return dialect === "postgres" ? "BYTEA" : "BLOB";
|
|
274
291
|
}
|
|
@@ -285,13 +302,13 @@ function generateCollectionDDL(name, collection, dialect) {
|
|
|
285
302
|
const columns = ["id TEXT PRIMARY KEY NOT NULL"];
|
|
286
303
|
for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
|
|
287
304
|
const sqlType = fieldTypeToSql(descriptor, dialect);
|
|
288
|
-
let colDef = `${fieldName} ${sqlType}`;
|
|
305
|
+
let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
|
|
289
306
|
if (descriptor.defaultValue !== void 0) {
|
|
290
307
|
colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
|
|
291
308
|
}
|
|
292
309
|
if (descriptor.kind === "enum" && descriptor.enumValues) {
|
|
293
310
|
const values = descriptor.enumValues.map((v) => `'${v}'`).join(", ");
|
|
294
|
-
colDef += ` CHECK (${fieldName} IN (${values}))`;
|
|
311
|
+
colDef += ` CHECK (${(0, import_core.quoteIdent)(fieldName)} IN (${values}))`;
|
|
295
312
|
}
|
|
296
313
|
columns.push(colDef);
|
|
297
314
|
}
|
|
@@ -299,24 +316,26 @@ function generateCollectionDDL(name, collection, dialect) {
|
|
|
299
316
|
columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
|
|
300
317
|
columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
|
|
301
318
|
columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
|
|
302
|
-
statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
|
|
319
|
+
statements.push(`CREATE TABLE IF NOT EXISTS ${(0, import_core.quoteIdent)(name)} (
|
|
303
320
|
${columns.join(",\n ")}
|
|
304
321
|
)`);
|
|
305
322
|
for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
|
|
306
323
|
const sqlType = fieldTypeToSql(descriptor, dialect);
|
|
307
|
-
let colDef = `${fieldName} ${sqlType}`;
|
|
324
|
+
let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
|
|
308
325
|
if (descriptor.defaultValue !== void 0) {
|
|
309
326
|
colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
|
|
310
327
|
}
|
|
311
328
|
statements.push(`--kora:safe-alter
|
|
312
|
-
ALTER TABLE ${name} ADD COLUMN ${colDef}`);
|
|
329
|
+
ALTER TABLE ${(0, import_core.quoteIdent)(name)} ADD COLUMN ${colDef}`);
|
|
313
330
|
}
|
|
314
331
|
for (const indexField of collection.indexes) {
|
|
315
332
|
statements.push(
|
|
316
|
-
`CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
|
|
333
|
+
`CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${(0, import_core.quoteIdent)(name)} (${(0, import_core.quoteIdent)(indexField)})`
|
|
317
334
|
);
|
|
318
335
|
}
|
|
319
|
-
statements.push(
|
|
336
|
+
statements.push(
|
|
337
|
+
`CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${(0, import_core.quoteIdent)(name)} (_deleted)`
|
|
338
|
+
);
|
|
320
339
|
return statements;
|
|
321
340
|
}
|
|
322
341
|
function generateAllCollectionDDL(schema, dialect) {
|
|
@@ -326,30 +345,6 @@ function generateAllCollectionDDL(schema, dialect) {
|
|
|
326
345
|
}
|
|
327
346
|
return statements;
|
|
328
347
|
}
|
|
329
|
-
function replayOperationsForRecord(ops) {
|
|
330
|
-
let record = null;
|
|
331
|
-
let deleted = false;
|
|
332
|
-
for (const op of ops) {
|
|
333
|
-
switch (op.type) {
|
|
334
|
-
case "insert":
|
|
335
|
-
if (op.data) {
|
|
336
|
-
record = { ...op.data };
|
|
337
|
-
deleted = false;
|
|
338
|
-
}
|
|
339
|
-
break;
|
|
340
|
-
case "update":
|
|
341
|
-
if (op.data) {
|
|
342
|
-
record = { ...record ?? {}, ...op.data };
|
|
343
|
-
deleted = false;
|
|
344
|
-
}
|
|
345
|
-
break;
|
|
346
|
-
case "delete":
|
|
347
|
-
deleted = true;
|
|
348
|
-
break;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
return deleted ? null : record;
|
|
352
|
-
}
|
|
353
348
|
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
354
349
|
var BASE64_REVERSE = new Map(
|
|
355
350
|
Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
|
|
@@ -400,6 +395,9 @@ function serializeFieldValue(value, descriptor) {
|
|
|
400
395
|
if (value === null || value === void 0) return null;
|
|
401
396
|
switch (descriptor.kind) {
|
|
402
397
|
case "array":
|
|
398
|
+
case "object":
|
|
399
|
+
case "json":
|
|
400
|
+
case "blob":
|
|
403
401
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
404
402
|
case "boolean":
|
|
405
403
|
return value ? 1 : 0;
|
|
@@ -413,6 +411,9 @@ function deserializeFieldValue(value, descriptor) {
|
|
|
413
411
|
if (value === null || value === void 0) return null;
|
|
414
412
|
switch (descriptor.kind) {
|
|
415
413
|
case "array":
|
|
414
|
+
case "object":
|
|
415
|
+
case "json":
|
|
416
|
+
case "blob":
|
|
416
417
|
return typeof value === "string" ? JSON.parse(value) : value;
|
|
417
418
|
case "boolean":
|
|
418
419
|
return value === 1 || value === true;
|
|
@@ -445,6 +446,14 @@ var MemoryServerStore = class {
|
|
|
445
446
|
operations = [];
|
|
446
447
|
operationIndex = /* @__PURE__ */ new Map();
|
|
447
448
|
versionVector = /* @__PURE__ */ new Map();
|
|
449
|
+
/**
|
|
450
|
+
* Server-assigned delivery sequence per operation id. Single-process, so a plain
|
|
451
|
+
* counter incremented at insert time gives commit-order delivery sequence with no
|
|
452
|
+
* gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
|
|
453
|
+
* is a forward walk.
|
|
454
|
+
*/
|
|
455
|
+
deliverySeqByOpId = /* @__PURE__ */ new Map();
|
|
456
|
+
deliverySeqCounter = 0;
|
|
448
457
|
schema = null;
|
|
449
458
|
/** Materialized records: collection -> recordId -> record data */
|
|
450
459
|
materializedRecords = /* @__PURE__ */ new Map();
|
|
@@ -478,6 +487,8 @@ var MemoryServerStore = class {
|
|
|
478
487
|
}
|
|
479
488
|
this.operations.push(op);
|
|
480
489
|
this.operationIndex.set(op.id, op);
|
|
490
|
+
this.deliverySeqCounter += 1;
|
|
491
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
481
492
|
const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
|
|
482
493
|
if (op.sequenceNumber > currentSeq) {
|
|
483
494
|
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
@@ -497,6 +508,23 @@ var MemoryServerStore = class {
|
|
|
497
508
|
this.assertOpen();
|
|
498
509
|
return this.operations.length;
|
|
499
510
|
}
|
|
511
|
+
async getMaxDeliverySequence() {
|
|
512
|
+
this.assertOpen();
|
|
513
|
+
return this.deliverySeqCounter;
|
|
514
|
+
}
|
|
515
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
516
|
+
this.assertOpen();
|
|
517
|
+
const result = [];
|
|
518
|
+
if (limit <= 0) return result;
|
|
519
|
+
for (const op of this.operations) {
|
|
520
|
+
const deliverySequence = this.deliverySeqByOpId.get(op.id) ?? 0;
|
|
521
|
+
if (deliverySequence > afterDeliverySequence) {
|
|
522
|
+
result.push({ operation: op, deliverySequence });
|
|
523
|
+
if (result.length >= limit) break;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
500
528
|
async materializeCollection(collection) {
|
|
501
529
|
this.assertOpen();
|
|
502
530
|
if (this.schema?.collections[collection]) {
|
|
@@ -641,12 +669,16 @@ var MemoryServerStore = class {
|
|
|
641
669
|
this.operations.length = 0;
|
|
642
670
|
this.operationIndex.clear();
|
|
643
671
|
this.versionVector.clear();
|
|
672
|
+
this.deliverySeqByOpId.clear();
|
|
673
|
+
this.deliverySeqCounter = 0;
|
|
644
674
|
for (const [nid, seq] of versionVector) {
|
|
645
675
|
this.versionVector.set(nid, seq);
|
|
646
676
|
}
|
|
647
677
|
for (const op of operations2) {
|
|
648
678
|
this.operations.push(op);
|
|
649
679
|
this.operationIndex.set(op.id, op);
|
|
680
|
+
this.deliverySeqCounter += 1;
|
|
681
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
650
682
|
if (this.schema?.collections[op.collection]) {
|
|
651
683
|
this.rebuildMaterializedRecord(op.collection, op.recordId);
|
|
652
684
|
}
|
|
@@ -676,13 +708,14 @@ var MemoryServerStore = class {
|
|
|
676
708
|
return a.timestamp.wallTime - b.timestamp.wallTime;
|
|
677
709
|
if (a.timestamp.logical !== b.timestamp.logical)
|
|
678
710
|
return a.timestamp.logical - b.timestamp.logical;
|
|
679
|
-
return a.
|
|
711
|
+
return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
|
|
680
712
|
});
|
|
681
713
|
const parsedOps = recordOps.map((op) => ({
|
|
682
714
|
type: op.type,
|
|
683
|
-
data: op.data
|
|
715
|
+
data: op.data,
|
|
716
|
+
atomicOps: op.atomicOps ?? null
|
|
684
717
|
}));
|
|
685
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
718
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
686
719
|
if (recordData) {
|
|
687
720
|
const createdAt = recordOps.length > 0 ? recordOps[0].timestamp.wallTime : Date.now();
|
|
688
721
|
const updatedAt = recordOps.length > 0 ? recordOps[recordOps.length - 1].timestamp.wallTime : Date.now();
|
|
@@ -805,6 +838,8 @@ var pgOperations = (0, import_pg_core.pgTable)(
|
|
|
805
838
|
// JSON-serialized, null for deletes
|
|
806
839
|
previousData: (0, import_pg_core.text)("previous_data"),
|
|
807
840
|
// JSON-serialized, null for insert/delete
|
|
841
|
+
atomicOps: (0, import_pg_core.text)("atomic_ops"),
|
|
842
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
808
843
|
wallTime: (0, import_pg_core.bigint)("wall_time", { mode: "number" }).notNull(),
|
|
809
844
|
logical: (0, import_pg_core.integer)("logical").notNull(),
|
|
810
845
|
timestampNodeId: (0, import_pg_core.text)("timestamp_node_id").notNull(),
|
|
@@ -812,12 +847,18 @@ var pgOperations = (0, import_pg_core.pgTable)(
|
|
|
812
847
|
causalDeps: (0, import_pg_core.text)("causal_deps").notNull().default("[]"),
|
|
813
848
|
// JSON array of op IDs
|
|
814
849
|
schemaVersion: (0, import_pg_core.integer)("schema_version").notNull(),
|
|
815
|
-
receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull()
|
|
850
|
+
receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull(),
|
|
851
|
+
// Server-assigned monotonic delivery sequence, ordered by commit (assigned from
|
|
852
|
+
// a counter row locked inside the append transaction, so delivery order equals
|
|
853
|
+
// visibility order even across instances). Drives the gap-free server->client
|
|
854
|
+
// delivery watermark. Nullable only for pre-column rows, backfilled on startup.
|
|
855
|
+
deliverySeq: (0, import_pg_core.bigint)("delivery_seq", { mode: "number" })
|
|
816
856
|
},
|
|
817
857
|
(table) => ({
|
|
818
858
|
nodeSeqIdx: (0, import_pg_core.index)("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
819
859
|
collectionIdx: (0, import_pg_core.index)("idx_pg_collection").on(table.collection),
|
|
820
|
-
receivedIdx: (0, import_pg_core.index)("idx_pg_received").on(table.receivedAt)
|
|
860
|
+
receivedIdx: (0, import_pg_core.index)("idx_pg_received").on(table.receivedAt),
|
|
861
|
+
deliveryIdx: (0, import_pg_core.index)("idx_pg_delivery_seq").on(table.deliverySeq)
|
|
821
862
|
})
|
|
822
863
|
);
|
|
823
864
|
var pgSyncState = (0, import_pg_core.pgTable)("sync_state", {
|
|
@@ -834,15 +875,43 @@ var PostgresServerStore = class {
|
|
|
834
875
|
ready;
|
|
835
876
|
schema = null;
|
|
836
877
|
closed = false;
|
|
837
|
-
|
|
878
|
+
/**
|
|
879
|
+
* Monotonic counter for this node's server-originated sequence numbers. Lazily
|
|
880
|
+
* seeded from the persisted version vector on first use, then advanced in memory
|
|
881
|
+
* (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
|
|
882
|
+
* applies never receive the same number. Null means "not yet seeded"; reset to
|
|
883
|
+
* null after a backup import so it re-seeds from the restored version vector.
|
|
884
|
+
*/
|
|
885
|
+
sequenceCounter = null;
|
|
886
|
+
/**
|
|
887
|
+
* Time source for HLC timestamps on server-originated operations. Injectable so a
|
|
888
|
+
* test can freeze wall-clock time and prove the conditional-apply ordering holds
|
|
889
|
+
* under same-millisecond commits (the case the advisory lock and clock advance
|
|
890
|
+
* exist to make correct). Defaults to the system clock in production.
|
|
891
|
+
*/
|
|
892
|
+
timeSource;
|
|
893
|
+
constructor(db, nodeId, timeSource) {
|
|
838
894
|
this.db = db;
|
|
839
895
|
this.nodeId = nodeId ?? (0, import_core3.generateUUIDv7)();
|
|
896
|
+
this.timeSource = timeSource ?? { now: () => Date.now() };
|
|
840
897
|
this.ready = this.initialize();
|
|
841
898
|
}
|
|
842
899
|
getVersionVector() {
|
|
843
900
|
this.assertOpen();
|
|
844
901
|
return new Map(this.versionVector);
|
|
845
902
|
}
|
|
903
|
+
/**
|
|
904
|
+
* Atomically reserve this node's next server-operation sequence number. The
|
|
905
|
+
* synchronous increment cannot interleave on the single JS thread, so two
|
|
906
|
+
* concurrent conditional applies are always handed distinct numbers.
|
|
907
|
+
*/
|
|
908
|
+
reserveSequenceNumber() {
|
|
909
|
+
if (this.sequenceCounter === null) {
|
|
910
|
+
this.sequenceCounter = this.versionVector.get(this.nodeId) ?? 0;
|
|
911
|
+
}
|
|
912
|
+
this.sequenceCounter += 1;
|
|
913
|
+
return this.sequenceCounter;
|
|
914
|
+
}
|
|
846
915
|
getNodeId() {
|
|
847
916
|
return this.nodeId;
|
|
848
917
|
}
|
|
@@ -880,8 +949,9 @@ var PostgresServerStore = class {
|
|
|
880
949
|
return "duplicate";
|
|
881
950
|
}
|
|
882
951
|
const now = Date.now();
|
|
883
|
-
const row = this.serializeOperation(op, now);
|
|
884
952
|
await this.db.transaction(async (tx) => {
|
|
953
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
954
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
885
955
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
886
956
|
await tx.insert(pgSyncState).values({
|
|
887
957
|
nodeId: op.nodeId,
|
|
@@ -904,6 +974,73 @@ var PostgresServerStore = class {
|
|
|
904
974
|
}
|
|
905
975
|
return "applied";
|
|
906
976
|
}
|
|
977
|
+
/**
|
|
978
|
+
* Conditionally apply operations atomically, serialized across server instances
|
|
979
|
+
* on the target record via a transaction-scoped advisory lock. The lock makes
|
|
980
|
+
* the read-decide-write cycle atomic against concurrent instances, so a cap
|
|
981
|
+
* check like `responseCount < max` cannot be passed by two admissions at once.
|
|
982
|
+
*/
|
|
983
|
+
async applyConditional(input) {
|
|
984
|
+
this.assertOpen();
|
|
985
|
+
await this.ready;
|
|
986
|
+
this.assertSchema();
|
|
987
|
+
this.assertCollection(input.target.collection);
|
|
988
|
+
const schema = this.schema;
|
|
989
|
+
const collectionDef = schema.collections[input.target.collection];
|
|
990
|
+
const lockKey = `kora:${input.target.collection}:${input.target.id}`;
|
|
991
|
+
const result = await this.db.transaction(async (tx) => {
|
|
992
|
+
await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
|
993
|
+
if (input.idempotencyKey) {
|
|
994
|
+
const existing = await tx.execute(
|
|
995
|
+
import_drizzle_orm.sql`SELECT 1 FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(input.idempotencyKey.collection))} WHERE id = ${input.idempotencyKey.id} AND _deleted = 0 LIMIT 1`
|
|
996
|
+
);
|
|
997
|
+
if (existing.length > 0) {
|
|
998
|
+
return { admitted: true, idempotent: true, applied: [] };
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const rows = await tx.execute(
|
|
1002
|
+
import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(input.target.collection))} WHERE id = ${input.target.id} AND _deleted = 0`
|
|
1003
|
+
);
|
|
1004
|
+
const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
|
|
1005
|
+
if (!input.admit(current)) {
|
|
1006
|
+
return { admitted: false, idempotent: false, applied: [] };
|
|
1007
|
+
}
|
|
1008
|
+
const clock = new import_core3.HybridLogicalClock(this.nodeId, this.timeSource);
|
|
1009
|
+
const latest = await this.latestOperationTimestamp(
|
|
1010
|
+
tx,
|
|
1011
|
+
input.target.collection,
|
|
1012
|
+
input.target.id
|
|
1013
|
+
);
|
|
1014
|
+
if (latest) {
|
|
1015
|
+
clock.advanceTo(latest);
|
|
1016
|
+
}
|
|
1017
|
+
const ops = await input.buildOperations(current, { clock });
|
|
1018
|
+
const now = Date.now();
|
|
1019
|
+
for (const op of ops) {
|
|
1020
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
1021
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
1022
|
+
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
1023
|
+
await tx.insert(pgSyncState).values({ nodeId: op.nodeId, maxSequenceNumber: op.sequenceNumber, lastSeenAt: now }).onConflictDoUpdate({
|
|
1024
|
+
target: pgSyncState.nodeId,
|
|
1025
|
+
set: {
|
|
1026
|
+
maxSequenceNumber: import_drizzle_orm.sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
|
|
1027
|
+
lastSeenAt: import_drizzle_orm.sql`${now}`
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
if (this.schema?.collections[op.collection]) {
|
|
1031
|
+
await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return { admitted: true, idempotent: false, applied: ops };
|
|
1035
|
+
});
|
|
1036
|
+
for (const op of result.applied) {
|
|
1037
|
+
const currentMax = this.versionVector.get(op.nodeId) ?? 0;
|
|
1038
|
+
if (op.sequenceNumber > currentMax) {
|
|
1039
|
+
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return result;
|
|
1043
|
+
}
|
|
907
1044
|
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
908
1045
|
this.assertOpen();
|
|
909
1046
|
await this.ready;
|
|
@@ -918,6 +1055,23 @@ var PostgresServerStore = class {
|
|
|
918
1055
|
const result = await this.db.select({ value: (0, import_drizzle_orm.count)() }).from(pgOperations);
|
|
919
1056
|
return result[0]?.value ?? 0;
|
|
920
1057
|
}
|
|
1058
|
+
async getMaxDeliverySequence() {
|
|
1059
|
+
this.assertOpen();
|
|
1060
|
+
await this.ready;
|
|
1061
|
+
const rows = await this.db.execute(
|
|
1062
|
+
import_drizzle_orm.sql`SELECT COALESCE(MAX(delivery_seq), 0) AS m FROM operations`
|
|
1063
|
+
);
|
|
1064
|
+
return Number(rows[0]?.m ?? 0);
|
|
1065
|
+
}
|
|
1066
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
1067
|
+
this.assertOpen();
|
|
1068
|
+
await this.ready;
|
|
1069
|
+
const rows = await this.db.select().from(pgOperations).where((0, import_drizzle_orm.gt)(pgOperations.deliverySeq, afterDeliverySequence)).orderBy((0, import_drizzle_orm.asc)(pgOperations.deliverySeq)).limit(limit);
|
|
1070
|
+
return rows.map((row) => ({
|
|
1071
|
+
operation: this.deserializeOperation(row),
|
|
1072
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
1073
|
+
}));
|
|
1074
|
+
}
|
|
921
1075
|
async materializeCollection(collection) {
|
|
922
1076
|
this.assertOpen();
|
|
923
1077
|
await this.ready;
|
|
@@ -952,7 +1106,7 @@ var PostgresServerStore = class {
|
|
|
952
1106
|
this.assertCollection(collection);
|
|
953
1107
|
const schema = this.schema;
|
|
954
1108
|
const collectionDef = schema.collections[collection];
|
|
955
|
-
const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
|
|
1109
|
+
const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
|
|
956
1110
|
const rows = await this.db.execute(query);
|
|
957
1111
|
if (rows.length === 0) return null;
|
|
958
1112
|
return this.deserializeRow(rows[0], collectionDef);
|
|
@@ -969,7 +1123,7 @@ var PostgresServerStore = class {
|
|
|
969
1123
|
}
|
|
970
1124
|
}
|
|
971
1125
|
const whereClause = this.buildWhereClause(where ?? {}, false);
|
|
972
|
-
const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`;
|
|
1126
|
+
const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`;
|
|
973
1127
|
const rows = await this.db.execute(query);
|
|
974
1128
|
const cnt = rows[0]?.cnt;
|
|
975
1129
|
return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
|
|
@@ -981,7 +1135,7 @@ var PostgresServerStore = class {
|
|
|
981
1135
|
this.assertOpen();
|
|
982
1136
|
await this.ready;
|
|
983
1137
|
const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
984
|
-
const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.
|
|
1138
|
+
const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.deliverySeq));
|
|
985
1139
|
const operations2 = rows.map((row) => this.deserializeOperation(row));
|
|
986
1140
|
return buildServerBackup2(this.nodeId, operations2, this.versionVector);
|
|
987
1141
|
}
|
|
@@ -996,6 +1150,7 @@ var PostgresServerStore = class {
|
|
|
996
1150
|
const result = await this.applyRemoteOperation(op);
|
|
997
1151
|
if (result === "applied") restored++;
|
|
998
1152
|
}
|
|
1153
|
+
this.sequenceCounter = null;
|
|
999
1154
|
return { operationsRestored: restored, success: true };
|
|
1000
1155
|
}
|
|
1001
1156
|
const now = Date.now();
|
|
@@ -1005,20 +1160,49 @@ var PostgresServerStore = class {
|
|
|
1005
1160
|
for (const [nid, seq] of versionVector) {
|
|
1006
1161
|
await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
|
|
1007
1162
|
}
|
|
1163
|
+
let deliverySeq = 0;
|
|
1008
1164
|
for (const op of operations2) {
|
|
1009
|
-
|
|
1165
|
+
deliverySeq += 1;
|
|
1166
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
1010
1167
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
1011
1168
|
}
|
|
1169
|
+
await tx.execute(
|
|
1170
|
+
import_drizzle_orm.sql`INSERT INTO delivery_counter (id, value) VALUES (1, ${deliverySeq})
|
|
1171
|
+
ON CONFLICT (id) DO UPDATE SET value = ${deliverySeq}`
|
|
1172
|
+
);
|
|
1012
1173
|
});
|
|
1013
1174
|
this.versionVector.clear();
|
|
1014
1175
|
for (const [nid, seq] of versionVector) {
|
|
1015
1176
|
this.versionVector.set(nid, seq);
|
|
1016
1177
|
}
|
|
1178
|
+
this.sequenceCounter = null;
|
|
1017
1179
|
return { operationsRestored: operations2.length, success: true };
|
|
1018
1180
|
}
|
|
1019
1181
|
// ---------------------------------------------------------------------------
|
|
1020
1182
|
// Materialization internals
|
|
1021
1183
|
// ---------------------------------------------------------------------------
|
|
1184
|
+
/**
|
|
1185
|
+
* The HLC timestamp of the most recent operation on a record, by total order
|
|
1186
|
+
* (wallTime, logical, nodeId), or null when the record has no operations. Used
|
|
1187
|
+
* by {@link applyConditional} to advance its clock past the record's latest
|
|
1188
|
+
* write so newly built operations sort strictly after it.
|
|
1189
|
+
*/
|
|
1190
|
+
async latestOperationTimestamp(txOrDb, collection, recordId) {
|
|
1191
|
+
const rows = await txOrDb.select({
|
|
1192
|
+
wallTime: pgOperations.wallTime,
|
|
1193
|
+
logical: pgOperations.logical,
|
|
1194
|
+
timestampNodeId: pgOperations.timestampNodeId
|
|
1195
|
+
}).from(pgOperations).where((0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(pgOperations.collection, collection), (0, import_drizzle_orm.eq)(pgOperations.recordId, recordId))).orderBy(
|
|
1196
|
+
(0, import_drizzle_orm.desc)(pgOperations.wallTime),
|
|
1197
|
+
(0, import_drizzle_orm.desc)(pgOperations.logical),
|
|
1198
|
+
(0, import_drizzle_orm.desc)(pgOperations.timestampNodeId)
|
|
1199
|
+
).limit(1);
|
|
1200
|
+
const row = rows[0];
|
|
1201
|
+
if (!row) {
|
|
1202
|
+
return null;
|
|
1203
|
+
}
|
|
1204
|
+
return { wallTime: row.wallTime, logical: row.logical, nodeId: row.timestampNodeId };
|
|
1205
|
+
}
|
|
1022
1206
|
/**
|
|
1023
1207
|
* Rebuild a single record in the materialized collection table by replaying
|
|
1024
1208
|
* all operations for that record.
|
|
@@ -1029,17 +1213,19 @@ var PostgresServerStore = class {
|
|
|
1029
1213
|
const ops = await txOrDb.select({
|
|
1030
1214
|
type: pgOperations.type,
|
|
1031
1215
|
data: pgOperations.data,
|
|
1216
|
+
atomicOps: pgOperations.atomicOps,
|
|
1032
1217
|
wallTime: pgOperations.wallTime
|
|
1033
1218
|
}).from(pgOperations).where((0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(pgOperations.collection, collection), (0, import_drizzle_orm.eq)(pgOperations.recordId, recordId))).orderBy(
|
|
1034
1219
|
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
1035
1220
|
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
1036
|
-
(0, import_drizzle_orm.asc)(pgOperations.
|
|
1221
|
+
(0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
|
|
1037
1222
|
);
|
|
1038
1223
|
const parsedOps = ops.map((op) => ({
|
|
1039
1224
|
type: op.type,
|
|
1040
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1225
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1226
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1041
1227
|
}));
|
|
1042
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1228
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1043
1229
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
1044
1230
|
if (recordData) {
|
|
1045
1231
|
const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
|
|
@@ -1056,7 +1242,7 @@ var PostgresServerStore = class {
|
|
|
1056
1242
|
);
|
|
1057
1243
|
} else {
|
|
1058
1244
|
await txOrDb.execute(
|
|
1059
|
-
import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1245
|
+
import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1060
1246
|
);
|
|
1061
1247
|
}
|
|
1062
1248
|
}
|
|
@@ -1075,16 +1261,16 @@ var PostgresServerStore = class {
|
|
|
1075
1261
|
updatedAt,
|
|
1076
1262
|
0
|
|
1077
1263
|
];
|
|
1078
|
-
const columnsSql = import_drizzle_orm.sql.raw(allColumns.join(", "));
|
|
1264
|
+
const columnsSql = import_drizzle_orm.sql.raw(allColumns.map((c) => (0, import_core3.quoteIdent)(c)).join(", "));
|
|
1079
1265
|
const valuesSql = import_drizzle_orm.sql.join(
|
|
1080
1266
|
values.map((v) => import_drizzle_orm.sql`${v}`),
|
|
1081
1267
|
import_drizzle_orm.sql.raw(", ")
|
|
1082
1268
|
);
|
|
1083
1269
|
const updateSet = import_drizzle_orm.sql.raw(
|
|
1084
|
-
allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
|
|
1270
|
+
allColumns.slice(1).map((c) => `${(0, import_core3.quoteIdent)(c)} = excluded.${(0, import_core3.quoteIdent)(c)}`).join(", ")
|
|
1085
1271
|
);
|
|
1086
1272
|
await txOrDb.execute(
|
|
1087
|
-
import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1273
|
+
import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1088
1274
|
);
|
|
1089
1275
|
}
|
|
1090
1276
|
/**
|
|
@@ -1106,11 +1292,12 @@ var PostgresServerStore = class {
|
|
|
1106
1292
|
recordId: pgOperations.recordId,
|
|
1107
1293
|
type: pgOperations.type,
|
|
1108
1294
|
data: pgOperations.data,
|
|
1295
|
+
atomicOps: pgOperations.atomicOps,
|
|
1109
1296
|
wallTime: pgOperations.wallTime
|
|
1110
1297
|
}).from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collectionName)).orderBy(
|
|
1111
1298
|
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
1112
1299
|
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
1113
|
-
(0, import_drizzle_orm.asc)(pgOperations.
|
|
1300
|
+
(0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
|
|
1114
1301
|
);
|
|
1115
1302
|
if (allOps.length === 0) return;
|
|
1116
1303
|
const grouped = /* @__PURE__ */ new Map();
|
|
@@ -1126,9 +1313,10 @@ var PostgresServerStore = class {
|
|
|
1126
1313
|
for (const [recordId, recordOps] of grouped) {
|
|
1127
1314
|
const parsedOps = recordOps.map((op) => ({
|
|
1128
1315
|
type: op.type,
|
|
1129
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1316
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1317
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1130
1318
|
}));
|
|
1131
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1319
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1132
1320
|
if (recordData) {
|
|
1133
1321
|
const createdAt = recordOps[0].wallTime;
|
|
1134
1322
|
const updatedAt = recordOps[recordOps.length - 1].wallTime;
|
|
@@ -1144,7 +1332,7 @@ var PostgresServerStore = class {
|
|
|
1144
1332
|
);
|
|
1145
1333
|
} else {
|
|
1146
1334
|
await this.db.execute(
|
|
1147
|
-
import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
|
|
1335
|
+
import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
|
|
1148
1336
|
);
|
|
1149
1337
|
}
|
|
1150
1338
|
}
|
|
@@ -1157,10 +1345,12 @@ var PostgresServerStore = class {
|
|
|
1157
1345
|
options?.where ?? {},
|
|
1158
1346
|
options?.includeDeleted ?? false
|
|
1159
1347
|
);
|
|
1160
|
-
const parts = [
|
|
1348
|
+
const parts = [
|
|
1349
|
+
import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`
|
|
1350
|
+
];
|
|
1161
1351
|
if (options?.orderBy) {
|
|
1162
1352
|
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
1163
|
-
parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
1353
|
+
parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${(0, import_core3.quoteIdent)(options.orderBy)} ${dir}`));
|
|
1164
1354
|
}
|
|
1165
1355
|
if (options?.limit !== void 0) {
|
|
1166
1356
|
parts.push(import_drizzle_orm.sql` LIMIT ${options.limit}`);
|
|
@@ -1176,7 +1366,7 @@ var PostgresServerStore = class {
|
|
|
1176
1366
|
conditions.push(import_drizzle_orm.sql.raw("_deleted = 0"));
|
|
1177
1367
|
}
|
|
1178
1368
|
for (const [key, value] of Object.entries(where)) {
|
|
1179
|
-
conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw(key)} = ${value}`);
|
|
1369
|
+
conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(key))} = ${value}`);
|
|
1180
1370
|
}
|
|
1181
1371
|
if (conditions.length === 0) {
|
|
1182
1372
|
return import_drizzle_orm.sql.raw("1 = 1");
|
|
@@ -1249,44 +1439,92 @@ var PostgresServerStore = class {
|
|
|
1249
1439
|
}
|
|
1250
1440
|
}
|
|
1251
1441
|
async ensureTables() {
|
|
1252
|
-
await this.db.
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1442
|
+
await this.db.transaction(async (tx) => {
|
|
1443
|
+
await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended('kora:ensure-tables', 0))`);
|
|
1444
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1445
|
+
CREATE TABLE IF NOT EXISTS operations (
|
|
1446
|
+
id TEXT PRIMARY KEY,
|
|
1447
|
+
node_id TEXT NOT NULL,
|
|
1448
|
+
type TEXT NOT NULL,
|
|
1449
|
+
collection TEXT NOT NULL,
|
|
1450
|
+
record_id TEXT NOT NULL,
|
|
1451
|
+
data TEXT,
|
|
1452
|
+
previous_data TEXT,
|
|
1453
|
+
atomic_ops TEXT,
|
|
1454
|
+
wall_time BIGINT NOT NULL,
|
|
1455
|
+
logical INTEGER NOT NULL,
|
|
1456
|
+
timestamp_node_id TEXT NOT NULL,
|
|
1457
|
+
sequence_number INTEGER NOT NULL,
|
|
1458
|
+
causal_deps TEXT NOT NULL DEFAULT '[]',
|
|
1459
|
+
schema_version INTEGER NOT NULL,
|
|
1460
|
+
received_at BIGINT NOT NULL
|
|
1461
|
+
)
|
|
1462
|
+
`);
|
|
1463
|
+
await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS atomic_ops TEXT`);
|
|
1464
|
+
await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS delivery_seq BIGINT`);
|
|
1465
|
+
await tx.execute(
|
|
1466
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
|
|
1467
|
+
);
|
|
1468
|
+
await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
|
|
1469
|
+
await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
|
|
1470
|
+
await tx.execute(
|
|
1471
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)`
|
|
1472
|
+
);
|
|
1473
|
+
await tx.execute(
|
|
1474
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
|
|
1475
|
+
);
|
|
1476
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1477
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
1478
|
+
node_id TEXT PRIMARY KEY,
|
|
1479
|
+
max_sequence_number INTEGER NOT NULL,
|
|
1480
|
+
last_seen_at BIGINT NOT NULL
|
|
1481
|
+
)
|
|
1482
|
+
`);
|
|
1483
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1484
|
+
CREATE TABLE IF NOT EXISTS delivery_counter (
|
|
1485
|
+
id INTEGER PRIMARY KEY,
|
|
1486
|
+
value BIGINT NOT NULL
|
|
1487
|
+
)
|
|
1488
|
+
`);
|
|
1489
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1490
|
+
WITH ordered AS (
|
|
1491
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
1492
|
+
ORDER BY received_at ASC, sequence_number ASC, id ASC
|
|
1493
|
+
) + COALESCE((SELECT MAX(delivery_seq) FROM operations), 0) AS rn
|
|
1494
|
+
FROM operations WHERE delivery_seq IS NULL
|
|
1495
|
+
)
|
|
1496
|
+
UPDATE operations o SET delivery_seq = ordered.rn
|
|
1497
|
+
FROM ordered WHERE o.id = ordered.id
|
|
1498
|
+
`);
|
|
1499
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1500
|
+
INSERT INTO delivery_counter (id, value)
|
|
1501
|
+
VALUES (1, COALESCE((SELECT MAX(delivery_seq) FROM operations), 0))
|
|
1502
|
+
ON CONFLICT (id) DO UPDATE
|
|
1503
|
+
SET value = GREATEST(delivery_counter.value, EXCLUDED.value)
|
|
1504
|
+
`);
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Reserve the next delivery sequence inside an open append transaction. The
|
|
1509
|
+
* `UPDATE ... RETURNING` locks the counter row until this transaction commits,
|
|
1510
|
+
* serializing assignment into commit order (see the counter table comment).
|
|
1511
|
+
*/
|
|
1512
|
+
async nextDeliverySeq(tx) {
|
|
1513
|
+
const rows = await tx.execute(
|
|
1514
|
+
import_drizzle_orm.sql`UPDATE delivery_counter SET value = value + 1 WHERE id = 1 RETURNING value`
|
|
1277
1515
|
);
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1516
|
+
const value = rows[0]?.value;
|
|
1517
|
+
if (value === void 0 || value === null) {
|
|
1518
|
+
throw new Error(
|
|
1519
|
+
"delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
return Number(value);
|
|
1285
1523
|
}
|
|
1286
1524
|
// ---------------------------------------------------------------------------
|
|
1287
1525
|
// Operation serialization
|
|
1288
1526
|
// ---------------------------------------------------------------------------
|
|
1289
|
-
serializeOperation(op, receivedAt) {
|
|
1527
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1290
1528
|
return {
|
|
1291
1529
|
id: op.id,
|
|
1292
1530
|
nodeId: op.nodeId,
|
|
@@ -1295,16 +1533,19 @@ var PostgresServerStore = class {
|
|
|
1295
1533
|
recordId: op.recordId,
|
|
1296
1534
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1297
1535
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
1536
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1298
1537
|
wallTime: op.timestamp.wallTime,
|
|
1299
1538
|
logical: op.timestamp.logical,
|
|
1300
1539
|
timestampNodeId: op.timestamp.nodeId,
|
|
1301
1540
|
sequenceNumber: op.sequenceNumber,
|
|
1302
1541
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1303
1542
|
schemaVersion: op.schemaVersion,
|
|
1304
|
-
receivedAt
|
|
1543
|
+
receivedAt,
|
|
1544
|
+
deliverySeq
|
|
1305
1545
|
};
|
|
1306
1546
|
}
|
|
1307
1547
|
deserializeOperation(row) {
|
|
1548
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1308
1549
|
return {
|
|
1309
1550
|
id: row.id,
|
|
1310
1551
|
nodeId: row.nodeId,
|
|
@@ -1320,7 +1561,8 @@ var PostgresServerStore = class {
|
|
|
1320
1561
|
},
|
|
1321
1562
|
sequenceNumber: row.sequenceNumber,
|
|
1322
1563
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1323
|
-
schemaVersion: row.schemaVersion
|
|
1564
|
+
schemaVersion: row.schemaVersion,
|
|
1565
|
+
...atomicOps ? { atomicOps } : {}
|
|
1324
1566
|
};
|
|
1325
1567
|
}
|
|
1326
1568
|
// ---------------------------------------------------------------------------
|
|
@@ -1390,6 +1632,8 @@ var operations = (0, import_sqlite_core.sqliteTable)(
|
|
|
1390
1632
|
// JSON-serialized, null for deletes
|
|
1391
1633
|
previousData: (0, import_sqlite_core.text)("previous_data"),
|
|
1392
1634
|
// JSON-serialized, null for insert/delete
|
|
1635
|
+
atomicOps: (0, import_sqlite_core.text)("atomic_ops"),
|
|
1636
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
1393
1637
|
wallTime: (0, import_sqlite_core.integer)("wall_time").notNull(),
|
|
1394
1638
|
logical: (0, import_sqlite_core.integer)("logical").notNull(),
|
|
1395
1639
|
timestampNodeId: (0, import_sqlite_core.text)("timestamp_node_id").notNull(),
|
|
@@ -1397,12 +1641,17 @@ var operations = (0, import_sqlite_core.sqliteTable)(
|
|
|
1397
1641
|
causalDeps: (0, import_sqlite_core.text)("causal_deps").notNull().default("[]"),
|
|
1398
1642
|
// JSON array of op IDs
|
|
1399
1643
|
schemaVersion: (0, import_sqlite_core.integer)("schema_version").notNull(),
|
|
1400
|
-
receivedAt: (0, import_sqlite_core.integer)("received_at").notNull()
|
|
1644
|
+
receivedAt: (0, import_sqlite_core.integer)("received_at").notNull(),
|
|
1645
|
+
// Server-assigned monotonic delivery sequence, ordered by commit. Drives the
|
|
1646
|
+
// gap-free server->client delivery watermark. Nullable only for rows written
|
|
1647
|
+
// before the column existed; those are backfilled on startup.
|
|
1648
|
+
deliverySeq: (0, import_sqlite_core.integer)("delivery_seq")
|
|
1401
1649
|
},
|
|
1402
1650
|
(table) => ({
|
|
1403
1651
|
nodeSeqIdx: (0, import_sqlite_core.index)("idx_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
1404
1652
|
collectionIdx: (0, import_sqlite_core.index)("idx_collection").on(table.collection),
|
|
1405
|
-
receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt)
|
|
1653
|
+
receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt),
|
|
1654
|
+
deliveryIdx: (0, import_sqlite_core.index)("idx_delivery_seq").on(table.deliverySeq)
|
|
1406
1655
|
})
|
|
1407
1656
|
);
|
|
1408
1657
|
var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
|
|
@@ -1410,6 +1659,10 @@ var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
|
|
|
1410
1659
|
maxSequenceNumber: (0, import_sqlite_core.integer)("max_sequence_number").notNull(),
|
|
1411
1660
|
lastSeenAt: (0, import_sqlite_core.integer)("last_seen_at").notNull()
|
|
1412
1661
|
});
|
|
1662
|
+
var deliveryCounter = (0, import_sqlite_core.sqliteTable)("delivery_counter", {
|
|
1663
|
+
id: (0, import_sqlite_core.integer)("id").primaryKey(),
|
|
1664
|
+
value: (0, import_sqlite_core.integer)("value").notNull()
|
|
1665
|
+
});
|
|
1413
1666
|
|
|
1414
1667
|
// src/store/sqlite-server-store.ts
|
|
1415
1668
|
var esmRequire = (0, import_node_module.createRequire)(importMetaUrl);
|
|
@@ -1463,12 +1716,15 @@ var SqliteServerStore = class {
|
|
|
1463
1716
|
async applyRemoteOperation(op) {
|
|
1464
1717
|
this.assertOpen();
|
|
1465
1718
|
const now = Date.now();
|
|
1466
|
-
const row = this.serializeOperation(op, now);
|
|
1467
1719
|
const result = this.db.transaction((tx) => {
|
|
1468
|
-
const
|
|
1469
|
-
|
|
1720
|
+
const existing = tx.all(
|
|
1721
|
+
import_drizzle_orm2.sql`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
|
|
1722
|
+
);
|
|
1723
|
+
if (existing.length > 0) {
|
|
1470
1724
|
return "duplicate";
|
|
1471
1725
|
}
|
|
1726
|
+
const row = this.serializeOperation(op, now, this.nextDeliverySeq(tx));
|
|
1727
|
+
tx.insert(operations).values(row).run();
|
|
1472
1728
|
tx.insert(syncState).values({
|
|
1473
1729
|
nodeId: op.nodeId,
|
|
1474
1730
|
maxSequenceNumber: op.sequenceNumber,
|
|
@@ -1497,6 +1753,21 @@ var SqliteServerStore = class {
|
|
|
1497
1753
|
const result = this.db.select({ value: (0, import_drizzle_orm2.count)() }).from(operations).all();
|
|
1498
1754
|
return result[0]?.value ?? 0;
|
|
1499
1755
|
}
|
|
1756
|
+
async getMaxDeliverySequence() {
|
|
1757
|
+
this.assertOpen();
|
|
1758
|
+
const rows = this.db.all(
|
|
1759
|
+
import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
1760
|
+
);
|
|
1761
|
+
return rows[0]?.m ?? 0;
|
|
1762
|
+
}
|
|
1763
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
1764
|
+
this.assertOpen();
|
|
1765
|
+
const rows = this.db.select().from(operations).where((0, import_drizzle_orm2.gt)(operations.deliverySeq, afterDeliverySequence)).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).limit(limit).all();
|
|
1766
|
+
return rows.map((row) => ({
|
|
1767
|
+
operation: this.deserializeOperation(row),
|
|
1768
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
1769
|
+
}));
|
|
1770
|
+
}
|
|
1500
1771
|
async materializeCollection(collection) {
|
|
1501
1772
|
this.assertOpen();
|
|
1502
1773
|
if (this.schema?.collections[collection]) {
|
|
@@ -1528,7 +1799,7 @@ var SqliteServerStore = class {
|
|
|
1528
1799
|
this.assertCollection(collection);
|
|
1529
1800
|
const schema = this.schema;
|
|
1530
1801
|
const collectionDef = schema.collections[collection];
|
|
1531
|
-
const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
|
|
1802
|
+
const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
|
|
1532
1803
|
const rows = this.db.all(query);
|
|
1533
1804
|
if (rows.length === 0) return null;
|
|
1534
1805
|
return this.deserializeRow(rows[0], collectionDef);
|
|
@@ -1544,7 +1815,7 @@ var SqliteServerStore = class {
|
|
|
1544
1815
|
}
|
|
1545
1816
|
}
|
|
1546
1817
|
const whereClause = this.buildWhereClause(where ?? {}, false);
|
|
1547
|
-
const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`;
|
|
1818
|
+
const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`;
|
|
1548
1819
|
const rows = this.db.all(query);
|
|
1549
1820
|
return rows[0]?.cnt ?? 0;
|
|
1550
1821
|
}
|
|
@@ -1554,7 +1825,7 @@ var SqliteServerStore = class {
|
|
|
1554
1825
|
async exportBackup() {
|
|
1555
1826
|
this.assertOpen();
|
|
1556
1827
|
const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
1557
|
-
const rows = this.db.select().from(operations).all();
|
|
1828
|
+
const rows = this.db.select().from(operations).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).all();
|
|
1558
1829
|
const deserialized = rows.map((row) => this.deserializeOperation(row));
|
|
1559
1830
|
const vv = this.getVersionVector();
|
|
1560
1831
|
return buildServerBackup2(this.nodeId, deserialized, vv);
|
|
@@ -1574,13 +1845,17 @@ var SqliteServerStore = class {
|
|
|
1574
1845
|
this.db.transaction((tx) => {
|
|
1575
1846
|
tx.run(import_drizzle_orm2.sql.raw("DELETE FROM operations"));
|
|
1576
1847
|
tx.run(import_drizzle_orm2.sql.raw("DELETE FROM sync_state"));
|
|
1848
|
+
tx.update(deliveryCounter).set({ value: 0 }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).run();
|
|
1577
1849
|
for (const [nid, seq] of versionVector) {
|
|
1578
1850
|
tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
|
|
1579
1851
|
}
|
|
1852
|
+
let deliverySeq = 0;
|
|
1580
1853
|
for (const op of ops) {
|
|
1581
|
-
|
|
1854
|
+
deliverySeq += 1;
|
|
1855
|
+
const row = this.serializeOperation(op, Date.now(), deliverySeq);
|
|
1582
1856
|
tx.insert(operations).values(row).run();
|
|
1583
1857
|
}
|
|
1858
|
+
tx.update(deliveryCounter).set({ value: deliverySeq }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).run();
|
|
1584
1859
|
});
|
|
1585
1860
|
return { operationsRestored: ops.length, success: true };
|
|
1586
1861
|
}
|
|
@@ -1598,13 +1873,15 @@ var SqliteServerStore = class {
|
|
|
1598
1873
|
const ops = txOrDb.select({
|
|
1599
1874
|
type: operations.type,
|
|
1600
1875
|
data: operations.data,
|
|
1876
|
+
atomicOps: operations.atomicOps,
|
|
1601
1877
|
wallTime: operations.wallTime
|
|
1602
|
-
}).from(operations).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(operations.collection, collection), (0, import_drizzle_orm2.eq)(operations.recordId, recordId))).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.
|
|
1878
|
+
}).from(operations).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(operations.collection, collection), (0, import_drizzle_orm2.eq)(operations.recordId, recordId))).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.timestampNodeId)).all();
|
|
1603
1879
|
const parsedOps = ops.map((op) => ({
|
|
1604
1880
|
type: op.type,
|
|
1605
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1881
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1882
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1606
1883
|
}));
|
|
1607
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1884
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1608
1885
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
1609
1886
|
if (recordData) {
|
|
1610
1887
|
const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
|
|
@@ -1621,7 +1898,7 @@ var SqliteServerStore = class {
|
|
|
1621
1898
|
);
|
|
1622
1899
|
} else {
|
|
1623
1900
|
txOrDb.run(
|
|
1624
|
-
import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1901
|
+
import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1625
1902
|
);
|
|
1626
1903
|
}
|
|
1627
1904
|
}
|
|
@@ -1642,16 +1919,16 @@ var SqliteServerStore = class {
|
|
|
1642
1919
|
0
|
|
1643
1920
|
// _deleted = false
|
|
1644
1921
|
];
|
|
1645
|
-
const columnsSql = import_drizzle_orm2.sql.raw(allColumns.join(", "));
|
|
1922
|
+
const columnsSql = import_drizzle_orm2.sql.raw(allColumns.map((c) => (0, import_core4.quoteIdent)(c)).join(", "));
|
|
1646
1923
|
const valuesSql = import_drizzle_orm2.sql.join(
|
|
1647
1924
|
values.map((v) => import_drizzle_orm2.sql`${v}`),
|
|
1648
1925
|
import_drizzle_orm2.sql.raw(", ")
|
|
1649
1926
|
);
|
|
1650
1927
|
const updateSet = import_drizzle_orm2.sql.raw(
|
|
1651
|
-
allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
|
|
1928
|
+
allColumns.slice(1).map((c) => `${(0, import_core4.quoteIdent)(c)} = excluded.${(0, import_core4.quoteIdent)(c)}`).join(", ")
|
|
1652
1929
|
);
|
|
1653
1930
|
txOrDb.run(
|
|
1654
|
-
import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1931
|
+
import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1655
1932
|
);
|
|
1656
1933
|
}
|
|
1657
1934
|
/**
|
|
@@ -1674,8 +1951,9 @@ var SqliteServerStore = class {
|
|
|
1674
1951
|
recordId: operations.recordId,
|
|
1675
1952
|
type: operations.type,
|
|
1676
1953
|
data: operations.data,
|
|
1954
|
+
atomicOps: operations.atomicOps,
|
|
1677
1955
|
wallTime: operations.wallTime
|
|
1678
|
-
}).from(operations).where((0, import_drizzle_orm2.eq)(operations.collection, collectionName)).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.
|
|
1956
|
+
}).from(operations).where((0, import_drizzle_orm2.eq)(operations.collection, collectionName)).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.timestampNodeId)).all();
|
|
1679
1957
|
if (allOps.length === 0) return;
|
|
1680
1958
|
const grouped = /* @__PURE__ */ new Map();
|
|
1681
1959
|
for (const op of allOps) {
|
|
@@ -1691,9 +1969,10 @@ var SqliteServerStore = class {
|
|
|
1691
1969
|
for (const [recordId, recordOps] of grouped) {
|
|
1692
1970
|
const parsedOps = recordOps.map((op) => ({
|
|
1693
1971
|
type: op.type,
|
|
1694
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1972
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1973
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1695
1974
|
}));
|
|
1696
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1975
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1697
1976
|
if (recordData) {
|
|
1698
1977
|
const createdAt = recordOps[0].wallTime;
|
|
1699
1978
|
const updatedAt = recordOps[recordOps.length - 1].wallTime;
|
|
@@ -1709,7 +1988,7 @@ var SqliteServerStore = class {
|
|
|
1709
1988
|
);
|
|
1710
1989
|
} else {
|
|
1711
1990
|
tx.run(
|
|
1712
|
-
import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
|
|
1991
|
+
import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
|
|
1713
1992
|
);
|
|
1714
1993
|
}
|
|
1715
1994
|
}
|
|
@@ -1723,10 +2002,12 @@ var SqliteServerStore = class {
|
|
|
1723
2002
|
options?.where ?? {},
|
|
1724
2003
|
options?.includeDeleted ?? false
|
|
1725
2004
|
);
|
|
1726
|
-
const parts = [
|
|
2005
|
+
const parts = [
|
|
2006
|
+
import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`
|
|
2007
|
+
];
|
|
1727
2008
|
if (options?.orderBy) {
|
|
1728
2009
|
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
1729
|
-
parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
2010
|
+
parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${(0, import_core4.quoteIdent)(options.orderBy)} ${dir}`));
|
|
1730
2011
|
}
|
|
1731
2012
|
if (options?.limit !== void 0) {
|
|
1732
2013
|
parts.push(import_drizzle_orm2.sql` LIMIT ${options.limit}`);
|
|
@@ -1742,7 +2023,7 @@ var SqliteServerStore = class {
|
|
|
1742
2023
|
conditions.push(import_drizzle_orm2.sql.raw("_deleted = 0"));
|
|
1743
2024
|
}
|
|
1744
2025
|
for (const [key, value] of Object.entries(where)) {
|
|
1745
|
-
conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw(key)} = ${value}`);
|
|
2026
|
+
conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(key))} = ${value}`);
|
|
1746
2027
|
}
|
|
1747
2028
|
if (conditions.length === 0) {
|
|
1748
2029
|
return import_drizzle_orm2.sql.raw("1 = 1");
|
|
@@ -1813,6 +2094,7 @@ var SqliteServerStore = class {
|
|
|
1813
2094
|
record_id TEXT NOT NULL,
|
|
1814
2095
|
data TEXT,
|
|
1815
2096
|
previous_data TEXT,
|
|
2097
|
+
atomic_ops TEXT,
|
|
1816
2098
|
wall_time INTEGER NOT NULL,
|
|
1817
2099
|
logical INTEGER NOT NULL,
|
|
1818
2100
|
timestamp_node_id TEXT NOT NULL,
|
|
@@ -1822,9 +2104,35 @@ var SqliteServerStore = class {
|
|
|
1822
2104
|
received_at INTEGER NOT NULL
|
|
1823
2105
|
)
|
|
1824
2106
|
`);
|
|
2107
|
+
try {
|
|
2108
|
+
this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
|
|
2109
|
+
} catch (e) {
|
|
2110
|
+
const msg = e instanceof Error ? e.message : "";
|
|
2111
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
2112
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
2113
|
+
throw e;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
try {
|
|
2117
|
+
this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
|
|
2118
|
+
} catch (e) {
|
|
2119
|
+
const msg = e instanceof Error ? e.message : "";
|
|
2120
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
2121
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
2122
|
+
throw e;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
1825
2125
|
this.db.run(import_drizzle_orm2.sql`
|
|
1826
2126
|
CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
|
|
1827
2127
|
`);
|
|
2128
|
+
this.db.run(import_drizzle_orm2.sql`
|
|
2129
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
|
|
2130
|
+
`);
|
|
2131
|
+
this.db.run(import_drizzle_orm2.sql`
|
|
2132
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_seq_unique
|
|
2133
|
+
ON operations (delivery_seq)
|
|
2134
|
+
WHERE delivery_seq IS NOT NULL
|
|
2135
|
+
`);
|
|
1828
2136
|
this.db.run(import_drizzle_orm2.sql`
|
|
1829
2137
|
CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
|
|
1830
2138
|
`);
|
|
@@ -1841,11 +2149,60 @@ var SqliteServerStore = class {
|
|
|
1841
2149
|
last_seen_at INTEGER NOT NULL
|
|
1842
2150
|
)
|
|
1843
2151
|
`);
|
|
2152
|
+
this.db.run(import_drizzle_orm2.sql`
|
|
2153
|
+
CREATE TABLE IF NOT EXISTS delivery_counter (
|
|
2154
|
+
id INTEGER PRIMARY KEY,
|
|
2155
|
+
value INTEGER NOT NULL
|
|
2156
|
+
)
|
|
2157
|
+
`);
|
|
2158
|
+
this.backfillDeliverySequence();
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Assign delivery sequences to any operations written before the column existed,
|
|
2162
|
+
* then seed the durable counter from the current max. Ordered by
|
|
2163
|
+
* (received_at, sequence_number, id) so the backfill is deterministic and stable
|
|
2164
|
+
* across restarts. A one-time migration: after it runs, all rows have a value.
|
|
2165
|
+
*/
|
|
2166
|
+
backfillDeliverySequence() {
|
|
2167
|
+
this.db.transaction((tx) => {
|
|
2168
|
+
const maxRow = tx.all(
|
|
2169
|
+
import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
2170
|
+
);
|
|
2171
|
+
let next = maxRow[0]?.m ?? 0;
|
|
2172
|
+
const nullRows = tx.all(
|
|
2173
|
+
import_drizzle_orm2.sql`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
|
|
2174
|
+
);
|
|
2175
|
+
for (const row of nullRows) {
|
|
2176
|
+
next += 1;
|
|
2177
|
+
tx.run(import_drizzle_orm2.sql`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
|
|
2178
|
+
}
|
|
2179
|
+
tx.insert(deliveryCounter).values({ id: 1, value: next }).onConflictDoUpdate({
|
|
2180
|
+
target: deliveryCounter.id,
|
|
2181
|
+
set: {
|
|
2182
|
+
value: import_drizzle_orm2.sql`MAX(${deliveryCounter.value}, ${next})`
|
|
2183
|
+
}
|
|
2184
|
+
}).run();
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Reserve the next delivery sequence inside the append transaction. SQLite locks
|
|
2189
|
+
* the database for the write transaction, and this counter row keeps sequence
|
|
2190
|
+
* allocation shared by every SqliteServerStore instance using the same file.
|
|
2191
|
+
*/
|
|
2192
|
+
nextDeliverySeq(tx) {
|
|
2193
|
+
const rows = tx.update(deliveryCounter).set({ value: import_drizzle_orm2.sql`${deliveryCounter.value} + 1` }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).returning({ value: deliveryCounter.value }).all();
|
|
2194
|
+
const value = rows[0]?.value;
|
|
2195
|
+
if (value === void 0 || value === null) {
|
|
2196
|
+
throw new Error(
|
|
2197
|
+
"delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2200
|
+
return value;
|
|
1844
2201
|
}
|
|
1845
2202
|
// ---------------------------------------------------------------------------
|
|
1846
2203
|
// Operation serialization
|
|
1847
2204
|
// ---------------------------------------------------------------------------
|
|
1848
|
-
serializeOperation(op, receivedAt) {
|
|
2205
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1849
2206
|
return {
|
|
1850
2207
|
id: op.id,
|
|
1851
2208
|
nodeId: op.nodeId,
|
|
@@ -1854,16 +2211,19 @@ var SqliteServerStore = class {
|
|
|
1854
2211
|
recordId: op.recordId,
|
|
1855
2212
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1856
2213
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
2214
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1857
2215
|
wallTime: op.timestamp.wallTime,
|
|
1858
2216
|
logical: op.timestamp.logical,
|
|
1859
2217
|
timestampNodeId: op.timestamp.nodeId,
|
|
1860
2218
|
sequenceNumber: op.sequenceNumber,
|
|
1861
2219
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1862
2220
|
schemaVersion: op.schemaVersion,
|
|
1863
|
-
receivedAt
|
|
2221
|
+
receivedAt,
|
|
2222
|
+
deliverySeq
|
|
1864
2223
|
};
|
|
1865
2224
|
}
|
|
1866
2225
|
deserializeOperation(row) {
|
|
2226
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1867
2227
|
return {
|
|
1868
2228
|
id: row.id,
|
|
1869
2229
|
nodeId: row.nodeId,
|
|
@@ -1879,7 +2239,8 @@ var SqliteServerStore = class {
|
|
|
1879
2239
|
},
|
|
1880
2240
|
sequenceNumber: row.sequenceNumber,
|
|
1881
2241
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1882
|
-
schemaVersion: row.schemaVersion
|
|
2242
|
+
schemaVersion: row.schemaVersion,
|
|
2243
|
+
...atomicOps ? { atomicOps } : {}
|
|
1883
2244
|
};
|
|
1884
2245
|
}
|
|
1885
2246
|
// ---------------------------------------------------------------------------
|
|
@@ -2065,6 +2426,7 @@ var WsServerTransport = class {
|
|
|
2065
2426
|
// src/session/client-session.ts
|
|
2066
2427
|
init_cjs_shims();
|
|
2067
2428
|
var import_core10 = require("@korajs/core");
|
|
2429
|
+
var import_core11 = require("@korajs/core");
|
|
2068
2430
|
var import_internal = require("@korajs/core/internal");
|
|
2069
2431
|
var import_sync2 = require("@korajs/sync");
|
|
2070
2432
|
var import_sync3 = require("@korajs/sync");
|
|
@@ -2178,6 +2540,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
|
|
|
2178
2540
|
);
|
|
2179
2541
|
}
|
|
2180
2542
|
function nextServerSequenceNumber(store) {
|
|
2543
|
+
if (store.reserveSequenceNumber) {
|
|
2544
|
+
return store.reserveSequenceNumber();
|
|
2545
|
+
}
|
|
2181
2546
|
const nodeId = store.getNodeId();
|
|
2182
2547
|
const current = store.getVersionVector().get(nodeId) ?? 0;
|
|
2183
2548
|
return current + 1;
|
|
@@ -2187,14 +2552,28 @@ function nextServerSequenceNumber(store) {
|
|
|
2187
2552
|
async function applyServerOperation(store, op, relationLookup) {
|
|
2188
2553
|
const schema = store.getSchema();
|
|
2189
2554
|
const lookup = relationLookup ?? (schema ? (0, import_merge2.buildMergeRelationLookup)(schema) : /* @__PURE__ */ new Map());
|
|
2555
|
+
const shapeCheck = validateOperationShape(op, schema);
|
|
2556
|
+
if (!shapeCheck.valid) {
|
|
2557
|
+
return {
|
|
2558
|
+
result: "skipped",
|
|
2559
|
+
appliedOperations: [],
|
|
2560
|
+
rejection: {
|
|
2561
|
+
code: shapeCheck.code ?? "SCHEMA_VALIDATION_ERROR",
|
|
2562
|
+
message: shapeCheck.message ?? `Operation "${op.id}" does not match the server schema`,
|
|
2563
|
+
retriable: false
|
|
2564
|
+
}
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2190
2567
|
const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
|
|
2191
2568
|
if (!constraintCheck.valid) {
|
|
2569
|
+
const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
|
|
2192
2570
|
return {
|
|
2193
2571
|
result: "skipped",
|
|
2194
2572
|
appliedOperations: [],
|
|
2195
2573
|
rejection: {
|
|
2196
|
-
code
|
|
2197
|
-
message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint
|
|
2574
|
+
code,
|
|
2575
|
+
message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`,
|
|
2576
|
+
retriable: isRetriableRejection(code)
|
|
2198
2577
|
}
|
|
2199
2578
|
};
|
|
2200
2579
|
}
|
|
@@ -2207,7 +2586,8 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2207
2586
|
appliedOperations: [],
|
|
2208
2587
|
rejection: {
|
|
2209
2588
|
code: "REFERENTIAL_INTEGRITY",
|
|
2210
|
-
message: `Operation "${op.id}" violates referential integrity on "${op.collection}"
|
|
2589
|
+
message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`,
|
|
2590
|
+
retriable: isRetriableRejection("REFERENTIAL_INTEGRITY")
|
|
2211
2591
|
}
|
|
2212
2592
|
};
|
|
2213
2593
|
}
|
|
@@ -2216,16 +2596,14 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2216
2596
|
return { result: primaryResult, appliedOperations: [] };
|
|
2217
2597
|
}
|
|
2218
2598
|
const appliedOperations = [op];
|
|
2219
|
-
let serverSeq = nextServerSequenceNumber(store);
|
|
2220
2599
|
for (const effect of referential.sideEffectOps) {
|
|
2221
2600
|
const sideOp = await createServerSideEffectOperation(
|
|
2222
2601
|
store,
|
|
2223
2602
|
op,
|
|
2224
2603
|
effect,
|
|
2225
2604
|
op.schemaVersion,
|
|
2226
|
-
|
|
2605
|
+
nextServerSequenceNumber(store)
|
|
2227
2606
|
);
|
|
2228
|
-
serverSeq += 1;
|
|
2229
2607
|
const sideResult = await store.applyRemoteOperation(sideOp);
|
|
2230
2608
|
if (sideResult === "applied") {
|
|
2231
2609
|
appliedOperations.push(sideOp);
|
|
@@ -2239,6 +2617,67 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2239
2617
|
appliedOperations: result === "applied" ? [op] : []
|
|
2240
2618
|
};
|
|
2241
2619
|
}
|
|
2620
|
+
function validateOperationShape(op, schema) {
|
|
2621
|
+
if (!schema) {
|
|
2622
|
+
return { valid: true };
|
|
2623
|
+
}
|
|
2624
|
+
const collection = schema.collections[op.collection];
|
|
2625
|
+
if (!collection) {
|
|
2626
|
+
return {
|
|
2627
|
+
valid: false,
|
|
2628
|
+
code: "UNKNOWN_COLLECTION",
|
|
2629
|
+
message: `Operation "${op.id}" targets unknown collection "${op.collection}".`
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
const declaredFields = new Set(Object.keys(collection.fields));
|
|
2633
|
+
const systemPreviousFields = /* @__PURE__ */ new Set(["id", "_created_at", "_updated_at", "_deleted"]);
|
|
2634
|
+
const invalidDataField = firstInvalidField(op.data, declaredFields);
|
|
2635
|
+
if (invalidDataField) {
|
|
2636
|
+
return {
|
|
2637
|
+
valid: false,
|
|
2638
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2639
|
+
message: `Operation "${op.id}" contains undeclared field "${invalidDataField}" in data for collection "${op.collection}".`
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
const invalidPreviousField = firstInvalidField(
|
|
2643
|
+
op.previousData,
|
|
2644
|
+
declaredFields,
|
|
2645
|
+
systemPreviousFields
|
|
2646
|
+
);
|
|
2647
|
+
if (invalidPreviousField) {
|
|
2648
|
+
return {
|
|
2649
|
+
valid: false,
|
|
2650
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2651
|
+
message: `Operation "${op.id}" contains undeclared field "${invalidPreviousField}" in previousData for collection "${op.collection}".`
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
if (op.atomicOps) {
|
|
2655
|
+
for (const field of Object.keys(op.atomicOps)) {
|
|
2656
|
+
if (!declaredFields.has(field)) {
|
|
2657
|
+
return {
|
|
2658
|
+
valid: false,
|
|
2659
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2660
|
+
message: `Operation "${op.id}" contains undeclared atomic field "${field}" for collection "${op.collection}".`
|
|
2661
|
+
};
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
return { valid: true };
|
|
2666
|
+
}
|
|
2667
|
+
function firstInvalidField(value, declaredFields, extraAllowedFields) {
|
|
2668
|
+
if (value === null || value === void 0) {
|
|
2669
|
+
return null;
|
|
2670
|
+
}
|
|
2671
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
2672
|
+
return null;
|
|
2673
|
+
}
|
|
2674
|
+
for (const field of Object.keys(value)) {
|
|
2675
|
+
if (!declaredFields.has(field) && !extraAllowedFields?.has(field)) {
|
|
2676
|
+
return field;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
return null;
|
|
2680
|
+
}
|
|
2242
2681
|
|
|
2243
2682
|
// src/auth/no-auth.ts
|
|
2244
2683
|
init_cjs_shims();
|
|
@@ -2286,11 +2725,12 @@ function mergeScopeMaps(base, overlay, source) {
|
|
|
2286
2725
|
|
|
2287
2726
|
// src/scopes/server-scope-filter.ts
|
|
2288
2727
|
init_cjs_shims();
|
|
2289
|
-
function operationMatchesScopes(op, scopes) {
|
|
2728
|
+
function operationMatchesScopes(op, scopes, fullRecord) {
|
|
2290
2729
|
if (!scopes) return true;
|
|
2291
2730
|
const collectionScope = scopes[op.collection];
|
|
2292
2731
|
if (!collectionScope) return false;
|
|
2293
|
-
|
|
2732
|
+
if (Object.keys(collectionScope).length === 0) return true;
|
|
2733
|
+
const snapshot = buildSnapshot(op, fullRecord);
|
|
2294
2734
|
if (!snapshot) return false;
|
|
2295
2735
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
2296
2736
|
if (snapshot[field] !== expected) {
|
|
@@ -2299,11 +2739,20 @@ function operationMatchesScopes(op, scopes) {
|
|
|
2299
2739
|
}
|
|
2300
2740
|
return true;
|
|
2301
2741
|
}
|
|
2302
|
-
function
|
|
2742
|
+
function missingScopeFields(op, scopes) {
|
|
2743
|
+
if (!scopes) return [];
|
|
2744
|
+
const collectionScope = scopes[op.collection];
|
|
2745
|
+
if (!collectionScope) return [];
|
|
2746
|
+
if (Object.keys(collectionScope).length === 0) return [];
|
|
2747
|
+
const snapshot = buildSnapshot(op);
|
|
2748
|
+
return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
|
|
2749
|
+
}
|
|
2750
|
+
function buildSnapshot(op, fullRecord) {
|
|
2303
2751
|
const previous = asRecord(op.previousData);
|
|
2304
2752
|
const next = asRecord(op.data);
|
|
2305
|
-
if (!previous && !next) return null;
|
|
2753
|
+
if (!previous && !next && !fullRecord) return null;
|
|
2306
2754
|
return {
|
|
2755
|
+
...fullRecord ?? {},
|
|
2307
2756
|
...previous ?? {},
|
|
2308
2757
|
...next ?? {}
|
|
2309
2758
|
};
|
|
@@ -2396,21 +2845,73 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
|
|
|
2396
2845
|
"[kora] An authenticated session resolved to no sync scopes, so every user will sync every other user's data. Return per-user sync scopes from your auth provider (for example KoraAuthProvider's resolveScopes) to isolate tenants. Note: declaring sync rules in your schema is not enough on its own \u2014 the per-user values come from the auth provider. (This warning is expected for single-tenant apps where all authenticated users are meant to share the same data.)"
|
|
2397
2846
|
);
|
|
2398
2847
|
}
|
|
2399
|
-
|
|
2848
|
+
function stableStringify(value) {
|
|
2849
|
+
if (Array.isArray(value)) {
|
|
2850
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
2851
|
+
}
|
|
2852
|
+
if (value && typeof value === "object") {
|
|
2853
|
+
const record = value;
|
|
2854
|
+
const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
|
|
2855
|
+
return `{${entries.join(",")}}`;
|
|
2856
|
+
}
|
|
2857
|
+
return JSON.stringify(value);
|
|
2858
|
+
}
|
|
2859
|
+
function sameScopeMap(a, b) {
|
|
2860
|
+
return stableStringify(a ?? null) === stableStringify(b ?? null);
|
|
2861
|
+
}
|
|
2862
|
+
var ClientSession = class _ClientSession {
|
|
2400
2863
|
state = "connected";
|
|
2401
2864
|
clientNodeId = null;
|
|
2402
2865
|
authContext = null;
|
|
2403
2866
|
syncQuerySubsets = [];
|
|
2404
2867
|
resumeDeltaCursor = null;
|
|
2868
|
+
/**
|
|
2869
|
+
* The delivery watermark the client reported at handshake, or null when the client
|
|
2870
|
+
* does not use the delivery watermark (old client, or no watermark yet). When set,
|
|
2871
|
+
* the server drives the gap-free server->client stream from delivery sequences
|
|
2872
|
+
* instead of the version-vector delta.
|
|
2873
|
+
*/
|
|
2874
|
+
clientDeliveryWatermark = null;
|
|
2875
|
+
/**
|
|
2876
|
+
* The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
|
|
2877
|
+
* watermark, as reported in acks). Incremental streaming pushes resume from here, not
|
|
2878
|
+
* from the highest sequence sent, so a dropped or unapplied batch is always
|
|
2879
|
+
* re-included by the next push (which re-scans from the acknowledged position). This
|
|
2880
|
+
* is what makes streaming recovery independent of any bounded retransmit buffer: there
|
|
2881
|
+
* is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
|
|
2882
|
+
* client's reported watermark at handshake.
|
|
2883
|
+
*/
|
|
2884
|
+
lastAckedDeliverySeq = 0;
|
|
2885
|
+
/**
|
|
2886
|
+
* Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
|
|
2887
|
+
* this as its stale window so a slow client does not receive duplicate full-stream
|
|
2888
|
+
* batches every server tick while the first batch is still being applied.
|
|
2889
|
+
*/
|
|
2890
|
+
lastDeliveryPushAttemptAtMs = 0;
|
|
2891
|
+
deliveryStallRepeatCount = 0;
|
|
2892
|
+
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
2893
|
+
deliveryPushChain = Promise.resolve();
|
|
2894
|
+
/**
|
|
2895
|
+
* Relay batches sent to this client but not yet acknowledged, keyed by messageId.
|
|
2896
|
+
* The client acks every operation-batch it applies; on ack the entry is cleared.
|
|
2897
|
+
* {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
|
|
2898
|
+
* dropped by a lossy transport is redelivered instead of leaving the client with a
|
|
2899
|
+
* permanent version-vector gap (a lost operation) that delta sync cannot recover.
|
|
2900
|
+
* Bounded so a silent client cannot grow it without limit.
|
|
2901
|
+
*/
|
|
2902
|
+
pendingRelays = /* @__PURE__ */ new Map();
|
|
2903
|
+
static MAX_PENDING_RELAYS = 1e3;
|
|
2405
2904
|
sessionId;
|
|
2406
2905
|
transport;
|
|
2407
2906
|
store;
|
|
2408
2907
|
auth;
|
|
2409
2908
|
serializer;
|
|
2410
2909
|
emitter;
|
|
2910
|
+
logger;
|
|
2411
2911
|
batchSize;
|
|
2412
2912
|
schemaVersion;
|
|
2413
2913
|
supportedSchemaVersions;
|
|
2914
|
+
operationTransforms;
|
|
2414
2915
|
onRelay;
|
|
2415
2916
|
onAwarenessUpdate;
|
|
2416
2917
|
onYjsDocUpdate;
|
|
@@ -2418,9 +2919,13 @@ var ClientSession = class {
|
|
|
2418
2919
|
onBlobChunkResponse;
|
|
2419
2920
|
persistBlobChunk;
|
|
2420
2921
|
onClose;
|
|
2922
|
+
onOrphanedRelays;
|
|
2923
|
+
takeOrphanedRelays;
|
|
2421
2924
|
maxOperationBytes;
|
|
2422
2925
|
maxOpsPerMinute;
|
|
2423
2926
|
rateLimiter;
|
|
2927
|
+
validateOperation;
|
|
2928
|
+
koraContext;
|
|
2424
2929
|
constructor(options) {
|
|
2425
2930
|
this.sessionId = options.sessionId;
|
|
2426
2931
|
this.transport = options.transport;
|
|
@@ -2428,6 +2933,7 @@ var ClientSession = class {
|
|
|
2428
2933
|
this.auth = options.auth ?? null;
|
|
2429
2934
|
this.serializer = options.serializer ?? new import_sync3.NegotiatedMessageSerializer("json");
|
|
2430
2935
|
this.emitter = options.emitter ?? null;
|
|
2936
|
+
this.logger = options.logger ?? null;
|
|
2431
2937
|
this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2432
2938
|
this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
2433
2939
|
const supported = options.supportedSchemaVersions;
|
|
@@ -2435,6 +2941,7 @@ var ClientSession = class {
|
|
|
2435
2941
|
min: this.schemaVersion,
|
|
2436
2942
|
max: this.schemaVersion
|
|
2437
2943
|
};
|
|
2944
|
+
this.operationTransforms = options.operationTransforms ?? [];
|
|
2438
2945
|
this.onRelay = options.onRelay ?? null;
|
|
2439
2946
|
this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
|
|
2440
2947
|
this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
|
|
@@ -2442,9 +2949,13 @@ var ClientSession = class {
|
|
|
2442
2949
|
this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
|
|
2443
2950
|
this.persistBlobChunk = options.persistBlobChunk ?? null;
|
|
2444
2951
|
this.onClose = options.onClose ?? null;
|
|
2952
|
+
this.onOrphanedRelays = options.onOrphanedRelays ?? null;
|
|
2953
|
+
this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
|
|
2445
2954
|
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2446
2955
|
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
2447
2956
|
this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
|
|
2957
|
+
this.validateOperation = options.validateOperation ?? null;
|
|
2958
|
+
this.koraContext = options.koraContext ?? null;
|
|
2448
2959
|
}
|
|
2449
2960
|
/**
|
|
2450
2961
|
* Start handling messages from the client transport.
|
|
@@ -2464,25 +2975,129 @@ var ClientSession = class {
|
|
|
2464
2975
|
*/
|
|
2465
2976
|
relayOperations(operations2) {
|
|
2466
2977
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2978
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2979
|
+
this.pushDeliveryStream();
|
|
2980
|
+
return;
|
|
2981
|
+
}
|
|
2467
2982
|
if (operations2.length === 0) return;
|
|
2468
|
-
|
|
2983
|
+
void this.relayVisibleOperations(operations2);
|
|
2984
|
+
}
|
|
2985
|
+
/**
|
|
2986
|
+
* Push newly-available in-scope operations to a delivery-watermark client, resuming
|
|
2987
|
+
* from the highest delivery sequence already sent. Pushes are serialized so their
|
|
2988
|
+
* batches never interleave (which would break the base/max chain).
|
|
2989
|
+
*/
|
|
2990
|
+
pushDeliveryStream() {
|
|
2991
|
+
this.deliveryPushChain = this.deliveryPushChain.then(async () => {
|
|
2992
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2993
|
+
try {
|
|
2994
|
+
this.lastDeliveryPushAttemptAtMs = Date.now();
|
|
2995
|
+
await this.sendDeliveryStream(
|
|
2996
|
+
this.lastAckedDeliverySeq,
|
|
2997
|
+
false,
|
|
2998
|
+
this.clientNodeId ?? void 0
|
|
2999
|
+
);
|
|
3000
|
+
} catch {
|
|
3001
|
+
}
|
|
3002
|
+
});
|
|
3003
|
+
}
|
|
3004
|
+
async relayVisibleOperations(operations2) {
|
|
3005
|
+
const visibleOperations = [];
|
|
3006
|
+
for (const op of operations2) {
|
|
3007
|
+
if (await this.operationVisibleToClient(op)) {
|
|
3008
|
+
visibleOperations.push(op);
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
2469
3011
|
if (visibleOperations.length === 0) return;
|
|
3012
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2470
3013
|
const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
|
|
2471
3014
|
const msg = {
|
|
2472
3015
|
type: "operation-batch",
|
|
2473
|
-
messageId: (0,
|
|
3016
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2474
3017
|
operations: serializedOps,
|
|
2475
3018
|
isFinal: true,
|
|
2476
3019
|
batchIndex: 0
|
|
2477
3020
|
};
|
|
3021
|
+
this.trackPendingRelay(msg, visibleOperations);
|
|
2478
3022
|
this.sendToClient(msg);
|
|
2479
3023
|
}
|
|
3024
|
+
/** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
|
|
3025
|
+
trackPendingRelay(msg, ops) {
|
|
3026
|
+
if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
|
|
3027
|
+
const oldest = this.pendingRelays.keys().next().value;
|
|
3028
|
+
if (oldest !== void 0) {
|
|
3029
|
+
this.pendingRelays.delete(oldest);
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
|
|
3033
|
+
}
|
|
3034
|
+
/**
|
|
3035
|
+
* Retransmit relay batches this client has not acknowledged within `staleMs`.
|
|
3036
|
+
* Called on a periodic tick by the server (and directly by tests). Redelivering an
|
|
3037
|
+
* already-applied op is harmless: the client dedups by content-addressed id.
|
|
3038
|
+
*/
|
|
3039
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
3040
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
3041
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
if (this.pendingRelays.size === 0) return;
|
|
3045
|
+
const cutoff = Date.now() - staleMs;
|
|
3046
|
+
for (const { message, sentAtMs } of this.pendingRelays.values()) {
|
|
3047
|
+
if (sentAtMs <= cutoff) {
|
|
3048
|
+
this.sendToClient(message);
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* Wake the durable delivery stream for clients that negotiated delivery
|
|
3054
|
+
* watermarks. This is used both for dropped watermark batches and for operations
|
|
3055
|
+
* appended by another server/store instance: the session always scans from its
|
|
3056
|
+
* own acknowledged cursor and applies its visibility filter before sending.
|
|
3057
|
+
*/
|
|
3058
|
+
pushDeliveryStreamIfSupported(staleMs = 0, options = {}) {
|
|
3059
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
3060
|
+
if (this.clientDeliveryWatermark === null) return;
|
|
3061
|
+
if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
if (options.trackStall) {
|
|
3065
|
+
this.deliveryStallRepeatCount += 1;
|
|
3066
|
+
if (this.deliveryStallRepeatCount >= 3) {
|
|
3067
|
+
this.emitter?.emit({
|
|
3068
|
+
type: "sync:delivery-stalled",
|
|
3069
|
+
sessionId: this.sessionId,
|
|
3070
|
+
watermark: this.lastAckedDeliverySeq,
|
|
3071
|
+
repeatCount: this.deliveryStallRepeatCount
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
this.pushDeliveryStream();
|
|
3076
|
+
}
|
|
2480
3077
|
/**
|
|
2481
3078
|
* Close this session.
|
|
2482
3079
|
*/
|
|
3080
|
+
/**
|
|
3081
|
+
* Hand any unacknowledged relays to the server so they survive this session and are
|
|
3082
|
+
* redelivered when this client reconnects (per-node buffer), then clear them. Called
|
|
3083
|
+
* from every session-teardown path (explicit close and transport close).
|
|
3084
|
+
*/
|
|
3085
|
+
flushOrphanedRelays() {
|
|
3086
|
+
if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
|
|
3087
|
+
const ops = [];
|
|
3088
|
+
for (const entry of this.pendingRelays.values()) {
|
|
3089
|
+
ops.push(...entry.ops);
|
|
3090
|
+
}
|
|
3091
|
+
if (ops.length > 0) {
|
|
3092
|
+
this.onOrphanedRelays(this.clientNodeId, ops);
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
this.pendingRelays.clear();
|
|
3096
|
+
}
|
|
2483
3097
|
close(reason) {
|
|
2484
3098
|
if (this.state === "closed") return;
|
|
2485
3099
|
this.state = "closed";
|
|
3100
|
+
this.flushOrphanedRelays();
|
|
2486
3101
|
if (this.transport.isConnected()) {
|
|
2487
3102
|
this.transport.close(1e3, reason ?? "session closed");
|
|
2488
3103
|
}
|
|
@@ -2537,6 +3152,11 @@ var ClientSession = class {
|
|
|
2537
3152
|
await this.handleOperationBatch(message);
|
|
2538
3153
|
break;
|
|
2539
3154
|
case "acknowledgment":
|
|
3155
|
+
this.pendingRelays.delete(message.acknowledgedMessageId);
|
|
3156
|
+
if (message.deliverySequence !== void 0) {
|
|
3157
|
+
this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
|
|
3158
|
+
this.deliveryStallRepeatCount = 0;
|
|
3159
|
+
}
|
|
2540
3160
|
break;
|
|
2541
3161
|
case "error":
|
|
2542
3162
|
break;
|
|
@@ -2567,7 +3187,7 @@ var ClientSession = class {
|
|
|
2567
3187
|
return;
|
|
2568
3188
|
}
|
|
2569
3189
|
const bytes = (0, import_sync2.decodeBlobChunkBytes)(message.bytes);
|
|
2570
|
-
const actual = await (0,
|
|
3190
|
+
const actual = await (0, import_core11.hashBlob)(bytes);
|
|
2571
3191
|
if (actual !== message.hash) {
|
|
2572
3192
|
return;
|
|
2573
3193
|
}
|
|
@@ -2575,6 +3195,18 @@ var ClientSession = class {
|
|
|
2575
3195
|
}
|
|
2576
3196
|
handleMessageFailure(error) {
|
|
2577
3197
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
3198
|
+
this.logger?.log({
|
|
3199
|
+
timestamp: Date.now(),
|
|
3200
|
+
level: "error",
|
|
3201
|
+
event: "session.message_failed",
|
|
3202
|
+
sessionId: this.sessionId,
|
|
3203
|
+
nodeId: this.clientNodeId ?? void 0,
|
|
3204
|
+
error: reason,
|
|
3205
|
+
details: {
|
|
3206
|
+
state: this.state,
|
|
3207
|
+
errorName: error instanceof Error ? error.name : typeof error
|
|
3208
|
+
}
|
|
3209
|
+
});
|
|
2578
3210
|
this.sendError("SYNC_ERROR", reason, true);
|
|
2579
3211
|
this.close(reason);
|
|
2580
3212
|
}
|
|
@@ -2613,14 +3245,34 @@ var ClientSession = class {
|
|
|
2613
3245
|
this.syncQuerySubsets = [];
|
|
2614
3246
|
}
|
|
2615
3247
|
this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
|
|
3248
|
+
this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
|
|
3249
|
+
if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
|
|
3250
|
+
this.clientDeliveryWatermark = 0;
|
|
3251
|
+
}
|
|
3252
|
+
const clientUsesWatermark = this.clientDeliveryWatermark !== null;
|
|
3253
|
+
let serverMaxDelivery = 0;
|
|
3254
|
+
if (clientUsesWatermark) {
|
|
3255
|
+
serverMaxDelivery = await this.store.getMaxDeliverySequence();
|
|
3256
|
+
if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
|
|
3257
|
+
this.clientDeliveryWatermark = 0;
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
2616
3260
|
const serverVector = this.store.getVersionVector();
|
|
2617
3261
|
const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
|
|
2618
3262
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
2619
3263
|
if (!(0, import_sync3.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
|
|
2620
3264
|
const { min, max } = this.supportedSchemaVersions;
|
|
3265
|
+
this.emitter?.emit({
|
|
3266
|
+
type: "sync:schema-mismatch",
|
|
3267
|
+
clientSchemaVersion: msg.schemaVersion,
|
|
3268
|
+
serverSchemaVersion: this.schemaVersion,
|
|
3269
|
+
supportedMin: min,
|
|
3270
|
+
supportedMax: max,
|
|
3271
|
+
reason: `${import_sync3.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`
|
|
3272
|
+
});
|
|
2621
3273
|
const response2 = {
|
|
2622
3274
|
type: "handshake-response",
|
|
2623
|
-
messageId: (0,
|
|
3275
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2624
3276
|
nodeId: this.store.getNodeId(),
|
|
2625
3277
|
versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
|
|
2626
3278
|
schemaVersion: this.schemaVersion,
|
|
@@ -2636,13 +3288,17 @@ var ClientSession = class {
|
|
|
2636
3288
|
}
|
|
2637
3289
|
const response = {
|
|
2638
3290
|
type: "handshake-response",
|
|
2639
|
-
messageId: (0,
|
|
3291
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2640
3292
|
nodeId: this.store.getNodeId(),
|
|
2641
3293
|
versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
|
|
2642
3294
|
schemaVersion: this.schemaVersion,
|
|
2643
3295
|
accepted: true,
|
|
2644
3296
|
selectedWireFormat,
|
|
2645
3297
|
serverTime: Date.now(),
|
|
3298
|
+
// The server's highest delivery sequence, so a client whose persisted watermark
|
|
3299
|
+
// is ahead of it (server rolled back) can reset to a full resync. Sent only to
|
|
3300
|
+
// watermark-using clients.
|
|
3301
|
+
...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
|
|
2646
3302
|
// Advertise central blob storage so the client uploads the bytes behind
|
|
2647
3303
|
// its blob fields, keeping them available after the author goes offline.
|
|
2648
3304
|
...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
|
|
@@ -2653,17 +3309,40 @@ var ClientSession = class {
|
|
|
2653
3309
|
this.sendToClient(response);
|
|
2654
3310
|
this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
|
|
2655
3311
|
this.state = "syncing";
|
|
2656
|
-
|
|
2657
|
-
|
|
3312
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
3313
|
+
this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
|
|
3314
|
+
const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
|
|
3315
|
+
this.lastDeliveryPushAttemptAtMs = Date.now();
|
|
3316
|
+
await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
|
|
3317
|
+
} else {
|
|
3318
|
+
const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
|
|
3319
|
+
await this.sendDelta(clientVector);
|
|
3320
|
+
}
|
|
2658
3321
|
this.state = "streaming";
|
|
3322
|
+
if (this.takeOrphanedRelays && this.clientNodeId) {
|
|
3323
|
+
const buffered = this.takeOrphanedRelays(this.clientNodeId);
|
|
3324
|
+
if (buffered.length > 0) {
|
|
3325
|
+
this.relayOperations(buffered);
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
2659
3328
|
}
|
|
2660
3329
|
async handleOperationBatch(msg) {
|
|
2661
3330
|
const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2662
3331
|
const applied = [];
|
|
2663
|
-
|
|
3332
|
+
let acknowledgedThrough = 0;
|
|
3333
|
+
let canAdvanceAck = true;
|
|
2664
3334
|
for (const op of operations2) {
|
|
2665
|
-
if (!
|
|
2666
|
-
|
|
3335
|
+
if (!canAdvanceAck) {
|
|
3336
|
+
continue;
|
|
3337
|
+
}
|
|
3338
|
+
if (!await this.operationVisibleToClient(op)) {
|
|
3339
|
+
this.sendOperationRejected(
|
|
3340
|
+
op,
|
|
3341
|
+
"SCOPE_VIOLATION",
|
|
3342
|
+
`Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
|
|
3343
|
+
true
|
|
3344
|
+
);
|
|
3345
|
+
canAdvanceAck = false;
|
|
2667
3346
|
continue;
|
|
2668
3347
|
}
|
|
2669
3348
|
if (!isOperationTimestampValid(op)) {
|
|
@@ -2672,6 +3351,7 @@ var ClientSession = class {
|
|
|
2672
3351
|
`Operation "${op.id}" timestamp is too far in the future`,
|
|
2673
3352
|
false
|
|
2674
3353
|
);
|
|
3354
|
+
canAdvanceAck = false;
|
|
2675
3355
|
continue;
|
|
2676
3356
|
}
|
|
2677
3357
|
if (!this.rateLimiter.allow(1)) {
|
|
@@ -2680,6 +3360,7 @@ var ClientSession = class {
|
|
|
2680
3360
|
`Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
|
|
2681
3361
|
true
|
|
2682
3362
|
);
|
|
3363
|
+
canAdvanceAck = false;
|
|
2683
3364
|
continue;
|
|
2684
3365
|
}
|
|
2685
3366
|
const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
|
|
@@ -2689,27 +3370,73 @@ var ClientSession = class {
|
|
|
2689
3370
|
sizeCheck.message ?? `Operation "${op.id}" is too large`,
|
|
2690
3371
|
false
|
|
2691
3372
|
);
|
|
3373
|
+
canAdvanceAck = false;
|
|
2692
3374
|
continue;
|
|
2693
3375
|
}
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
3376
|
+
const serverOp = this.transformForServerSchema(op);
|
|
3377
|
+
if (serverOp === null) {
|
|
3378
|
+
this.sendOperationRejected(
|
|
3379
|
+
op,
|
|
3380
|
+
"SCHEMA_TRANSFORM_UNAVAILABLE",
|
|
3381
|
+
`Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
|
|
3382
|
+
false
|
|
3383
|
+
);
|
|
3384
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3385
|
+
continue;
|
|
3386
|
+
}
|
|
3387
|
+
if (this.validateOperation && this.koraContext) {
|
|
3388
|
+
let decision;
|
|
3389
|
+
try {
|
|
3390
|
+
decision = await this.validateOperation(serverOp, {
|
|
3391
|
+
auth: this.authContext,
|
|
3392
|
+
kora: this.koraContext
|
|
3393
|
+
});
|
|
3394
|
+
} catch (error) {
|
|
3395
|
+
const message = error instanceof Error ? error.message : "validator error";
|
|
3396
|
+
this.sendOperationRejected(
|
|
3397
|
+
serverOp,
|
|
3398
|
+
"VALIDATION_ERROR",
|
|
3399
|
+
`Validator threw: ${message}`,
|
|
3400
|
+
true
|
|
3401
|
+
);
|
|
3402
|
+
canAdvanceAck = false;
|
|
2698
3403
|
continue;
|
|
2699
3404
|
}
|
|
2700
|
-
if (
|
|
2701
|
-
|
|
3405
|
+
if (decision.action === "reject") {
|
|
3406
|
+
const retriable = decision.retriable ?? isRetriableRejection(decision.code);
|
|
3407
|
+
this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
|
|
3408
|
+
if (retriable) {
|
|
3409
|
+
canAdvanceAck = false;
|
|
3410
|
+
} else {
|
|
3411
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3412
|
+
}
|
|
3413
|
+
continue;
|
|
3414
|
+
}
|
|
3415
|
+
if (decision.action === "ignore") {
|
|
3416
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3417
|
+
continue;
|
|
2702
3418
|
}
|
|
2703
|
-
} catch {
|
|
2704
3419
|
}
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
3420
|
+
const applyResult = await applyServerOperation(this.store, serverOp);
|
|
3421
|
+
if (applyResult.rejection) {
|
|
3422
|
+
this.sendOperationRejected(
|
|
3423
|
+
serverOp,
|
|
3424
|
+
applyResult.rejection.code,
|
|
3425
|
+
applyResult.rejection.message,
|
|
3426
|
+
applyResult.rejection.retriable
|
|
2712
3427
|
);
|
|
3428
|
+
if (applyResult.rejection.retriable) {
|
|
3429
|
+
canAdvanceAck = false;
|
|
3430
|
+
} else {
|
|
3431
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3432
|
+
}
|
|
3433
|
+
continue;
|
|
3434
|
+
}
|
|
3435
|
+
if (applyResult.result === "applied") {
|
|
3436
|
+
applied.push(...applyResult.appliedOperations);
|
|
3437
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3438
|
+
} else {
|
|
3439
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
2713
3440
|
}
|
|
2714
3441
|
}
|
|
2715
3442
|
if (operations2.length > 0) {
|
|
@@ -2722,15 +3449,21 @@ var ClientSession = class {
|
|
|
2722
3449
|
const lastOp = operations2[operations2.length - 1];
|
|
2723
3450
|
const ack = {
|
|
2724
3451
|
type: "acknowledgment",
|
|
2725
|
-
messageId: (0,
|
|
3452
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2726
3453
|
acknowledgedMessageId: msg.messageId,
|
|
2727
|
-
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3454
|
+
lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
|
|
2728
3455
|
};
|
|
2729
3456
|
this.sendToClient(ack);
|
|
2730
3457
|
if (applied.length > 0) {
|
|
2731
3458
|
this.onRelay?.(this.sessionId, applied);
|
|
2732
3459
|
}
|
|
2733
3460
|
}
|
|
3461
|
+
transformForServerSchema(op) {
|
|
3462
|
+
if (op.schemaVersion === this.schemaVersion) {
|
|
3463
|
+
return op;
|
|
3464
|
+
}
|
|
3465
|
+
return (0, import_core10.applyOperationTransforms)(op, this.schemaVersion, this.operationTransforms);
|
|
3466
|
+
}
|
|
2734
3467
|
async sendDelta(clientVector) {
|
|
2735
3468
|
const serverVector = this.store.getVersionVector();
|
|
2736
3469
|
const missing = [];
|
|
@@ -2738,14 +3471,17 @@ var ClientSession = class {
|
|
|
2738
3471
|
const clientSeq = clientVector.get(nodeId) ?? 0;
|
|
2739
3472
|
if (serverSeq > clientSeq) {
|
|
2740
3473
|
const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
|
|
2741
|
-
const
|
|
2742
|
-
|
|
3474
|
+
for (const op of ops) {
|
|
3475
|
+
if (await this.operationVisibleToClient(op)) {
|
|
3476
|
+
missing.push(op);
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
2743
3479
|
}
|
|
2744
3480
|
}
|
|
2745
3481
|
if (missing.length === 0) {
|
|
2746
3482
|
const emptyBatch = {
|
|
2747
3483
|
type: "operation-batch",
|
|
2748
|
-
messageId: (0,
|
|
3484
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2749
3485
|
operations: [],
|
|
2750
3486
|
isFinal: true,
|
|
2751
3487
|
batchIndex: 0,
|
|
@@ -2760,7 +3496,7 @@ var ClientSession = class {
|
|
|
2760
3496
|
if (afterCursor.length === 0) {
|
|
2761
3497
|
const emptyBatch = {
|
|
2762
3498
|
type: "operation-batch",
|
|
2763
|
-
messageId: (0,
|
|
3499
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2764
3500
|
operations: [],
|
|
2765
3501
|
isFinal: true,
|
|
2766
3502
|
batchIndex: this.resumeDeltaCursor?.batchIndex ?? 0,
|
|
@@ -2776,7 +3512,7 @@ var ClientSession = class {
|
|
|
2776
3512
|
const batchCursor = (0, import_sync3.createDeltaCursorFromBatch)(batchOps, i);
|
|
2777
3513
|
const batchMsg = {
|
|
2778
3514
|
type: "operation-batch",
|
|
2779
|
-
messageId: (0,
|
|
3515
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2780
3516
|
operations: serializedOps,
|
|
2781
3517
|
isFinal: i === totalBatches - 1,
|
|
2782
3518
|
batchIndex: i,
|
|
@@ -2791,11 +3527,111 @@ var ClientSession = class {
|
|
|
2791
3527
|
});
|
|
2792
3528
|
}
|
|
2793
3529
|
}
|
|
2794
|
-
|
|
2795
|
-
|
|
3530
|
+
/**
|
|
3531
|
+
* Send the gap-free server->client delivery stream, resuming just after
|
|
3532
|
+
* `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
|
|
3533
|
+
* (commit order), scope-filtered for this session, and sent in batches that chain:
|
|
3534
|
+
* each batch's `baseDeliverySequence` equals the previous batch's
|
|
3535
|
+
* `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
|
|
3536
|
+
* applies a batch only when its watermark equals the base and advances the
|
|
3537
|
+
* watermark to the max, so a dropped batch stalls the watermark and is recovered by
|
|
3538
|
+
* the next handshake resend. Because delivery-sequence order respects causal order
|
|
3539
|
+
* (a dependency is always committed, and thus sequenced, before its dependent), no
|
|
3540
|
+
* topological sort is needed and the client never defers on a missing dependency
|
|
3541
|
+
* that is itself in this stream.
|
|
3542
|
+
*
|
|
3543
|
+
* The final batch advances the watermark to the highest delivery sequence scanned,
|
|
3544
|
+
* not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
|
|
3545
|
+
* next reconnect. Returns the number of operations actually sent.
|
|
3546
|
+
*/
|
|
3547
|
+
async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
|
|
3548
|
+
const scanChunk = Math.max(this.batchSize, 1) * 5;
|
|
3549
|
+
let scanCursor = fromDeliverySeq;
|
|
3550
|
+
let maxScanned = fromDeliverySeq;
|
|
3551
|
+
const visible = [];
|
|
3552
|
+
while (true) {
|
|
3553
|
+
const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
|
|
3554
|
+
const last = chunk[chunk.length - 1];
|
|
3555
|
+
if (last === void 0) break;
|
|
3556
|
+
scanCursor = last.deliverySequence;
|
|
3557
|
+
maxScanned = scanCursor;
|
|
3558
|
+
for (const delivered of chunk) {
|
|
3559
|
+
if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
|
|
3560
|
+
continue;
|
|
3561
|
+
}
|
|
3562
|
+
if (await this.operationVisibleToClient(delivered.operation)) {
|
|
3563
|
+
visible.push(delivered);
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
if (chunk.length < scanChunk) break;
|
|
3567
|
+
}
|
|
3568
|
+
if (visible.length === 0) {
|
|
3569
|
+
if (finalizeWhenEmpty) {
|
|
3570
|
+
this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
|
|
3571
|
+
}
|
|
3572
|
+
return 0;
|
|
3573
|
+
}
|
|
3574
|
+
const totalBatches = Math.ceil(visible.length / this.batchSize);
|
|
3575
|
+
let base = fromDeliverySeq;
|
|
3576
|
+
for (let i = 0; i < totalBatches; i++) {
|
|
3577
|
+
const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
|
|
3578
|
+
const lastInSlice = slice[slice.length - 1];
|
|
3579
|
+
if (lastInSlice === void 0) continue;
|
|
3580
|
+
const isFinal = i === totalBatches - 1;
|
|
3581
|
+
const lastSeq = lastInSlice.deliverySequence;
|
|
3582
|
+
const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
|
|
3583
|
+
this.sendDeliveryBatch(slice, base, max, i, isFinal);
|
|
3584
|
+
base = max;
|
|
3585
|
+
}
|
|
3586
|
+
return visible.length;
|
|
3587
|
+
}
|
|
3588
|
+
/** Build, track, and send one chained delivery-stream batch. */
|
|
3589
|
+
sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
|
|
3590
|
+
const batchMsg = {
|
|
3591
|
+
type: "operation-batch",
|
|
3592
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
3593
|
+
operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
|
|
3594
|
+
isFinal,
|
|
3595
|
+
batchIndex,
|
|
3596
|
+
baseDeliverySequence: base,
|
|
3597
|
+
maxDeliverySequence: max
|
|
3598
|
+
};
|
|
3599
|
+
this.sendToClient(batchMsg);
|
|
3600
|
+
if (slice.length > 0) {
|
|
3601
|
+
this.emitter?.emit({
|
|
3602
|
+
type: "sync:sent",
|
|
3603
|
+
operations: slice.map((delivered) => delivered.operation),
|
|
3604
|
+
batchSize: slice.length
|
|
3605
|
+
});
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
async operationVisibleToClient(op) {
|
|
3609
|
+
const scopes = this.authContext?.scopes;
|
|
3610
|
+
const subsets = this.syncQuerySubsets;
|
|
3611
|
+
const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
|
|
3612
|
+
const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
|
|
3613
|
+
if (!operationMatchesScopes(op, scopes, fullRecord)) {
|
|
2796
3614
|
return false;
|
|
2797
3615
|
}
|
|
2798
|
-
return (0, import_sync3.operationMatchesQuerySubsets)(op,
|
|
3616
|
+
return (0, import_sync3.operationMatchesQuerySubsets)(op, subsets, fullRecord);
|
|
3617
|
+
}
|
|
3618
|
+
/**
|
|
3619
|
+
* Read a record's current field values from the materialized store for scope /
|
|
3620
|
+
* query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
|
|
3621
|
+
* carries no fields) is still judged against the record's actual scope. Returns
|
|
3622
|
+
* undefined when the record cannot be read (never materialized, or no schema).
|
|
3623
|
+
*/
|
|
3624
|
+
async lookupRecordFields(collection, recordId) {
|
|
3625
|
+
try {
|
|
3626
|
+
const rows = await this.store.queryCollection(collection, {
|
|
3627
|
+
where: { id: recordId },
|
|
3628
|
+
includeDeleted: true,
|
|
3629
|
+
limit: 1
|
|
3630
|
+
});
|
|
3631
|
+
return rows[0];
|
|
3632
|
+
} catch {
|
|
3633
|
+
return void 0;
|
|
3634
|
+
}
|
|
2799
3635
|
}
|
|
2800
3636
|
handleAwarenessUpdate(msg) {
|
|
2801
3637
|
this.onAwarenessUpdate?.(this.sessionId, msg);
|
|
@@ -2806,13 +3642,32 @@ var ClientSession = class {
|
|
|
2806
3642
|
sendError(code, message, retriable) {
|
|
2807
3643
|
const errorMsg = {
|
|
2808
3644
|
type: "error",
|
|
2809
|
-
messageId: (0,
|
|
3645
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2810
3646
|
code,
|
|
2811
3647
|
message,
|
|
2812
3648
|
retriable
|
|
2813
3649
|
};
|
|
2814
3650
|
this.sendToClient(errorMsg);
|
|
2815
3651
|
}
|
|
3652
|
+
/**
|
|
3653
|
+
* Reject one specific client operation, tied to its id, so the submitter can
|
|
3654
|
+
* divert it out of its pending queue into a durable rejected store rather than
|
|
3655
|
+
* losing it or retrying forever. The op is NOT materialized, so no other
|
|
3656
|
+
* replica ever sees it.
|
|
3657
|
+
*/
|
|
3658
|
+
sendOperationRejected(op, code, message, retriable) {
|
|
3659
|
+
const rejectedMsg = {
|
|
3660
|
+
type: "operation-rejected",
|
|
3661
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
3662
|
+
operationId: op.id,
|
|
3663
|
+
collection: op.collection,
|
|
3664
|
+
recordId: op.recordId,
|
|
3665
|
+
code,
|
|
3666
|
+
message,
|
|
3667
|
+
retriable
|
|
3668
|
+
};
|
|
3669
|
+
this.sendToClient(rejectedMsg);
|
|
3670
|
+
}
|
|
2816
3671
|
setSerializerWireFormat(format) {
|
|
2817
3672
|
if (typeof this.serializer.setWireFormat === "function") {
|
|
2818
3673
|
this.serializer.setWireFormat(format);
|
|
@@ -2821,6 +3676,7 @@ var ClientSession = class {
|
|
|
2821
3676
|
handleTransportClose() {
|
|
2822
3677
|
if (this.state === "closed") return;
|
|
2823
3678
|
this.state = "closed";
|
|
3679
|
+
this.flushOrphanedRelays();
|
|
2824
3680
|
this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
|
|
2825
3681
|
this.onClose?.(this.sessionId);
|
|
2826
3682
|
}
|
|
@@ -2834,13 +3690,13 @@ function selectWireFormat(supportedWireFormats) {
|
|
|
2834
3690
|
|
|
2835
3691
|
// src/server/kora-sync-server.ts
|
|
2836
3692
|
init_cjs_shims();
|
|
2837
|
-
var
|
|
3693
|
+
var import_core14 = require("@korajs/core");
|
|
2838
3694
|
var import_internal2 = require("@korajs/core/internal");
|
|
2839
3695
|
var import_sync5 = require("@korajs/sync");
|
|
2840
3696
|
|
|
2841
3697
|
// src/awareness/awareness-relay.ts
|
|
2842
3698
|
init_cjs_shims();
|
|
2843
|
-
var
|
|
3699
|
+
var import_core12 = require("@korajs/core");
|
|
2844
3700
|
var AwarenessRelay = class {
|
|
2845
3701
|
clients = /* @__PURE__ */ new Map();
|
|
2846
3702
|
/**
|
|
@@ -2869,7 +3725,7 @@ var AwarenessRelay = class {
|
|
|
2869
3725
|
if (hasStates) {
|
|
2870
3726
|
const catchUpMsg = {
|
|
2871
3727
|
type: "awareness-update",
|
|
2872
|
-
messageId: (0,
|
|
3728
|
+
messageId: (0, import_core12.generateUUIDv7)(),
|
|
2873
3729
|
clientId: 0,
|
|
2874
3730
|
// Server-sourced
|
|
2875
3731
|
states: existingStates
|
|
@@ -2892,7 +3748,7 @@ var AwarenessRelay = class {
|
|
|
2892
3748
|
};
|
|
2893
3749
|
const msg = {
|
|
2894
3750
|
type: "awareness-update",
|
|
2895
|
-
messageId: (0,
|
|
3751
|
+
messageId: (0, import_core12.generateUUIDv7)(),
|
|
2896
3752
|
clientId: client.clientId,
|
|
2897
3753
|
states: removalStates
|
|
2898
3754
|
};
|
|
@@ -3196,21 +4052,416 @@ var YjsDocRelay = class {
|
|
|
3196
4052
|
}
|
|
3197
4053
|
};
|
|
3198
4054
|
|
|
4055
|
+
// src/server/route-context.ts
|
|
4056
|
+
init_cjs_shims();
|
|
4057
|
+
var import_core13 = require("@korajs/core");
|
|
4058
|
+
|
|
4059
|
+
// src/apply/route-predicate.ts
|
|
4060
|
+
init_cjs_shims();
|
|
4061
|
+
function evaluateField(actual, operators) {
|
|
4062
|
+
if ("$eq" in operators && !Object.is(actual, operators.$eq)) {
|
|
4063
|
+
return false;
|
|
4064
|
+
}
|
|
4065
|
+
if ("$ne" in operators && Object.is(actual, operators.$ne)) {
|
|
4066
|
+
return false;
|
|
4067
|
+
}
|
|
4068
|
+
if (operators.$in !== void 0 && !operators.$in.some((candidate) => Object.is(actual, candidate))) {
|
|
4069
|
+
return false;
|
|
4070
|
+
}
|
|
4071
|
+
const numericChecks = [
|
|
4072
|
+
["$lt", (a, b) => a < b],
|
|
4073
|
+
["$lte", (a, b) => a <= b],
|
|
4074
|
+
["$gt", (a, b) => a > b],
|
|
4075
|
+
["$gte", (a, b) => a >= b]
|
|
4076
|
+
];
|
|
4077
|
+
for (const [operator, compare] of numericChecks) {
|
|
4078
|
+
const bound = operators[operator];
|
|
4079
|
+
if (bound === void 0) {
|
|
4080
|
+
continue;
|
|
4081
|
+
}
|
|
4082
|
+
if (typeof actual !== "number" || typeof bound !== "number") {
|
|
4083
|
+
return false;
|
|
4084
|
+
}
|
|
4085
|
+
if (!compare(actual, bound)) {
|
|
4086
|
+
return false;
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
return true;
|
|
4090
|
+
}
|
|
4091
|
+
function evaluateRoutePredicate(record, predicate) {
|
|
4092
|
+
const fields = Object.keys(predicate);
|
|
4093
|
+
if (fields.length === 0) {
|
|
4094
|
+
return true;
|
|
4095
|
+
}
|
|
4096
|
+
if (record === null) {
|
|
4097
|
+
return false;
|
|
4098
|
+
}
|
|
4099
|
+
for (const field of fields) {
|
|
4100
|
+
const operators = predicate[field];
|
|
4101
|
+
if (operators && !evaluateField(record[field], operators)) {
|
|
4102
|
+
return false;
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
return true;
|
|
4106
|
+
}
|
|
4107
|
+
|
|
4108
|
+
// src/server/route-context.ts
|
|
4109
|
+
function recordMatchesScope(collection, record, scope) {
|
|
4110
|
+
const collectionScope = scope[collection];
|
|
4111
|
+
if (!collectionScope) {
|
|
4112
|
+
return false;
|
|
4113
|
+
}
|
|
4114
|
+
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
4115
|
+
if (record[field] !== expected) {
|
|
4116
|
+
return false;
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
return true;
|
|
4120
|
+
}
|
|
4121
|
+
function pickFields(record, keys) {
|
|
4122
|
+
const picked = {};
|
|
4123
|
+
for (const key of keys) {
|
|
4124
|
+
if (key in record) {
|
|
4125
|
+
picked[key] = record[key];
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
4128
|
+
return picked;
|
|
4129
|
+
}
|
|
4130
|
+
async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
|
|
4131
|
+
const nodeId = store.getNodeId();
|
|
4132
|
+
const clock = clockOverride ?? new import_core13.HybridLogicalClock(nodeId);
|
|
4133
|
+
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
4134
|
+
const sequenceNumber = nextServerSequenceNumber(store);
|
|
4135
|
+
if (mutation.type === "insert") {
|
|
4136
|
+
return (0, import_core13.createOperation)(
|
|
4137
|
+
{
|
|
4138
|
+
nodeId,
|
|
4139
|
+
type: "insert",
|
|
4140
|
+
collection: mutation.collection,
|
|
4141
|
+
recordId: mutation.recordId ?? (0, import_core13.generateUUIDv7)(),
|
|
4142
|
+
data: mutation.data ?? {},
|
|
4143
|
+
previousData: null,
|
|
4144
|
+
sequenceNumber,
|
|
4145
|
+
causalDeps: [],
|
|
4146
|
+
schemaVersion
|
|
4147
|
+
},
|
|
4148
|
+
clock
|
|
4149
|
+
);
|
|
4150
|
+
}
|
|
4151
|
+
if (!mutation.recordId) {
|
|
4152
|
+
throw new RouteMutationError(
|
|
4153
|
+
"MISSING_RECORD_ID",
|
|
4154
|
+
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
4155
|
+
);
|
|
4156
|
+
}
|
|
4157
|
+
const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
|
|
4158
|
+
if (mutation.type === "update") {
|
|
4159
|
+
const rawData = mutation.data ?? {};
|
|
4160
|
+
const data = {};
|
|
4161
|
+
const atomicOps = {};
|
|
4162
|
+
for (const key of Object.keys(rawData)) {
|
|
4163
|
+
const value = rawData[key];
|
|
4164
|
+
if ((0, import_core13.isAtomicOp)(value)) {
|
|
4165
|
+
data[key] = (0, import_core13.resolveAtomicOp)(current ? current[key] : void 0, value);
|
|
4166
|
+
atomicOps[key] = (0, import_core13.toAtomicOp)(value);
|
|
4167
|
+
} else {
|
|
4168
|
+
data[key] = value;
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
return (0, import_core13.createOperation)(
|
|
4172
|
+
{
|
|
4173
|
+
nodeId,
|
|
4174
|
+
type: "update",
|
|
4175
|
+
collection: mutation.collection,
|
|
4176
|
+
recordId: mutation.recordId,
|
|
4177
|
+
data,
|
|
4178
|
+
previousData: current ? pickFields(current, Object.keys(rawData)) : {},
|
|
4179
|
+
sequenceNumber,
|
|
4180
|
+
causalDeps: [],
|
|
4181
|
+
schemaVersion,
|
|
4182
|
+
...Object.keys(atomicOps).length > 0 ? { atomicOps } : {}
|
|
4183
|
+
},
|
|
4184
|
+
clock
|
|
4185
|
+
);
|
|
4186
|
+
}
|
|
4187
|
+
return (0, import_core13.createOperation)(
|
|
4188
|
+
{
|
|
4189
|
+
nodeId,
|
|
4190
|
+
type: "delete",
|
|
4191
|
+
collection: mutation.collection,
|
|
4192
|
+
recordId: mutation.recordId,
|
|
4193
|
+
data: null,
|
|
4194
|
+
previousData: current ? { ...current } : null,
|
|
4195
|
+
sequenceNumber,
|
|
4196
|
+
causalDeps: [],
|
|
4197
|
+
schemaVersion
|
|
4198
|
+
},
|
|
4199
|
+
clock
|
|
4200
|
+
);
|
|
4201
|
+
}
|
|
4202
|
+
var RouteMutationError = class extends Error {
|
|
4203
|
+
constructor(code, message) {
|
|
4204
|
+
super(message);
|
|
4205
|
+
this.code = code;
|
|
4206
|
+
this.name = "RouteMutationError";
|
|
4207
|
+
}
|
|
4208
|
+
code;
|
|
4209
|
+
};
|
|
4210
|
+
function createRouteContext(server, store) {
|
|
4211
|
+
let mutationTail = Promise.resolve();
|
|
4212
|
+
function serialize(work) {
|
|
4213
|
+
const run = mutationTail.then(work, work);
|
|
4214
|
+
mutationTail = run.then(
|
|
4215
|
+
() => void 0,
|
|
4216
|
+
() => void 0
|
|
4217
|
+
);
|
|
4218
|
+
return run;
|
|
4219
|
+
}
|
|
4220
|
+
async function prepareMutation(mutation, scope) {
|
|
4221
|
+
let op;
|
|
4222
|
+
try {
|
|
4223
|
+
op = await buildRouteOperation(store, mutation);
|
|
4224
|
+
} catch (error) {
|
|
4225
|
+
if (error instanceof RouteMutationError) {
|
|
4226
|
+
return {
|
|
4227
|
+
ok: false,
|
|
4228
|
+
rejection: {
|
|
4229
|
+
ok: false,
|
|
4230
|
+
code: error.code,
|
|
4231
|
+
message: error.message,
|
|
4232
|
+
retriable: isRetriableRejection(error.code)
|
|
4233
|
+
}
|
|
4234
|
+
};
|
|
4235
|
+
}
|
|
4236
|
+
throw error;
|
|
4237
|
+
}
|
|
4238
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
4239
|
+
return {
|
|
4240
|
+
ok: false,
|
|
4241
|
+
rejection: {
|
|
4242
|
+
ok: false,
|
|
4243
|
+
code: "SCOPE_VIOLATION",
|
|
4244
|
+
message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
|
|
4245
|
+
retriable: isRetriableRejection("SCOPE_VIOLATION")
|
|
4246
|
+
}
|
|
4247
|
+
};
|
|
4248
|
+
}
|
|
4249
|
+
return { ok: true, op };
|
|
4250
|
+
}
|
|
4251
|
+
async function commitOperation(op, collection) {
|
|
4252
|
+
const result = await server.applyLocalOperation(op);
|
|
4253
|
+
if (result.result !== "applied") {
|
|
4254
|
+
const code = result.rejection?.code ?? "NOT_APPLIED";
|
|
4255
|
+
return {
|
|
4256
|
+
ok: false,
|
|
4257
|
+
code,
|
|
4258
|
+
message: result.rejection?.message ?? `Operation on "${collection}" was not applied (${result.result}).`,
|
|
4259
|
+
// Prefer the pipeline's own classification when present; fall back
|
|
4260
|
+
// to classifying the code so this stays correct if new codes appear.
|
|
4261
|
+
retriable: result.rejection?.retriable ?? isRetriableRejection(code)
|
|
4262
|
+
};
|
|
4263
|
+
}
|
|
4264
|
+
const record = await store.findRecord(collection, op.recordId);
|
|
4265
|
+
return { ok: true, operation: op, record };
|
|
4266
|
+
}
|
|
4267
|
+
async function rawApply(mutation, scope) {
|
|
4268
|
+
const prepared = await prepareMutation(mutation, scope);
|
|
4269
|
+
if (!prepared.ok) {
|
|
4270
|
+
return prepared.rejection;
|
|
4271
|
+
}
|
|
4272
|
+
return commitOperation(prepared.op, mutation.collection);
|
|
4273
|
+
}
|
|
4274
|
+
async function runConditionalViaStore(storeApplyConditional, spec, options) {
|
|
4275
|
+
const scope = options?.scope;
|
|
4276
|
+
const mutations = [];
|
|
4277
|
+
const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
|
|
4278
|
+
if (hasTargetUpdate) {
|
|
4279
|
+
mutations.push({
|
|
4280
|
+
collection: spec.collection,
|
|
4281
|
+
type: "update",
|
|
4282
|
+
recordId: spec.id,
|
|
4283
|
+
data: spec.update
|
|
4284
|
+
});
|
|
4285
|
+
}
|
|
4286
|
+
if (spec.also) {
|
|
4287
|
+
mutations.push(...spec.also);
|
|
4288
|
+
}
|
|
4289
|
+
let result;
|
|
4290
|
+
try {
|
|
4291
|
+
result = await storeApplyConditional({
|
|
4292
|
+
target: { collection: spec.collection, id: spec.id },
|
|
4293
|
+
...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
|
|
4294
|
+
admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
|
|
4295
|
+
buildOperations: async (current, context) => {
|
|
4296
|
+
const ops = [];
|
|
4297
|
+
for (let i = 0; i < mutations.length; i++) {
|
|
4298
|
+
const mutation = mutations[i];
|
|
4299
|
+
const useLockedCurrent = i === 0 && hasTargetUpdate;
|
|
4300
|
+
const op = await buildRouteOperation(
|
|
4301
|
+
store,
|
|
4302
|
+
mutation,
|
|
4303
|
+
useLockedCurrent ? { record: current } : void 0,
|
|
4304
|
+
context.clock
|
|
4305
|
+
);
|
|
4306
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
4307
|
+
throw new RouteMutationError(
|
|
4308
|
+
"SCOPE_VIOLATION",
|
|
4309
|
+
`Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
4310
|
+
);
|
|
4311
|
+
}
|
|
4312
|
+
ops.push(op);
|
|
4313
|
+
}
|
|
4314
|
+
return ops;
|
|
4315
|
+
}
|
|
4316
|
+
});
|
|
4317
|
+
} catch (error) {
|
|
4318
|
+
if (error instanceof RouteMutationError) {
|
|
4319
|
+
return {
|
|
4320
|
+
ok: false,
|
|
4321
|
+
code: error.code,
|
|
4322
|
+
message: error.message,
|
|
4323
|
+
retriable: isRetriableRejection(error.code)
|
|
4324
|
+
};
|
|
4325
|
+
}
|
|
4326
|
+
throw error;
|
|
4327
|
+
}
|
|
4328
|
+
if (!result.admitted) {
|
|
4329
|
+
return {
|
|
4330
|
+
ok: false,
|
|
4331
|
+
code: spec.reject?.code ?? "CONDITION_NOT_MET",
|
|
4332
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
4333
|
+
retriable: false
|
|
4334
|
+
};
|
|
4335
|
+
}
|
|
4336
|
+
if (result.idempotent) {
|
|
4337
|
+
const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
|
|
4338
|
+
return { ok: true, idempotent: true, operations: [], records: records2 };
|
|
4339
|
+
}
|
|
4340
|
+
server.relayServerOperations(result.applied);
|
|
4341
|
+
const records = [];
|
|
4342
|
+
for (const op of result.applied) {
|
|
4343
|
+
records.push(await store.findRecord(op.collection, op.recordId));
|
|
4344
|
+
}
|
|
4345
|
+
return { ok: true, idempotent: false, operations: result.applied, records };
|
|
4346
|
+
}
|
|
4347
|
+
return {
|
|
4348
|
+
apply(mutation, options) {
|
|
4349
|
+
return serialize(() => rawApply(mutation, options?.scope));
|
|
4350
|
+
},
|
|
4351
|
+
applyConditional(spec, options) {
|
|
4352
|
+
const storeApplyConditional = store.applyConditional;
|
|
4353
|
+
if (storeApplyConditional) {
|
|
4354
|
+
return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
|
|
4355
|
+
}
|
|
4356
|
+
return serialize(async () => {
|
|
4357
|
+
if (spec.idempotencyKey) {
|
|
4358
|
+
const existing = await store.findRecord(
|
|
4359
|
+
spec.idempotencyKey.collection,
|
|
4360
|
+
spec.idempotencyKey.id
|
|
4361
|
+
);
|
|
4362
|
+
if (existing) {
|
|
4363
|
+
return { ok: true, idempotent: true, operations: [], records: [existing] };
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
if (spec.if && Object.keys(spec.if).length > 0) {
|
|
4367
|
+
const target = await store.findRecord(spec.collection, spec.id);
|
|
4368
|
+
if (!evaluateRoutePredicate(target, spec.if)) {
|
|
4369
|
+
const code = spec.reject?.code ?? "CONDITION_NOT_MET";
|
|
4370
|
+
return {
|
|
4371
|
+
ok: false,
|
|
4372
|
+
code,
|
|
4373
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
4374
|
+
retriable: false
|
|
4375
|
+
};
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4378
|
+
const mutations = [];
|
|
4379
|
+
if (spec.update !== void 0 && spec.update !== null) {
|
|
4380
|
+
mutations.push({
|
|
4381
|
+
collection: spec.collection,
|
|
4382
|
+
type: "update",
|
|
4383
|
+
recordId: spec.id,
|
|
4384
|
+
data: spec.update
|
|
4385
|
+
});
|
|
4386
|
+
}
|
|
4387
|
+
if (spec.also) {
|
|
4388
|
+
mutations.push(...spec.also);
|
|
4389
|
+
}
|
|
4390
|
+
const prepared = [];
|
|
4391
|
+
for (const mutation of mutations) {
|
|
4392
|
+
const p = await prepareMutation(mutation, options?.scope);
|
|
4393
|
+
if (!p.ok) {
|
|
4394
|
+
return p.rejection;
|
|
4395
|
+
}
|
|
4396
|
+
prepared.push({ op: p.op, collection: mutation.collection });
|
|
4397
|
+
}
|
|
4398
|
+
const operations2 = [];
|
|
4399
|
+
const records = [];
|
|
4400
|
+
for (const { op, collection } of prepared) {
|
|
4401
|
+
const result = await commitOperation(op, collection);
|
|
4402
|
+
if (!result.ok) {
|
|
4403
|
+
return {
|
|
4404
|
+
ok: false,
|
|
4405
|
+
code: result.code,
|
|
4406
|
+
message: result.message,
|
|
4407
|
+
retriable: result.retriable
|
|
4408
|
+
};
|
|
4409
|
+
}
|
|
4410
|
+
operations2.push(result.operation);
|
|
4411
|
+
records.push(result.record);
|
|
4412
|
+
}
|
|
4413
|
+
return { ok: true, idempotent: false, operations: operations2, records };
|
|
4414
|
+
});
|
|
4415
|
+
},
|
|
4416
|
+
async query(collection, options) {
|
|
4417
|
+
const { scope, ...queryOptions } = options ?? {};
|
|
4418
|
+
const records = await store.queryCollection(collection, queryOptions);
|
|
4419
|
+
if (!scope) {
|
|
4420
|
+
return records;
|
|
4421
|
+
}
|
|
4422
|
+
return records.filter((record) => recordMatchesScope(collection, record, scope));
|
|
4423
|
+
},
|
|
4424
|
+
async findById(collection, id, options) {
|
|
4425
|
+
const record = await store.findRecord(collection, id);
|
|
4426
|
+
if (!record) {
|
|
4427
|
+
return null;
|
|
4428
|
+
}
|
|
4429
|
+
if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
|
|
4430
|
+
return null;
|
|
4431
|
+
}
|
|
4432
|
+
return record;
|
|
4433
|
+
}
|
|
4434
|
+
};
|
|
4435
|
+
}
|
|
4436
|
+
|
|
3199
4437
|
// src/server/kora-sync-server.ts
|
|
3200
4438
|
var DEFAULT_MAX_CONNECTIONS = 0;
|
|
3201
4439
|
var DEFAULT_BATCH_SIZE2 = 100;
|
|
3202
4440
|
var DEFAULT_SCHEMA_VERSION2 = 1;
|
|
3203
4441
|
var DEFAULT_HOST = "0.0.0.0";
|
|
3204
4442
|
var DEFAULT_PATH = "/";
|
|
3205
|
-
var
|
|
4443
|
+
var DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
|
|
4444
|
+
var DEFAULT_DELIVERY_POLL_INTERVAL_MS = 2e3;
|
|
4445
|
+
function validateIntervalOption(name, value) {
|
|
4446
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
4447
|
+
throw new import_core14.SyncError(`${name} must be a non-negative integer number of milliseconds`, {
|
|
4448
|
+
[name]: value
|
|
4449
|
+
});
|
|
4450
|
+
}
|
|
4451
|
+
return value;
|
|
4452
|
+
}
|
|
4453
|
+
var KoraSyncServer = class _KoraSyncServer {
|
|
3206
4454
|
store;
|
|
3207
4455
|
auth;
|
|
3208
4456
|
serializer;
|
|
3209
4457
|
emitter;
|
|
3210
4458
|
maxConnections;
|
|
3211
4459
|
batchSize;
|
|
4460
|
+
relayRetransmitIntervalMs;
|
|
4461
|
+
deliveryPollIntervalMs;
|
|
3212
4462
|
schemaVersion;
|
|
3213
4463
|
supportedSchemaVersions;
|
|
4464
|
+
operationTransforms;
|
|
3214
4465
|
port;
|
|
3215
4466
|
host;
|
|
3216
4467
|
path;
|
|
@@ -3220,24 +4471,63 @@ var KoraSyncServer = class {
|
|
|
3220
4471
|
yjsDocRelay = new YjsDocRelay();
|
|
3221
4472
|
blobChunkRelay;
|
|
3222
4473
|
persistBlobChunk;
|
|
4474
|
+
maxOperationBytes;
|
|
4475
|
+
maxOpsPerMinute;
|
|
4476
|
+
validateOperation;
|
|
4477
|
+
koraContext;
|
|
3223
4478
|
sessions = /* @__PURE__ */ new Map();
|
|
3224
4479
|
httpClients = /* @__PURE__ */ new Map();
|
|
3225
4480
|
httpSessionToClient = /* @__PURE__ */ new Map();
|
|
3226
|
-
|
|
4481
|
+
// Informational value reported by getStatus(). Keep in sync with the
|
|
4482
|
+
// @korajs/server package version on release; it is not used for protocol negotiation.
|
|
4483
|
+
serverVersion = "1.0.0-beta.0";
|
|
3227
4484
|
wsServer = null;
|
|
3228
4485
|
running = false;
|
|
4486
|
+
/**
|
|
4487
|
+
* Periodic tick that retransmits relay batches connected clients have not
|
|
4488
|
+
* acknowledged, so a relay dropped by a lossy transport is redelivered instead of
|
|
4489
|
+
* leaving that client with a permanent version-vector gap (a lost operation).
|
|
4490
|
+
*/
|
|
4491
|
+
relayRetransmitTimer = null;
|
|
4492
|
+
deliveryPollTimer = null;
|
|
4493
|
+
lastObservedDeliverySequence = 0;
|
|
4494
|
+
deliveryPollInFlight = false;
|
|
4495
|
+
/**
|
|
4496
|
+
* Relay operations a client never acknowledged before it disconnected, buffered by
|
|
4497
|
+
* client node id so they can be redelivered on its next connection. Bounded per
|
|
4498
|
+
* node and expired by age so a client that never returns cannot grow this forever.
|
|
4499
|
+
*/
|
|
4500
|
+
orphanedRelaysByNode = /* @__PURE__ */ new Map();
|
|
4501
|
+
static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
|
|
4502
|
+
static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
|
|
3229
4503
|
constructor(config) {
|
|
3230
4504
|
this.store = config.store;
|
|
4505
|
+
const storeSchemaVersion = this.store.getSchema()?.version;
|
|
4506
|
+
if (config.schemaVersion !== void 0 && storeSchemaVersion !== void 0 && config.schemaVersion !== storeSchemaVersion) {
|
|
4507
|
+
throw new import_core14.SyncError("Sync schema version does not match the configured store schema.", {
|
|
4508
|
+
syncSchemaVersion: config.schemaVersion,
|
|
4509
|
+
storeSchemaVersion
|
|
4510
|
+
});
|
|
4511
|
+
}
|
|
3231
4512
|
this.auth = config.auth ?? null;
|
|
3232
4513
|
this.serializer = config.serializer ?? new import_sync5.JsonMessageSerializer();
|
|
3233
4514
|
this.emitter = config.emitter ?? null;
|
|
3234
4515
|
this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
3235
4516
|
this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
|
|
3236
|
-
this.
|
|
4517
|
+
this.relayRetransmitIntervalMs = validateIntervalOption(
|
|
4518
|
+
"relayRetransmitIntervalMs",
|
|
4519
|
+
config.relayRetransmitIntervalMs ?? DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS
|
|
4520
|
+
);
|
|
4521
|
+
this.deliveryPollIntervalMs = validateIntervalOption(
|
|
4522
|
+
"deliveryPollIntervalMs",
|
|
4523
|
+
config.deliveryPollIntervalMs ?? DEFAULT_DELIVERY_POLL_INTERVAL_MS
|
|
4524
|
+
);
|
|
4525
|
+
this.schemaVersion = config.schemaVersion ?? storeSchemaVersion ?? DEFAULT_SCHEMA_VERSION2;
|
|
3237
4526
|
this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
|
|
3238
4527
|
min: this.schemaVersion,
|
|
3239
4528
|
max: this.schemaVersion
|
|
3240
4529
|
};
|
|
4530
|
+
this.operationTransforms = config.operationTransforms ?? [];
|
|
3241
4531
|
this.port = config.port;
|
|
3242
4532
|
this.host = config.host ?? DEFAULT_HOST;
|
|
3243
4533
|
this.path = config.path ?? DEFAULT_PATH;
|
|
@@ -3246,10 +4536,116 @@ var KoraSyncServer = class {
|
|
|
3246
4536
|
this.metrics.setSchemaVersion(this.schemaVersion);
|
|
3247
4537
|
this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
|
|
3248
4538
|
this.persistBlobChunk = config.persistBlobChunk ?? null;
|
|
4539
|
+
this.maxOperationBytes = config.maxOperationBytes;
|
|
4540
|
+
this.maxOpsPerMinute = config.maxOpsPerMinute;
|
|
4541
|
+
this.validateOperation = config.validateOperation;
|
|
4542
|
+
this.koraContext = createRouteContext(this, this.store);
|
|
3249
4543
|
if (!this.emitter) {
|
|
3250
4544
|
this.emitter = new import_internal2.SimpleEventEmitter();
|
|
3251
4545
|
}
|
|
3252
4546
|
}
|
|
4547
|
+
ensureBackgroundTimersStarted() {
|
|
4548
|
+
if (this.relayRetransmitIntervalMs > 0 && !this.relayRetransmitTimer) {
|
|
4549
|
+
this.relayRetransmitTimer = setInterval(() => {
|
|
4550
|
+
this.retransmitPendingRelays(this.relayRetransmitIntervalMs);
|
|
4551
|
+
}, this.relayRetransmitIntervalMs);
|
|
4552
|
+
this.relayRetransmitTimer.unref?.();
|
|
4553
|
+
}
|
|
4554
|
+
if (this.deliveryPollIntervalMs > 0 && !this.deliveryPollTimer) {
|
|
4555
|
+
this.deliveryPollTimer = setInterval(() => {
|
|
4556
|
+
void this.pollDeliveryLog();
|
|
4557
|
+
}, this.deliveryPollIntervalMs);
|
|
4558
|
+
this.deliveryPollTimer.unref?.();
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4561
|
+
/**
|
|
4562
|
+
* Retransmit relay batches that connected clients have not acknowledged within
|
|
4563
|
+
* `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
|
|
4564
|
+
* already-applied operation is harmless (clients dedup by content-addressed id).
|
|
4565
|
+
*/
|
|
4566
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
4567
|
+
for (const session of this.sessions.values()) {
|
|
4568
|
+
session.retransmitPendingRelays(staleMs);
|
|
4569
|
+
}
|
|
4570
|
+
this.expireOrphanedRelays();
|
|
4571
|
+
}
|
|
4572
|
+
/**
|
|
4573
|
+
* Check whether the authoritative delivery log advanced without passing through
|
|
4574
|
+
* this server instance, then wake delivery-watermark sessions to scan from their
|
|
4575
|
+
* own acknowledged cursors. Session-level filtering remains the only fan-out path.
|
|
4576
|
+
*/
|
|
4577
|
+
async pollDeliveryLog() {
|
|
4578
|
+
if (this.deliveryPollInFlight) return;
|
|
4579
|
+
this.deliveryPollInFlight = true;
|
|
4580
|
+
try {
|
|
4581
|
+
const maxDeliverySequence = await this.store.getMaxDeliverySequence();
|
|
4582
|
+
if (maxDeliverySequence <= this.lastObservedDeliverySequence) {
|
|
4583
|
+
for (const session of this.sessions.values()) {
|
|
4584
|
+
session.pushDeliveryStreamIfSupported(this.deliveryPollIntervalMs, {
|
|
4585
|
+
trackStall: true
|
|
4586
|
+
});
|
|
4587
|
+
}
|
|
4588
|
+
return;
|
|
4589
|
+
}
|
|
4590
|
+
this.lastObservedDeliverySequence = maxDeliverySequence;
|
|
4591
|
+
for (const session of this.sessions.values()) {
|
|
4592
|
+
session.pushDeliveryStreamIfSupported(0);
|
|
4593
|
+
}
|
|
4594
|
+
} catch (error) {
|
|
4595
|
+
this.logger.log({
|
|
4596
|
+
timestamp: Date.now(),
|
|
4597
|
+
level: "warn",
|
|
4598
|
+
event: "delivery_poll.failed",
|
|
4599
|
+
details: { error: error instanceof Error ? error.message : String(error) }
|
|
4600
|
+
});
|
|
4601
|
+
} finally {
|
|
4602
|
+
this.deliveryPollInFlight = false;
|
|
4603
|
+
}
|
|
4604
|
+
}
|
|
4605
|
+
/**
|
|
4606
|
+
* Buffer relay operations a disconnected client never acknowledged, keyed by its
|
|
4607
|
+
* node id, deduped by operation id and bounded by count. Redelivered when the
|
|
4608
|
+
* client reconnects (see {@link takeOrphanedRelays}).
|
|
4609
|
+
*/
|
|
4610
|
+
bufferOrphanedRelays(nodeId, ops) {
|
|
4611
|
+
if (ops.length === 0) return;
|
|
4612
|
+
const existing = this.orphanedRelaysByNode.get(nodeId);
|
|
4613
|
+
const merged = existing ? existing.ops : [];
|
|
4614
|
+
const seen = new Set(merged.map((op) => op.id));
|
|
4615
|
+
for (const op of ops) {
|
|
4616
|
+
if (!seen.has(op.id)) {
|
|
4617
|
+
merged.push(op);
|
|
4618
|
+
seen.add(op.id);
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
|
|
4622
|
+
this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
|
|
4623
|
+
}
|
|
4624
|
+
/** Remove and return a node's buffered orphaned relay operations, if any. */
|
|
4625
|
+
takeOrphanedRelays(nodeId) {
|
|
4626
|
+
const entry = this.orphanedRelaysByNode.get(nodeId);
|
|
4627
|
+
if (!entry) return [];
|
|
4628
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4629
|
+
return entry.ops;
|
|
4630
|
+
}
|
|
4631
|
+
/** Drop buffered orphaned relays older than the TTL (client never returned). */
|
|
4632
|
+
expireOrphanedRelays() {
|
|
4633
|
+
if (this.orphanedRelaysByNode.size === 0) return;
|
|
4634
|
+
const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
|
|
4635
|
+
for (const [nodeId, entry] of this.orphanedRelaysByNode) {
|
|
4636
|
+
if (entry.bufferedAtMs <= cutoff) {
|
|
4637
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4638
|
+
}
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
/**
|
|
4642
|
+
* The trusted, scoped data-plane context (`apply` / `query` / `findById`).
|
|
4643
|
+
* Shared with the production server so HTTP routes, the operation validator,
|
|
4644
|
+
* and `server.kora` are all the same object over the same pipeline.
|
|
4645
|
+
*/
|
|
4646
|
+
getKoraContext() {
|
|
4647
|
+
return this.koraContext;
|
|
4648
|
+
}
|
|
3253
4649
|
/**
|
|
3254
4650
|
* Subscribe to session-level events for metrics collection and logging.
|
|
3255
4651
|
* Called when a new session is created.
|
|
@@ -3298,6 +4694,21 @@ var KoraSyncServer = class {
|
|
|
3298
4694
|
details: { reason: event.reason }
|
|
3299
4695
|
});
|
|
3300
4696
|
});
|
|
4697
|
+
sessionEmitter.on("sync:schema-mismatch", (event) => {
|
|
4698
|
+
this.logger.log({
|
|
4699
|
+
timestamp: Date.now(),
|
|
4700
|
+
level: "warn",
|
|
4701
|
+
event: "session.schema_mismatch",
|
|
4702
|
+
sessionId,
|
|
4703
|
+
details: {
|
|
4704
|
+
clientSchemaVersion: event.clientSchemaVersion,
|
|
4705
|
+
serverSchemaVersion: event.serverSchemaVersion,
|
|
4706
|
+
supportedMin: event.supportedMin,
|
|
4707
|
+
supportedMax: event.supportedMax,
|
|
4708
|
+
reason: event.reason
|
|
4709
|
+
}
|
|
4710
|
+
});
|
|
4711
|
+
});
|
|
3301
4712
|
}
|
|
3302
4713
|
/**
|
|
3303
4714
|
* Get the metrics collector for external access (e.g., HTTP endpoints).
|
|
@@ -3318,10 +4729,10 @@ var KoraSyncServer = class {
|
|
|
3318
4729
|
*/
|
|
3319
4730
|
async start(wsServerImpl) {
|
|
3320
4731
|
if (this.running) {
|
|
3321
|
-
throw new
|
|
4732
|
+
throw new import_core14.SyncError("Server is already running", { port: this.port });
|
|
3322
4733
|
}
|
|
3323
4734
|
if (!wsServerImpl && this.port === void 0) {
|
|
3324
|
-
throw new
|
|
4735
|
+
throw new import_core14.SyncError(
|
|
3325
4736
|
"Port is required for standalone mode. Provide port in config or use handleConnection() for attach mode.",
|
|
3326
4737
|
{}
|
|
3327
4738
|
);
|
|
@@ -3367,6 +4778,15 @@ var KoraSyncServer = class {
|
|
|
3367
4778
|
event: "server.stopping",
|
|
3368
4779
|
details: { connectedClients: this.sessions.size }
|
|
3369
4780
|
});
|
|
4781
|
+
if (this.relayRetransmitTimer) {
|
|
4782
|
+
clearInterval(this.relayRetransmitTimer);
|
|
4783
|
+
this.relayRetransmitTimer = null;
|
|
4784
|
+
}
|
|
4785
|
+
if (this.deliveryPollTimer) {
|
|
4786
|
+
clearInterval(this.deliveryPollTimer);
|
|
4787
|
+
this.deliveryPollTimer = null;
|
|
4788
|
+
}
|
|
4789
|
+
this.orphanedRelaysByNode.clear();
|
|
3370
4790
|
this.awarenessRelay.clear();
|
|
3371
4791
|
this.yjsDocRelay.clear();
|
|
3372
4792
|
this.blobChunkRelay.clear();
|
|
@@ -3431,7 +4851,7 @@ var KoraSyncServer = class {
|
|
|
3431
4851
|
if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
|
|
3432
4852
|
transport.send({
|
|
3433
4853
|
type: "error",
|
|
3434
|
-
messageId: (0,
|
|
4854
|
+
messageId: (0, import_core14.generateUUIDv7)(),
|
|
3435
4855
|
code: "MAX_CONNECTIONS",
|
|
3436
4856
|
message: `Server has reached maximum connections (${this.maxConnections})`,
|
|
3437
4857
|
retriable: true
|
|
@@ -3444,12 +4864,13 @@ var KoraSyncServer = class {
|
|
|
3444
4864
|
event: "connection.rejected",
|
|
3445
4865
|
details: { reason: "max_connections", max: this.maxConnections }
|
|
3446
4866
|
});
|
|
3447
|
-
throw new
|
|
4867
|
+
throw new import_core14.SyncError("Maximum connections reached", {
|
|
3448
4868
|
current: this.sessions.size,
|
|
3449
4869
|
max: this.maxConnections
|
|
3450
4870
|
});
|
|
3451
4871
|
}
|
|
3452
|
-
|
|
4872
|
+
this.ensureBackgroundTimersStarted();
|
|
4873
|
+
const sessionId = (0, import_core14.generateUUIDv7)();
|
|
3453
4874
|
this.metrics.recordConnection(sessionId);
|
|
3454
4875
|
const sessionEmitter = new import_internal2.SimpleEventEmitter();
|
|
3455
4876
|
sessionEmitter.on("sync:connected", (event) => {
|
|
@@ -3495,6 +4916,33 @@ var KoraSyncServer = class {
|
|
|
3495
4916
|
sessionId
|
|
3496
4917
|
});
|
|
3497
4918
|
});
|
|
4919
|
+
sessionEmitter.on("sync:schema-mismatch", (event) => {
|
|
4920
|
+
this.logger.log({
|
|
4921
|
+
timestamp: Date.now(),
|
|
4922
|
+
level: "warn",
|
|
4923
|
+
event: "session.schema_mismatch",
|
|
4924
|
+
sessionId,
|
|
4925
|
+
details: {
|
|
4926
|
+
clientSchemaVersion: event.clientSchemaVersion,
|
|
4927
|
+
serverSchemaVersion: event.serverSchemaVersion,
|
|
4928
|
+
supportedMin: event.supportedMin,
|
|
4929
|
+
supportedMax: event.supportedMax,
|
|
4930
|
+
reason: event.reason
|
|
4931
|
+
}
|
|
4932
|
+
});
|
|
4933
|
+
});
|
|
4934
|
+
sessionEmitter.on("sync:delivery-stalled", (event) => {
|
|
4935
|
+
this.logger.log({
|
|
4936
|
+
timestamp: Date.now(),
|
|
4937
|
+
level: "warn",
|
|
4938
|
+
event: "session.delivery_stalled",
|
|
4939
|
+
sessionId,
|
|
4940
|
+
details: {
|
|
4941
|
+
watermark: event.watermark,
|
|
4942
|
+
repeatCount: event.repeatCount
|
|
4943
|
+
}
|
|
4944
|
+
});
|
|
4945
|
+
});
|
|
3498
4946
|
const session = new ClientSession({
|
|
3499
4947
|
sessionId,
|
|
3500
4948
|
transport,
|
|
@@ -3502,9 +4950,11 @@ var KoraSyncServer = class {
|
|
|
3502
4950
|
auth: this.auth ?? void 0,
|
|
3503
4951
|
serializer: this.serializer,
|
|
3504
4952
|
emitter: sessionEmitter,
|
|
4953
|
+
logger: this.logger,
|
|
3505
4954
|
batchSize: this.batchSize,
|
|
3506
4955
|
schemaVersion: this.schemaVersion,
|
|
3507
4956
|
supportedSchemaVersions: this.supportedSchemaVersions,
|
|
4957
|
+
operationTransforms: this.operationTransforms,
|
|
3508
4958
|
onRelay: (sourceSessionId, operations2) => {
|
|
3509
4959
|
this.handleRelay(sourceSessionId, operations2);
|
|
3510
4960
|
},
|
|
@@ -3521,9 +4971,18 @@ var KoraSyncServer = class {
|
|
|
3521
4971
|
this.blobChunkRelay.handleResponse(sourceSessionId, message);
|
|
3522
4972
|
},
|
|
3523
4973
|
...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
|
|
4974
|
+
// Only forward when configured, so an unset server value leaves the
|
|
4975
|
+
// session on its own documented default rather than `undefined`.
|
|
4976
|
+
...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
|
|
4977
|
+
...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
|
|
4978
|
+
...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
|
|
3524
4979
|
onClose: (sid) => {
|
|
3525
4980
|
this.handleSessionClose(sid);
|
|
3526
|
-
}
|
|
4981
|
+
},
|
|
4982
|
+
onOrphanedRelays: (nodeId, ops) => {
|
|
4983
|
+
this.bufferOrphanedRelays(nodeId, ops);
|
|
4984
|
+
},
|
|
4985
|
+
takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
|
|
3527
4986
|
});
|
|
3528
4987
|
this.sessions.set(sessionId, session);
|
|
3529
4988
|
this.yjsDocRelay.addClient(sessionId, transport);
|
|
@@ -3583,6 +5042,22 @@ var KoraSyncServer = class {
|
|
|
3583
5042
|
}
|
|
3584
5043
|
return result;
|
|
3585
5044
|
}
|
|
5045
|
+
/**
|
|
5046
|
+
* Relay already-applied, server-originated operations to every connected client.
|
|
5047
|
+
* Used by the conditional route apply, which commits its operations atomically
|
|
5048
|
+
* through the store (bypassing {@link applyLocalOperation}) and then fans them
|
|
5049
|
+
* out. Each session still enforces its own per-scope visibility filter.
|
|
5050
|
+
*
|
|
5051
|
+
* @param operations - Operations that have already been committed to the store
|
|
5052
|
+
*/
|
|
5053
|
+
relayServerOperations(operations2) {
|
|
5054
|
+
if (operations2.length === 0) {
|
|
5055
|
+
return;
|
|
5056
|
+
}
|
|
5057
|
+
for (const session of this.sessions.values()) {
|
|
5058
|
+
session.relayOperations(operations2);
|
|
5059
|
+
}
|
|
5060
|
+
}
|
|
3586
5061
|
/**
|
|
3587
5062
|
* Collect every blob reference still reachable from live records on the server.
|
|
3588
5063
|
*
|
|
@@ -3607,7 +5082,7 @@ var KoraSyncServer = class {
|
|
|
3607
5082
|
const records = await this.store.materializeCollection(name);
|
|
3608
5083
|
for (const record of records) {
|
|
3609
5084
|
for (const value of Object.values(record)) {
|
|
3610
|
-
if ((0,
|
|
5085
|
+
if ((0, import_core14.isBlobRef)(value)) {
|
|
3611
5086
|
refs.push(value);
|
|
3612
5087
|
}
|
|
3613
5088
|
}
|
|
@@ -3787,162 +5262,6 @@ function createKoraServer(config) {
|
|
|
3787
5262
|
|
|
3788
5263
|
// src/server/production-server.ts
|
|
3789
5264
|
init_cjs_shims();
|
|
3790
|
-
|
|
3791
|
-
// src/server/route-context.ts
|
|
3792
|
-
init_cjs_shims();
|
|
3793
|
-
var import_core13 = require("@korajs/core");
|
|
3794
|
-
function recordMatchesScope(collection, record, scope) {
|
|
3795
|
-
const collectionScope = scope[collection];
|
|
3796
|
-
if (!collectionScope) {
|
|
3797
|
-
return false;
|
|
3798
|
-
}
|
|
3799
|
-
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
3800
|
-
if (record[field] !== expected) {
|
|
3801
|
-
return false;
|
|
3802
|
-
}
|
|
3803
|
-
}
|
|
3804
|
-
return true;
|
|
3805
|
-
}
|
|
3806
|
-
function pickFields(record, keys) {
|
|
3807
|
-
const picked = {};
|
|
3808
|
-
for (const key of keys) {
|
|
3809
|
-
if (key in record) {
|
|
3810
|
-
picked[key] = record[key];
|
|
3811
|
-
}
|
|
3812
|
-
}
|
|
3813
|
-
return picked;
|
|
3814
|
-
}
|
|
3815
|
-
async function buildRouteOperation(store, mutation) {
|
|
3816
|
-
const nodeId = store.getNodeId();
|
|
3817
|
-
const clock = new import_core13.HybridLogicalClock(nodeId);
|
|
3818
|
-
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
3819
|
-
const sequenceNumber = nextServerSequenceNumber(store);
|
|
3820
|
-
if (mutation.type === "insert") {
|
|
3821
|
-
return (0, import_core13.createOperation)(
|
|
3822
|
-
{
|
|
3823
|
-
nodeId,
|
|
3824
|
-
type: "insert",
|
|
3825
|
-
collection: mutation.collection,
|
|
3826
|
-
recordId: mutation.recordId ?? (0, import_core13.generateUUIDv7)(),
|
|
3827
|
-
data: mutation.data ?? {},
|
|
3828
|
-
previousData: null,
|
|
3829
|
-
sequenceNumber,
|
|
3830
|
-
causalDeps: [],
|
|
3831
|
-
schemaVersion
|
|
3832
|
-
},
|
|
3833
|
-
clock
|
|
3834
|
-
);
|
|
3835
|
-
}
|
|
3836
|
-
if (!mutation.recordId) {
|
|
3837
|
-
throw new RouteMutationError(
|
|
3838
|
-
"MISSING_RECORD_ID",
|
|
3839
|
-
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
3840
|
-
);
|
|
3841
|
-
}
|
|
3842
|
-
const current = await store.findRecord(mutation.collection, mutation.recordId);
|
|
3843
|
-
if (mutation.type === "update") {
|
|
3844
|
-
const data = mutation.data ?? {};
|
|
3845
|
-
return (0, import_core13.createOperation)(
|
|
3846
|
-
{
|
|
3847
|
-
nodeId,
|
|
3848
|
-
type: "update",
|
|
3849
|
-
collection: mutation.collection,
|
|
3850
|
-
recordId: mutation.recordId,
|
|
3851
|
-
data,
|
|
3852
|
-
previousData: current ? pickFields(current, Object.keys(data)) : {},
|
|
3853
|
-
sequenceNumber,
|
|
3854
|
-
causalDeps: [],
|
|
3855
|
-
schemaVersion
|
|
3856
|
-
},
|
|
3857
|
-
clock
|
|
3858
|
-
);
|
|
3859
|
-
}
|
|
3860
|
-
return (0, import_core13.createOperation)(
|
|
3861
|
-
{
|
|
3862
|
-
nodeId,
|
|
3863
|
-
type: "delete",
|
|
3864
|
-
collection: mutation.collection,
|
|
3865
|
-
recordId: mutation.recordId,
|
|
3866
|
-
data: null,
|
|
3867
|
-
previousData: current ? { ...current } : null,
|
|
3868
|
-
sequenceNumber,
|
|
3869
|
-
causalDeps: [],
|
|
3870
|
-
schemaVersion
|
|
3871
|
-
},
|
|
3872
|
-
clock
|
|
3873
|
-
);
|
|
3874
|
-
}
|
|
3875
|
-
var RouteMutationError = class extends Error {
|
|
3876
|
-
constructor(code, message) {
|
|
3877
|
-
super(message);
|
|
3878
|
-
this.code = code;
|
|
3879
|
-
this.name = "RouteMutationError";
|
|
3880
|
-
}
|
|
3881
|
-
code;
|
|
3882
|
-
};
|
|
3883
|
-
function createRouteContext(server, store) {
|
|
3884
|
-
let mutationTail = Promise.resolve();
|
|
3885
|
-
function serialize(work) {
|
|
3886
|
-
const run = mutationTail.then(work, work);
|
|
3887
|
-
mutationTail = run.then(
|
|
3888
|
-
() => void 0,
|
|
3889
|
-
() => void 0
|
|
3890
|
-
);
|
|
3891
|
-
return run;
|
|
3892
|
-
}
|
|
3893
|
-
return {
|
|
3894
|
-
apply(mutation, options) {
|
|
3895
|
-
return serialize(async () => {
|
|
3896
|
-
let op;
|
|
3897
|
-
try {
|
|
3898
|
-
op = await buildRouteOperation(store, mutation);
|
|
3899
|
-
} catch (error) {
|
|
3900
|
-
if (error instanceof RouteMutationError) {
|
|
3901
|
-
return { ok: false, code: error.code, message: error.message };
|
|
3902
|
-
}
|
|
3903
|
-
throw error;
|
|
3904
|
-
}
|
|
3905
|
-
if (options?.scope && !operationMatchesScopes(op, options.scope)) {
|
|
3906
|
-
return {
|
|
3907
|
-
ok: false,
|
|
3908
|
-
code: "SCOPE_VIOLATION",
|
|
3909
|
-
message: `Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
3910
|
-
};
|
|
3911
|
-
}
|
|
3912
|
-
const result = await server.applyLocalOperation(op);
|
|
3913
|
-
if (result.result !== "applied") {
|
|
3914
|
-
return {
|
|
3915
|
-
ok: false,
|
|
3916
|
-
code: result.rejection?.code ?? "NOT_APPLIED",
|
|
3917
|
-
message: result.rejection?.message ?? `Operation on "${mutation.collection}" was not applied (${result.result}).`
|
|
3918
|
-
};
|
|
3919
|
-
}
|
|
3920
|
-
const record = await store.findRecord(mutation.collection, op.recordId);
|
|
3921
|
-
return { ok: true, operation: op, record };
|
|
3922
|
-
});
|
|
3923
|
-
},
|
|
3924
|
-
async query(collection, options) {
|
|
3925
|
-
const { scope, ...queryOptions } = options ?? {};
|
|
3926
|
-
const records = await store.queryCollection(collection, queryOptions);
|
|
3927
|
-
if (!scope) {
|
|
3928
|
-
return records;
|
|
3929
|
-
}
|
|
3930
|
-
return records.filter((record) => recordMatchesScope(collection, record, scope));
|
|
3931
|
-
},
|
|
3932
|
-
async findById(collection, id, options) {
|
|
3933
|
-
const record = await store.findRecord(collection, id);
|
|
3934
|
-
if (!record) {
|
|
3935
|
-
return null;
|
|
3936
|
-
}
|
|
3937
|
-
if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
|
|
3938
|
-
return null;
|
|
3939
|
-
}
|
|
3940
|
-
return record;
|
|
3941
|
-
}
|
|
3942
|
-
};
|
|
3943
|
-
}
|
|
3944
|
-
|
|
3945
|
-
// src/server/production-server.ts
|
|
3946
5265
|
var MIME_TYPES = {
|
|
3947
5266
|
".html": "text/html",
|
|
3948
5267
|
".js": "text/javascript",
|
|
@@ -3961,12 +5280,13 @@ function createProductionServer(config) {
|
|
|
3961
5280
|
const port = config.port ?? (Number(process.env.PORT) || 3001);
|
|
3962
5281
|
const staticDir = config.staticDir ?? "./dist";
|
|
3963
5282
|
const syncPath = config.syncPath ?? "/kora-sync";
|
|
5283
|
+
const crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy ?? "credentialless";
|
|
3964
5284
|
const syncServer = new KoraSyncServer({
|
|
3965
5285
|
store: config.store,
|
|
3966
5286
|
enableDashboard: true,
|
|
3967
5287
|
...config.syncOptions
|
|
3968
5288
|
});
|
|
3969
|
-
const routeContext =
|
|
5289
|
+
const routeContext = syncServer.getKoraContext();
|
|
3970
5290
|
let httpServer = null;
|
|
3971
5291
|
function getOperationalToken(kind) {
|
|
3972
5292
|
const auth = config.operationalAuth;
|
|
@@ -4106,6 +5426,10 @@ function createProductionServer(config) {
|
|
|
4106
5426
|
res.end(JSON.stringify(result.body ?? null));
|
|
4107
5427
|
}
|
|
4108
5428
|
return {
|
|
5429
|
+
kora: routeContext,
|
|
5430
|
+
getLiveBlobRefs() {
|
|
5431
|
+
return syncServer.getLiveBlobRefs();
|
|
5432
|
+
},
|
|
4109
5433
|
async start() {
|
|
4110
5434
|
const { createServer } = await import("http");
|
|
4111
5435
|
const { createReadStream, existsSync, statSync } = await import("fs");
|
|
@@ -4127,7 +5451,7 @@ function createProductionServer(config) {
|
|
|
4127
5451
|
});
|
|
4128
5452
|
async function handleRequest(req, res) {
|
|
4129
5453
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
4130
|
-
res.setHeader("Cross-Origin-Embedder-Policy",
|
|
5454
|
+
res.setHeader("Cross-Origin-Embedder-Policy", crossOriginEmbedderPolicy);
|
|
4131
5455
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
4132
5456
|
if (url.pathname === "/health") {
|
|
4133
5457
|
const status = await syncServer.getStatus();
|
|
@@ -4301,8 +5625,13 @@ data: ${JSON.stringify(s)}
|
|
|
4301
5625
|
socket.destroy();
|
|
4302
5626
|
}
|
|
4303
5627
|
});
|
|
4304
|
-
return new Promise((resolve2) => {
|
|
5628
|
+
return new Promise((resolve2, reject) => {
|
|
5629
|
+
const onError = (error) => {
|
|
5630
|
+
reject(error);
|
|
5631
|
+
};
|
|
5632
|
+
httpServer?.once("error", onError);
|
|
4305
5633
|
httpServer?.listen(port, "0.0.0.0", () => {
|
|
5634
|
+
httpServer?.off("error", onError);
|
|
4306
5635
|
resolve2(`http://localhost:${port}`);
|
|
4307
5636
|
});
|
|
4308
5637
|
});
|
|
@@ -4405,6 +5734,7 @@ function formatUptime(ms) {
|
|
|
4405
5734
|
MixedAuthProvider,
|
|
4406
5735
|
NoAuthProvider,
|
|
4407
5736
|
PostgresServerStore,
|
|
5737
|
+
RETRIABLE_REJECTION_CODES,
|
|
4408
5738
|
SqliteServerStore,
|
|
4409
5739
|
TokenAuthProvider,
|
|
4410
5740
|
WsServerTransport,
|
|
@@ -4415,6 +5745,7 @@ function formatUptime(ms) {
|
|
|
4415
5745
|
createPrettyLogger,
|
|
4416
5746
|
createProductionServer,
|
|
4417
5747
|
createSilentLogger,
|
|
4418
|
-
createSqliteServerStore
|
|
5748
|
+
createSqliteServerStore,
|
|
5749
|
+
isRetriableRejection
|
|
4419
5750
|
});
|
|
4420
5751
|
//# sourceMappingURL=index.cjs.map
|