@korajs/server 1.0.0-beta.4 → 1.0.0-beta.6
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 +873 -129
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +310 -2
- package/dist/index.d.ts +310 -2
- package/dist/index.js +873 -129
- 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
|
};
|
|
@@ -345,30 +345,6 @@ function generateAllCollectionDDL(schema, dialect) {
|
|
|
345
345
|
}
|
|
346
346
|
return statements;
|
|
347
347
|
}
|
|
348
|
-
function replayOperationsForRecord(ops) {
|
|
349
|
-
let record = null;
|
|
350
|
-
let deleted = false;
|
|
351
|
-
for (const op of ops) {
|
|
352
|
-
switch (op.type) {
|
|
353
|
-
case "insert":
|
|
354
|
-
if (op.data) {
|
|
355
|
-
record = { ...op.data };
|
|
356
|
-
deleted = false;
|
|
357
|
-
}
|
|
358
|
-
break;
|
|
359
|
-
case "update":
|
|
360
|
-
if (op.data) {
|
|
361
|
-
record = { ...record ?? {}, ...op.data };
|
|
362
|
-
deleted = false;
|
|
363
|
-
}
|
|
364
|
-
break;
|
|
365
|
-
case "delete":
|
|
366
|
-
deleted = true;
|
|
367
|
-
break;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
return deleted ? null : record;
|
|
371
|
-
}
|
|
372
348
|
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
373
349
|
var BASE64_REVERSE = new Map(
|
|
374
350
|
Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
|
|
@@ -419,6 +395,9 @@ function serializeFieldValue(value, descriptor) {
|
|
|
419
395
|
if (value === null || value === void 0) return null;
|
|
420
396
|
switch (descriptor.kind) {
|
|
421
397
|
case "array":
|
|
398
|
+
case "object":
|
|
399
|
+
case "json":
|
|
400
|
+
case "blob":
|
|
422
401
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
423
402
|
case "boolean":
|
|
424
403
|
return value ? 1 : 0;
|
|
@@ -432,6 +411,9 @@ function deserializeFieldValue(value, descriptor) {
|
|
|
432
411
|
if (value === null || value === void 0) return null;
|
|
433
412
|
switch (descriptor.kind) {
|
|
434
413
|
case "array":
|
|
414
|
+
case "object":
|
|
415
|
+
case "json":
|
|
416
|
+
case "blob":
|
|
435
417
|
return typeof value === "string" ? JSON.parse(value) : value;
|
|
436
418
|
case "boolean":
|
|
437
419
|
return value === 1 || value === true;
|
|
@@ -464,6 +446,14 @@ var MemoryServerStore = class {
|
|
|
464
446
|
operations = [];
|
|
465
447
|
operationIndex = /* @__PURE__ */ new Map();
|
|
466
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;
|
|
467
457
|
schema = null;
|
|
468
458
|
/** Materialized records: collection -> recordId -> record data */
|
|
469
459
|
materializedRecords = /* @__PURE__ */ new Map();
|
|
@@ -497,6 +487,8 @@ var MemoryServerStore = class {
|
|
|
497
487
|
}
|
|
498
488
|
this.operations.push(op);
|
|
499
489
|
this.operationIndex.set(op.id, op);
|
|
490
|
+
this.deliverySeqCounter += 1;
|
|
491
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
500
492
|
const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
|
|
501
493
|
if (op.sequenceNumber > currentSeq) {
|
|
502
494
|
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
@@ -516,6 +508,23 @@ var MemoryServerStore = class {
|
|
|
516
508
|
this.assertOpen();
|
|
517
509
|
return this.operations.length;
|
|
518
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
|
+
}
|
|
519
528
|
async materializeCollection(collection) {
|
|
520
529
|
this.assertOpen();
|
|
521
530
|
if (this.schema?.collections[collection]) {
|
|
@@ -660,12 +669,16 @@ var MemoryServerStore = class {
|
|
|
660
669
|
this.operations.length = 0;
|
|
661
670
|
this.operationIndex.clear();
|
|
662
671
|
this.versionVector.clear();
|
|
672
|
+
this.deliverySeqByOpId.clear();
|
|
673
|
+
this.deliverySeqCounter = 0;
|
|
663
674
|
for (const [nid, seq] of versionVector) {
|
|
664
675
|
this.versionVector.set(nid, seq);
|
|
665
676
|
}
|
|
666
677
|
for (const op of operations2) {
|
|
667
678
|
this.operations.push(op);
|
|
668
679
|
this.operationIndex.set(op.id, op);
|
|
680
|
+
this.deliverySeqCounter += 1;
|
|
681
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
669
682
|
if (this.schema?.collections[op.collection]) {
|
|
670
683
|
this.rebuildMaterializedRecord(op.collection, op.recordId);
|
|
671
684
|
}
|
|
@@ -695,13 +708,14 @@ var MemoryServerStore = class {
|
|
|
695
708
|
return a.timestamp.wallTime - b.timestamp.wallTime;
|
|
696
709
|
if (a.timestamp.logical !== b.timestamp.logical)
|
|
697
710
|
return a.timestamp.logical - b.timestamp.logical;
|
|
698
|
-
return a.
|
|
711
|
+
return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
|
|
699
712
|
});
|
|
700
713
|
const parsedOps = recordOps.map((op) => ({
|
|
701
714
|
type: op.type,
|
|
702
|
-
data: op.data
|
|
715
|
+
data: op.data,
|
|
716
|
+
atomicOps: op.atomicOps ?? null
|
|
703
717
|
}));
|
|
704
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
718
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
705
719
|
if (recordData) {
|
|
706
720
|
const createdAt = recordOps.length > 0 ? recordOps[0].timestamp.wallTime : Date.now();
|
|
707
721
|
const updatedAt = recordOps.length > 0 ? recordOps[recordOps.length - 1].timestamp.wallTime : Date.now();
|
|
@@ -824,6 +838,8 @@ var pgOperations = (0, import_pg_core.pgTable)(
|
|
|
824
838
|
// JSON-serialized, null for deletes
|
|
825
839
|
previousData: (0, import_pg_core.text)("previous_data"),
|
|
826
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
|
|
827
843
|
wallTime: (0, import_pg_core.bigint)("wall_time", { mode: "number" }).notNull(),
|
|
828
844
|
logical: (0, import_pg_core.integer)("logical").notNull(),
|
|
829
845
|
timestampNodeId: (0, import_pg_core.text)("timestamp_node_id").notNull(),
|
|
@@ -831,12 +847,18 @@ var pgOperations = (0, import_pg_core.pgTable)(
|
|
|
831
847
|
causalDeps: (0, import_pg_core.text)("causal_deps").notNull().default("[]"),
|
|
832
848
|
// JSON array of op IDs
|
|
833
849
|
schemaVersion: (0, import_pg_core.integer)("schema_version").notNull(),
|
|
834
|
-
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" })
|
|
835
856
|
},
|
|
836
857
|
(table) => ({
|
|
837
858
|
nodeSeqIdx: (0, import_pg_core.index)("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
838
859
|
collectionIdx: (0, import_pg_core.index)("idx_pg_collection").on(table.collection),
|
|
839
|
-
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)
|
|
840
862
|
})
|
|
841
863
|
);
|
|
842
864
|
var pgSyncState = (0, import_pg_core.pgTable)("sync_state", {
|
|
@@ -853,15 +875,43 @@ var PostgresServerStore = class {
|
|
|
853
875
|
ready;
|
|
854
876
|
schema = null;
|
|
855
877
|
closed = false;
|
|
856
|
-
|
|
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) {
|
|
857
894
|
this.db = db;
|
|
858
895
|
this.nodeId = nodeId ?? (0, import_core3.generateUUIDv7)();
|
|
896
|
+
this.timeSource = timeSource ?? { now: () => Date.now() };
|
|
859
897
|
this.ready = this.initialize();
|
|
860
898
|
}
|
|
861
899
|
getVersionVector() {
|
|
862
900
|
this.assertOpen();
|
|
863
901
|
return new Map(this.versionVector);
|
|
864
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
|
+
}
|
|
865
915
|
getNodeId() {
|
|
866
916
|
return this.nodeId;
|
|
867
917
|
}
|
|
@@ -899,8 +949,9 @@ var PostgresServerStore = class {
|
|
|
899
949
|
return "duplicate";
|
|
900
950
|
}
|
|
901
951
|
const now = Date.now();
|
|
902
|
-
const row = this.serializeOperation(op, now);
|
|
903
952
|
await this.db.transaction(async (tx) => {
|
|
953
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
954
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
904
955
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
905
956
|
await tx.insert(pgSyncState).values({
|
|
906
957
|
nodeId: op.nodeId,
|
|
@@ -923,6 +974,73 @@ var PostgresServerStore = class {
|
|
|
923
974
|
}
|
|
924
975
|
return "applied";
|
|
925
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
|
+
}
|
|
926
1044
|
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
927
1045
|
this.assertOpen();
|
|
928
1046
|
await this.ready;
|
|
@@ -937,6 +1055,23 @@ var PostgresServerStore = class {
|
|
|
937
1055
|
const result = await this.db.select({ value: (0, import_drizzle_orm.count)() }).from(pgOperations);
|
|
938
1056
|
return result[0]?.value ?? 0;
|
|
939
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
|
+
}
|
|
940
1075
|
async materializeCollection(collection) {
|
|
941
1076
|
this.assertOpen();
|
|
942
1077
|
await this.ready;
|
|
@@ -1000,7 +1135,7 @@ var PostgresServerStore = class {
|
|
|
1000
1135
|
this.assertOpen();
|
|
1001
1136
|
await this.ready;
|
|
1002
1137
|
const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
1003
|
-
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));
|
|
1004
1139
|
const operations2 = rows.map((row) => this.deserializeOperation(row));
|
|
1005
1140
|
return buildServerBackup2(this.nodeId, operations2, this.versionVector);
|
|
1006
1141
|
}
|
|
@@ -1015,6 +1150,7 @@ var PostgresServerStore = class {
|
|
|
1015
1150
|
const result = await this.applyRemoteOperation(op);
|
|
1016
1151
|
if (result === "applied") restored++;
|
|
1017
1152
|
}
|
|
1153
|
+
this.sequenceCounter = null;
|
|
1018
1154
|
return { operationsRestored: restored, success: true };
|
|
1019
1155
|
}
|
|
1020
1156
|
const now = Date.now();
|
|
@@ -1024,20 +1160,49 @@ var PostgresServerStore = class {
|
|
|
1024
1160
|
for (const [nid, seq] of versionVector) {
|
|
1025
1161
|
await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
|
|
1026
1162
|
}
|
|
1163
|
+
let deliverySeq = 0;
|
|
1027
1164
|
for (const op of operations2) {
|
|
1028
|
-
|
|
1165
|
+
deliverySeq += 1;
|
|
1166
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
1029
1167
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
1030
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
|
+
);
|
|
1031
1173
|
});
|
|
1032
1174
|
this.versionVector.clear();
|
|
1033
1175
|
for (const [nid, seq] of versionVector) {
|
|
1034
1176
|
this.versionVector.set(nid, seq);
|
|
1035
1177
|
}
|
|
1178
|
+
this.sequenceCounter = null;
|
|
1036
1179
|
return { operationsRestored: operations2.length, success: true };
|
|
1037
1180
|
}
|
|
1038
1181
|
// ---------------------------------------------------------------------------
|
|
1039
1182
|
// Materialization internals
|
|
1040
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
|
+
}
|
|
1041
1206
|
/**
|
|
1042
1207
|
* Rebuild a single record in the materialized collection table by replaying
|
|
1043
1208
|
* all operations for that record.
|
|
@@ -1048,17 +1213,19 @@ var PostgresServerStore = class {
|
|
|
1048
1213
|
const ops = await txOrDb.select({
|
|
1049
1214
|
type: pgOperations.type,
|
|
1050
1215
|
data: pgOperations.data,
|
|
1216
|
+
atomicOps: pgOperations.atomicOps,
|
|
1051
1217
|
wallTime: pgOperations.wallTime
|
|
1052
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(
|
|
1053
1219
|
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
1054
1220
|
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
1055
|
-
(0, import_drizzle_orm.asc)(pgOperations.
|
|
1221
|
+
(0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
|
|
1056
1222
|
);
|
|
1057
1223
|
const parsedOps = ops.map((op) => ({
|
|
1058
1224
|
type: op.type,
|
|
1059
|
-
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
|
|
1060
1227
|
}));
|
|
1061
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1228
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1062
1229
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
1063
1230
|
if (recordData) {
|
|
1064
1231
|
const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
|
|
@@ -1125,11 +1292,12 @@ var PostgresServerStore = class {
|
|
|
1125
1292
|
recordId: pgOperations.recordId,
|
|
1126
1293
|
type: pgOperations.type,
|
|
1127
1294
|
data: pgOperations.data,
|
|
1295
|
+
atomicOps: pgOperations.atomicOps,
|
|
1128
1296
|
wallTime: pgOperations.wallTime
|
|
1129
1297
|
}).from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collectionName)).orderBy(
|
|
1130
1298
|
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
1131
1299
|
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
1132
|
-
(0, import_drizzle_orm.asc)(pgOperations.
|
|
1300
|
+
(0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
|
|
1133
1301
|
);
|
|
1134
1302
|
if (allOps.length === 0) return;
|
|
1135
1303
|
const grouped = /* @__PURE__ */ new Map();
|
|
@@ -1145,9 +1313,10 @@ var PostgresServerStore = class {
|
|
|
1145
1313
|
for (const [recordId, recordOps] of grouped) {
|
|
1146
1314
|
const parsedOps = recordOps.map((op) => ({
|
|
1147
1315
|
type: op.type,
|
|
1148
|
-
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
|
|
1149
1318
|
}));
|
|
1150
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1319
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1151
1320
|
if (recordData) {
|
|
1152
1321
|
const createdAt = recordOps[0].wallTime;
|
|
1153
1322
|
const updatedAt = recordOps[recordOps.length - 1].wallTime;
|
|
@@ -1270,44 +1439,92 @@ var PostgresServerStore = class {
|
|
|
1270
1439
|
}
|
|
1271
1440
|
}
|
|
1272
1441
|
async ensureTables() {
|
|
1273
|
-
await this.db.
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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`
|
|
1298
1515
|
);
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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);
|
|
1306
1523
|
}
|
|
1307
1524
|
// ---------------------------------------------------------------------------
|
|
1308
1525
|
// Operation serialization
|
|
1309
1526
|
// ---------------------------------------------------------------------------
|
|
1310
|
-
serializeOperation(op, receivedAt) {
|
|
1527
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1311
1528
|
return {
|
|
1312
1529
|
id: op.id,
|
|
1313
1530
|
nodeId: op.nodeId,
|
|
@@ -1316,16 +1533,19 @@ var PostgresServerStore = class {
|
|
|
1316
1533
|
recordId: op.recordId,
|
|
1317
1534
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1318
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,
|
|
1319
1537
|
wallTime: op.timestamp.wallTime,
|
|
1320
1538
|
logical: op.timestamp.logical,
|
|
1321
1539
|
timestampNodeId: op.timestamp.nodeId,
|
|
1322
1540
|
sequenceNumber: op.sequenceNumber,
|
|
1323
1541
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1324
1542
|
schemaVersion: op.schemaVersion,
|
|
1325
|
-
receivedAt
|
|
1543
|
+
receivedAt,
|
|
1544
|
+
deliverySeq
|
|
1326
1545
|
};
|
|
1327
1546
|
}
|
|
1328
1547
|
deserializeOperation(row) {
|
|
1548
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1329
1549
|
return {
|
|
1330
1550
|
id: row.id,
|
|
1331
1551
|
nodeId: row.nodeId,
|
|
@@ -1341,7 +1561,8 @@ var PostgresServerStore = class {
|
|
|
1341
1561
|
},
|
|
1342
1562
|
sequenceNumber: row.sequenceNumber,
|
|
1343
1563
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1344
|
-
schemaVersion: row.schemaVersion
|
|
1564
|
+
schemaVersion: row.schemaVersion,
|
|
1565
|
+
...atomicOps ? { atomicOps } : {}
|
|
1345
1566
|
};
|
|
1346
1567
|
}
|
|
1347
1568
|
// ---------------------------------------------------------------------------
|
|
@@ -1411,6 +1632,8 @@ var operations = (0, import_sqlite_core.sqliteTable)(
|
|
|
1411
1632
|
// JSON-serialized, null for deletes
|
|
1412
1633
|
previousData: (0, import_sqlite_core.text)("previous_data"),
|
|
1413
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
|
|
1414
1637
|
wallTime: (0, import_sqlite_core.integer)("wall_time").notNull(),
|
|
1415
1638
|
logical: (0, import_sqlite_core.integer)("logical").notNull(),
|
|
1416
1639
|
timestampNodeId: (0, import_sqlite_core.text)("timestamp_node_id").notNull(),
|
|
@@ -1418,12 +1641,17 @@ var operations = (0, import_sqlite_core.sqliteTable)(
|
|
|
1418
1641
|
causalDeps: (0, import_sqlite_core.text)("causal_deps").notNull().default("[]"),
|
|
1419
1642
|
// JSON array of op IDs
|
|
1420
1643
|
schemaVersion: (0, import_sqlite_core.integer)("schema_version").notNull(),
|
|
1421
|
-
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")
|
|
1422
1649
|
},
|
|
1423
1650
|
(table) => ({
|
|
1424
1651
|
nodeSeqIdx: (0, import_sqlite_core.index)("idx_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
1425
1652
|
collectionIdx: (0, import_sqlite_core.index)("idx_collection").on(table.collection),
|
|
1426
|
-
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)
|
|
1427
1655
|
})
|
|
1428
1656
|
);
|
|
1429
1657
|
var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
|
|
@@ -1439,6 +1667,12 @@ var SqliteServerStore = class {
|
|
|
1439
1667
|
db;
|
|
1440
1668
|
schema = null;
|
|
1441
1669
|
closed = false;
|
|
1670
|
+
/**
|
|
1671
|
+
* Next delivery sequence to assign. Single-writer (better-sqlite3 is synchronous),
|
|
1672
|
+
* so a plain in-memory counter, seeded from MAX(delivery_seq) at startup, gives
|
|
1673
|
+
* commit-order delivery sequence. Assigned inside the append transaction.
|
|
1674
|
+
*/
|
|
1675
|
+
deliverySeqCounter = 0;
|
|
1442
1676
|
constructor(db, nodeId) {
|
|
1443
1677
|
this.db = db;
|
|
1444
1678
|
this.nodeId = nodeId ?? (0, import_core4.generateUUIDv7)();
|
|
@@ -1484,12 +1718,16 @@ var SqliteServerStore = class {
|
|
|
1484
1718
|
async applyRemoteOperation(op) {
|
|
1485
1719
|
this.assertOpen();
|
|
1486
1720
|
const now = Date.now();
|
|
1487
|
-
const row = this.serializeOperation(op, now);
|
|
1488
1721
|
const result = this.db.transaction((tx) => {
|
|
1489
|
-
const
|
|
1490
|
-
|
|
1722
|
+
const existing = tx.all(
|
|
1723
|
+
import_drizzle_orm2.sql`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
|
|
1724
|
+
);
|
|
1725
|
+
if (existing.length > 0) {
|
|
1491
1726
|
return "duplicate";
|
|
1492
1727
|
}
|
|
1728
|
+
this.deliverySeqCounter += 1;
|
|
1729
|
+
const row = this.serializeOperation(op, now, this.deliverySeqCounter);
|
|
1730
|
+
tx.insert(operations).values(row).run();
|
|
1493
1731
|
tx.insert(syncState).values({
|
|
1494
1732
|
nodeId: op.nodeId,
|
|
1495
1733
|
maxSequenceNumber: op.sequenceNumber,
|
|
@@ -1518,6 +1756,21 @@ var SqliteServerStore = class {
|
|
|
1518
1756
|
const result = this.db.select({ value: (0, import_drizzle_orm2.count)() }).from(operations).all();
|
|
1519
1757
|
return result[0]?.value ?? 0;
|
|
1520
1758
|
}
|
|
1759
|
+
async getMaxDeliverySequence() {
|
|
1760
|
+
this.assertOpen();
|
|
1761
|
+
const rows = this.db.all(
|
|
1762
|
+
import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
1763
|
+
);
|
|
1764
|
+
return rows[0]?.m ?? 0;
|
|
1765
|
+
}
|
|
1766
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
1767
|
+
this.assertOpen();
|
|
1768
|
+
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();
|
|
1769
|
+
return rows.map((row) => ({
|
|
1770
|
+
operation: this.deserializeOperation(row),
|
|
1771
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
1772
|
+
}));
|
|
1773
|
+
}
|
|
1521
1774
|
async materializeCollection(collection) {
|
|
1522
1775
|
this.assertOpen();
|
|
1523
1776
|
if (this.schema?.collections[collection]) {
|
|
@@ -1575,7 +1828,7 @@ var SqliteServerStore = class {
|
|
|
1575
1828
|
async exportBackup() {
|
|
1576
1829
|
this.assertOpen();
|
|
1577
1830
|
const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
1578
|
-
const rows = this.db.select().from(operations).all();
|
|
1831
|
+
const rows = this.db.select().from(operations).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).all();
|
|
1579
1832
|
const deserialized = rows.map((row) => this.deserializeOperation(row));
|
|
1580
1833
|
const vv = this.getVersionVector();
|
|
1581
1834
|
return buildServerBackup2(this.nodeId, deserialized, vv);
|
|
@@ -1598,10 +1851,13 @@ var SqliteServerStore = class {
|
|
|
1598
1851
|
for (const [nid, seq] of versionVector) {
|
|
1599
1852
|
tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
|
|
1600
1853
|
}
|
|
1854
|
+
let deliverySeq = 0;
|
|
1601
1855
|
for (const op of ops) {
|
|
1602
|
-
|
|
1856
|
+
deliverySeq += 1;
|
|
1857
|
+
const row = this.serializeOperation(op, Date.now(), deliverySeq);
|
|
1603
1858
|
tx.insert(operations).values(row).run();
|
|
1604
1859
|
}
|
|
1860
|
+
this.deliverySeqCounter = deliverySeq;
|
|
1605
1861
|
});
|
|
1606
1862
|
return { operationsRestored: ops.length, success: true };
|
|
1607
1863
|
}
|
|
@@ -1619,13 +1875,15 @@ var SqliteServerStore = class {
|
|
|
1619
1875
|
const ops = txOrDb.select({
|
|
1620
1876
|
type: operations.type,
|
|
1621
1877
|
data: operations.data,
|
|
1878
|
+
atomicOps: operations.atomicOps,
|
|
1622
1879
|
wallTime: operations.wallTime
|
|
1623
|
-
}).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.
|
|
1880
|
+
}).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();
|
|
1624
1881
|
const parsedOps = ops.map((op) => ({
|
|
1625
1882
|
type: op.type,
|
|
1626
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1883
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1884
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1627
1885
|
}));
|
|
1628
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1886
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1629
1887
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
1630
1888
|
if (recordData) {
|
|
1631
1889
|
const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
|
|
@@ -1695,8 +1953,9 @@ var SqliteServerStore = class {
|
|
|
1695
1953
|
recordId: operations.recordId,
|
|
1696
1954
|
type: operations.type,
|
|
1697
1955
|
data: operations.data,
|
|
1956
|
+
atomicOps: operations.atomicOps,
|
|
1698
1957
|
wallTime: operations.wallTime
|
|
1699
|
-
}).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.
|
|
1958
|
+
}).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();
|
|
1700
1959
|
if (allOps.length === 0) return;
|
|
1701
1960
|
const grouped = /* @__PURE__ */ new Map();
|
|
1702
1961
|
for (const op of allOps) {
|
|
@@ -1712,9 +1971,10 @@ var SqliteServerStore = class {
|
|
|
1712
1971
|
for (const [recordId, recordOps] of grouped) {
|
|
1713
1972
|
const parsedOps = recordOps.map((op) => ({
|
|
1714
1973
|
type: op.type,
|
|
1715
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1974
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1975
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1716
1976
|
}));
|
|
1717
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1977
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1718
1978
|
if (recordData) {
|
|
1719
1979
|
const createdAt = recordOps[0].wallTime;
|
|
1720
1980
|
const updatedAt = recordOps[recordOps.length - 1].wallTime;
|
|
@@ -1836,6 +2096,7 @@ var SqliteServerStore = class {
|
|
|
1836
2096
|
record_id TEXT NOT NULL,
|
|
1837
2097
|
data TEXT,
|
|
1838
2098
|
previous_data TEXT,
|
|
2099
|
+
atomic_ops TEXT,
|
|
1839
2100
|
wall_time INTEGER NOT NULL,
|
|
1840
2101
|
logical INTEGER NOT NULL,
|
|
1841
2102
|
timestamp_node_id TEXT NOT NULL,
|
|
@@ -1845,9 +2106,30 @@ var SqliteServerStore = class {
|
|
|
1845
2106
|
received_at INTEGER NOT NULL
|
|
1846
2107
|
)
|
|
1847
2108
|
`);
|
|
2109
|
+
try {
|
|
2110
|
+
this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
|
|
2111
|
+
} catch (e) {
|
|
2112
|
+
const msg = e instanceof Error ? e.message : "";
|
|
2113
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
2114
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
2115
|
+
throw e;
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
try {
|
|
2119
|
+
this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
|
|
2120
|
+
} catch (e) {
|
|
2121
|
+
const msg = e instanceof Error ? e.message : "";
|
|
2122
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
2123
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
2124
|
+
throw e;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
1848
2127
|
this.db.run(import_drizzle_orm2.sql`
|
|
1849
2128
|
CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
|
|
1850
2129
|
`);
|
|
2130
|
+
this.db.run(import_drizzle_orm2.sql`
|
|
2131
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
|
|
2132
|
+
`);
|
|
1851
2133
|
this.db.run(import_drizzle_orm2.sql`
|
|
1852
2134
|
CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
|
|
1853
2135
|
`);
|
|
@@ -1864,11 +2146,34 @@ var SqliteServerStore = class {
|
|
|
1864
2146
|
last_seen_at INTEGER NOT NULL
|
|
1865
2147
|
)
|
|
1866
2148
|
`);
|
|
2149
|
+
this.backfillDeliverySequence();
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* Assign delivery sequences to any operations written before the column existed,
|
|
2153
|
+
* then seed the in-memory counter from the current max. Ordered by
|
|
2154
|
+
* (received_at, sequence_number, id) so the backfill is deterministic and stable
|
|
2155
|
+
* across restarts. A one-time migration: after it runs, all rows have a value.
|
|
2156
|
+
*/
|
|
2157
|
+
backfillDeliverySequence() {
|
|
2158
|
+
this.db.transaction((tx) => {
|
|
2159
|
+
const maxRow = tx.all(
|
|
2160
|
+
import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
2161
|
+
);
|
|
2162
|
+
let next = maxRow[0]?.m ?? 0;
|
|
2163
|
+
const nullRows = tx.all(
|
|
2164
|
+
import_drizzle_orm2.sql`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
|
|
2165
|
+
);
|
|
2166
|
+
for (const row of nullRows) {
|
|
2167
|
+
next += 1;
|
|
2168
|
+
tx.run(import_drizzle_orm2.sql`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
|
|
2169
|
+
}
|
|
2170
|
+
this.deliverySeqCounter = next;
|
|
2171
|
+
});
|
|
1867
2172
|
}
|
|
1868
2173
|
// ---------------------------------------------------------------------------
|
|
1869
2174
|
// Operation serialization
|
|
1870
2175
|
// ---------------------------------------------------------------------------
|
|
1871
|
-
serializeOperation(op, receivedAt) {
|
|
2176
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1872
2177
|
return {
|
|
1873
2178
|
id: op.id,
|
|
1874
2179
|
nodeId: op.nodeId,
|
|
@@ -1877,16 +2182,19 @@ var SqliteServerStore = class {
|
|
|
1877
2182
|
recordId: op.recordId,
|
|
1878
2183
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1879
2184
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
2185
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1880
2186
|
wallTime: op.timestamp.wallTime,
|
|
1881
2187
|
logical: op.timestamp.logical,
|
|
1882
2188
|
timestampNodeId: op.timestamp.nodeId,
|
|
1883
2189
|
sequenceNumber: op.sequenceNumber,
|
|
1884
2190
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1885
2191
|
schemaVersion: op.schemaVersion,
|
|
1886
|
-
receivedAt
|
|
2192
|
+
receivedAt,
|
|
2193
|
+
deliverySeq
|
|
1887
2194
|
};
|
|
1888
2195
|
}
|
|
1889
2196
|
deserializeOperation(row) {
|
|
2197
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1890
2198
|
return {
|
|
1891
2199
|
id: row.id,
|
|
1892
2200
|
nodeId: row.nodeId,
|
|
@@ -1902,7 +2210,8 @@ var SqliteServerStore = class {
|
|
|
1902
2210
|
},
|
|
1903
2211
|
sequenceNumber: row.sequenceNumber,
|
|
1904
2212
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1905
|
-
schemaVersion: row.schemaVersion
|
|
2213
|
+
schemaVersion: row.schemaVersion,
|
|
2214
|
+
...atomicOps ? { atomicOps } : {}
|
|
1906
2215
|
};
|
|
1907
2216
|
}
|
|
1908
2217
|
// ---------------------------------------------------------------------------
|
|
@@ -2201,6 +2510,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
|
|
|
2201
2510
|
);
|
|
2202
2511
|
}
|
|
2203
2512
|
function nextServerSequenceNumber(store) {
|
|
2513
|
+
if (store.reserveSequenceNumber) {
|
|
2514
|
+
return store.reserveSequenceNumber();
|
|
2515
|
+
}
|
|
2204
2516
|
const nodeId = store.getNodeId();
|
|
2205
2517
|
const current = store.getVersionVector().get(nodeId) ?? 0;
|
|
2206
2518
|
return current + 1;
|
|
@@ -2242,16 +2554,14 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2242
2554
|
return { result: primaryResult, appliedOperations: [] };
|
|
2243
2555
|
}
|
|
2244
2556
|
const appliedOperations = [op];
|
|
2245
|
-
let serverSeq = nextServerSequenceNumber(store);
|
|
2246
2557
|
for (const effect of referential.sideEffectOps) {
|
|
2247
2558
|
const sideOp = await createServerSideEffectOperation(
|
|
2248
2559
|
store,
|
|
2249
2560
|
op,
|
|
2250
2561
|
effect,
|
|
2251
2562
|
op.schemaVersion,
|
|
2252
|
-
|
|
2563
|
+
nextServerSequenceNumber(store)
|
|
2253
2564
|
);
|
|
2254
|
-
serverSeq += 1;
|
|
2255
2565
|
const sideResult = await store.applyRemoteOperation(sideOp);
|
|
2256
2566
|
if (sideResult === "applied") {
|
|
2257
2567
|
appliedOperations.push(sideOp);
|
|
@@ -2312,11 +2622,11 @@ function mergeScopeMaps(base, overlay, source) {
|
|
|
2312
2622
|
|
|
2313
2623
|
// src/scopes/server-scope-filter.ts
|
|
2314
2624
|
init_cjs_shims();
|
|
2315
|
-
function operationMatchesScopes(op, scopes) {
|
|
2625
|
+
function operationMatchesScopes(op, scopes, fullRecord) {
|
|
2316
2626
|
if (!scopes) return true;
|
|
2317
2627
|
const collectionScope = scopes[op.collection];
|
|
2318
2628
|
if (!collectionScope) return false;
|
|
2319
|
-
const snapshot = buildSnapshot(op);
|
|
2629
|
+
const snapshot = buildSnapshot(op, fullRecord);
|
|
2320
2630
|
if (!snapshot) return false;
|
|
2321
2631
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
2322
2632
|
if (snapshot[field] !== expected) {
|
|
@@ -2325,11 +2635,19 @@ function operationMatchesScopes(op, scopes) {
|
|
|
2325
2635
|
}
|
|
2326
2636
|
return true;
|
|
2327
2637
|
}
|
|
2328
|
-
function
|
|
2638
|
+
function missingScopeFields(op, scopes) {
|
|
2639
|
+
if (!scopes) return [];
|
|
2640
|
+
const collectionScope = scopes[op.collection];
|
|
2641
|
+
if (!collectionScope) return [];
|
|
2642
|
+
const snapshot = buildSnapshot(op);
|
|
2643
|
+
return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
|
|
2644
|
+
}
|
|
2645
|
+
function buildSnapshot(op, fullRecord) {
|
|
2329
2646
|
const previous = asRecord(op.previousData);
|
|
2330
2647
|
const next = asRecord(op.data);
|
|
2331
|
-
if (!previous && !next) return null;
|
|
2648
|
+
if (!previous && !next && !fullRecord) return null;
|
|
2332
2649
|
return {
|
|
2650
|
+
...fullRecord ?? {},
|
|
2333
2651
|
...previous ?? {},
|
|
2334
2652
|
...next ?? {}
|
|
2335
2653
|
};
|
|
@@ -2422,18 +2740,48 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
|
|
|
2422
2740
|
"[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.)"
|
|
2423
2741
|
);
|
|
2424
2742
|
}
|
|
2425
|
-
var ClientSession = class {
|
|
2743
|
+
var ClientSession = class _ClientSession {
|
|
2426
2744
|
state = "connected";
|
|
2427
2745
|
clientNodeId = null;
|
|
2428
2746
|
authContext = null;
|
|
2429
2747
|
syncQuerySubsets = [];
|
|
2430
2748
|
resumeDeltaCursor = null;
|
|
2749
|
+
/**
|
|
2750
|
+
* The delivery watermark the client reported at handshake, or null when the client
|
|
2751
|
+
* does not use the delivery watermark (old client, or no watermark yet). When set,
|
|
2752
|
+
* the server drives the gap-free server->client stream from delivery sequences
|
|
2753
|
+
* instead of the version-vector delta.
|
|
2754
|
+
*/
|
|
2755
|
+
clientDeliveryWatermark = null;
|
|
2756
|
+
/**
|
|
2757
|
+
* The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
|
|
2758
|
+
* watermark, as reported in acks). Incremental streaming pushes resume from here, not
|
|
2759
|
+
* from the highest sequence sent, so a dropped or unapplied batch is always
|
|
2760
|
+
* re-included by the next push (which re-scans from the acknowledged position). This
|
|
2761
|
+
* is what makes streaming recovery independent of any bounded retransmit buffer: there
|
|
2762
|
+
* is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
|
|
2763
|
+
* client's reported watermark at handshake.
|
|
2764
|
+
*/
|
|
2765
|
+
lastAckedDeliverySeq = 0;
|
|
2766
|
+
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
2767
|
+
deliveryPushChain = Promise.resolve();
|
|
2768
|
+
/**
|
|
2769
|
+
* Relay batches sent to this client but not yet acknowledged, keyed by messageId.
|
|
2770
|
+
* The client acks every operation-batch it applies; on ack the entry is cleared.
|
|
2771
|
+
* {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
|
|
2772
|
+
* dropped by a lossy transport is redelivered instead of leaving the client with a
|
|
2773
|
+
* permanent version-vector gap (a lost operation) that delta sync cannot recover.
|
|
2774
|
+
* Bounded so a silent client cannot grow it without limit.
|
|
2775
|
+
*/
|
|
2776
|
+
pendingRelays = /* @__PURE__ */ new Map();
|
|
2777
|
+
static MAX_PENDING_RELAYS = 1e3;
|
|
2431
2778
|
sessionId;
|
|
2432
2779
|
transport;
|
|
2433
2780
|
store;
|
|
2434
2781
|
auth;
|
|
2435
2782
|
serializer;
|
|
2436
2783
|
emitter;
|
|
2784
|
+
logger;
|
|
2437
2785
|
batchSize;
|
|
2438
2786
|
schemaVersion;
|
|
2439
2787
|
supportedSchemaVersions;
|
|
@@ -2444,6 +2792,8 @@ var ClientSession = class {
|
|
|
2444
2792
|
onBlobChunkResponse;
|
|
2445
2793
|
persistBlobChunk;
|
|
2446
2794
|
onClose;
|
|
2795
|
+
onOrphanedRelays;
|
|
2796
|
+
takeOrphanedRelays;
|
|
2447
2797
|
maxOperationBytes;
|
|
2448
2798
|
maxOpsPerMinute;
|
|
2449
2799
|
rateLimiter;
|
|
@@ -2456,6 +2806,7 @@ var ClientSession = class {
|
|
|
2456
2806
|
this.auth = options.auth ?? null;
|
|
2457
2807
|
this.serializer = options.serializer ?? new import_sync3.NegotiatedMessageSerializer("json");
|
|
2458
2808
|
this.emitter = options.emitter ?? null;
|
|
2809
|
+
this.logger = options.logger ?? null;
|
|
2459
2810
|
this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2460
2811
|
this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
2461
2812
|
const supported = options.supportedSchemaVersions;
|
|
@@ -2470,6 +2821,8 @@ var ClientSession = class {
|
|
|
2470
2821
|
this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
|
|
2471
2822
|
this.persistBlobChunk = options.persistBlobChunk ?? null;
|
|
2472
2823
|
this.onClose = options.onClose ?? null;
|
|
2824
|
+
this.onOrphanedRelays = options.onOrphanedRelays ?? null;
|
|
2825
|
+
this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
|
|
2473
2826
|
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2474
2827
|
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
2475
2828
|
this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
|
|
@@ -2494,9 +2847,40 @@ var ClientSession = class {
|
|
|
2494
2847
|
*/
|
|
2495
2848
|
relayOperations(operations2) {
|
|
2496
2849
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2850
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2851
|
+
this.pushDeliveryStream();
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2497
2854
|
if (operations2.length === 0) return;
|
|
2498
|
-
|
|
2855
|
+
void this.relayVisibleOperations(operations2);
|
|
2856
|
+
}
|
|
2857
|
+
/**
|
|
2858
|
+
* Push newly-available in-scope operations to a delivery-watermark client, resuming
|
|
2859
|
+
* from the highest delivery sequence already sent. Pushes are serialized so their
|
|
2860
|
+
* batches never interleave (which would break the base/max chain).
|
|
2861
|
+
*/
|
|
2862
|
+
pushDeliveryStream() {
|
|
2863
|
+
this.deliveryPushChain = this.deliveryPushChain.then(async () => {
|
|
2864
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2865
|
+
try {
|
|
2866
|
+
await this.sendDeliveryStream(
|
|
2867
|
+
this.lastAckedDeliverySeq,
|
|
2868
|
+
false,
|
|
2869
|
+
this.clientNodeId ?? void 0
|
|
2870
|
+
);
|
|
2871
|
+
} catch {
|
|
2872
|
+
}
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
async relayVisibleOperations(operations2) {
|
|
2876
|
+
const visibleOperations = [];
|
|
2877
|
+
for (const op of operations2) {
|
|
2878
|
+
if (await this.operationVisibleToClient(op)) {
|
|
2879
|
+
visibleOperations.push(op);
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2499
2882
|
if (visibleOperations.length === 0) return;
|
|
2883
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2500
2884
|
const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
|
|
2501
2885
|
const msg = {
|
|
2502
2886
|
type: "operation-batch",
|
|
@@ -2505,14 +2889,62 @@ var ClientSession = class {
|
|
|
2505
2889
|
isFinal: true,
|
|
2506
2890
|
batchIndex: 0
|
|
2507
2891
|
};
|
|
2892
|
+
this.trackPendingRelay(msg, visibleOperations);
|
|
2508
2893
|
this.sendToClient(msg);
|
|
2509
2894
|
}
|
|
2895
|
+
/** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
|
|
2896
|
+
trackPendingRelay(msg, ops) {
|
|
2897
|
+
if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
|
|
2898
|
+
const oldest = this.pendingRelays.keys().next().value;
|
|
2899
|
+
if (oldest !== void 0) {
|
|
2900
|
+
this.pendingRelays.delete(oldest);
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
|
|
2904
|
+
}
|
|
2905
|
+
/**
|
|
2906
|
+
* Retransmit relay batches this client has not acknowledged within `staleMs`.
|
|
2907
|
+
* Called on a periodic tick by the server (and directly by tests). Redelivering an
|
|
2908
|
+
* already-applied op is harmless: the client dedups by content-addressed id.
|
|
2909
|
+
*/
|
|
2910
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
2911
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2912
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2913
|
+
this.pushDeliveryStream();
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
if (this.pendingRelays.size === 0) return;
|
|
2917
|
+
const cutoff = Date.now() - staleMs;
|
|
2918
|
+
for (const { message, sentAtMs } of this.pendingRelays.values()) {
|
|
2919
|
+
if (sentAtMs <= cutoff) {
|
|
2920
|
+
this.sendToClient(message);
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2510
2924
|
/**
|
|
2511
2925
|
* Close this session.
|
|
2512
2926
|
*/
|
|
2927
|
+
/**
|
|
2928
|
+
* Hand any unacknowledged relays to the server so they survive this session and are
|
|
2929
|
+
* redelivered when this client reconnects (per-node buffer), then clear them. Called
|
|
2930
|
+
* from every session-teardown path (explicit close and transport close).
|
|
2931
|
+
*/
|
|
2932
|
+
flushOrphanedRelays() {
|
|
2933
|
+
if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
|
|
2934
|
+
const ops = [];
|
|
2935
|
+
for (const entry of this.pendingRelays.values()) {
|
|
2936
|
+
ops.push(...entry.ops);
|
|
2937
|
+
}
|
|
2938
|
+
if (ops.length > 0) {
|
|
2939
|
+
this.onOrphanedRelays(this.clientNodeId, ops);
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
this.pendingRelays.clear();
|
|
2943
|
+
}
|
|
2513
2944
|
close(reason) {
|
|
2514
2945
|
if (this.state === "closed") return;
|
|
2515
2946
|
this.state = "closed";
|
|
2947
|
+
this.flushOrphanedRelays();
|
|
2516
2948
|
if (this.transport.isConnected()) {
|
|
2517
2949
|
this.transport.close(1e3, reason ?? "session closed");
|
|
2518
2950
|
}
|
|
@@ -2567,6 +2999,10 @@ var ClientSession = class {
|
|
|
2567
2999
|
await this.handleOperationBatch(message);
|
|
2568
3000
|
break;
|
|
2569
3001
|
case "acknowledgment":
|
|
3002
|
+
this.pendingRelays.delete(message.acknowledgedMessageId);
|
|
3003
|
+
if (message.deliverySequence !== void 0) {
|
|
3004
|
+
this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
|
|
3005
|
+
}
|
|
2570
3006
|
break;
|
|
2571
3007
|
case "error":
|
|
2572
3008
|
break;
|
|
@@ -2605,6 +3041,18 @@ var ClientSession = class {
|
|
|
2605
3041
|
}
|
|
2606
3042
|
handleMessageFailure(error) {
|
|
2607
3043
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
3044
|
+
this.logger?.log({
|
|
3045
|
+
timestamp: Date.now(),
|
|
3046
|
+
level: "error",
|
|
3047
|
+
event: "session.message_failed",
|
|
3048
|
+
sessionId: this.sessionId,
|
|
3049
|
+
nodeId: this.clientNodeId ?? void 0,
|
|
3050
|
+
error: reason,
|
|
3051
|
+
details: {
|
|
3052
|
+
state: this.state,
|
|
3053
|
+
errorName: error instanceof Error ? error.name : typeof error
|
|
3054
|
+
}
|
|
3055
|
+
});
|
|
2608
3056
|
this.sendError("SYNC_ERROR", reason, true);
|
|
2609
3057
|
this.close(reason);
|
|
2610
3058
|
}
|
|
@@ -2643,6 +3091,15 @@ var ClientSession = class {
|
|
|
2643
3091
|
this.syncQuerySubsets = [];
|
|
2644
3092
|
}
|
|
2645
3093
|
this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
|
|
3094
|
+
this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
|
|
3095
|
+
const clientUsesWatermark = this.clientDeliveryWatermark !== null;
|
|
3096
|
+
let serverMaxDelivery = 0;
|
|
3097
|
+
if (clientUsesWatermark) {
|
|
3098
|
+
serverMaxDelivery = await this.store.getMaxDeliverySequence();
|
|
3099
|
+
if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
|
|
3100
|
+
this.clientDeliveryWatermark = 0;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
2646
3103
|
const serverVector = this.store.getVersionVector();
|
|
2647
3104
|
const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
|
|
2648
3105
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
@@ -2673,6 +3130,10 @@ var ClientSession = class {
|
|
|
2673
3130
|
accepted: true,
|
|
2674
3131
|
selectedWireFormat,
|
|
2675
3132
|
serverTime: Date.now(),
|
|
3133
|
+
// The server's highest delivery sequence, so a client whose persisted watermark
|
|
3134
|
+
// is ahead of it (server rolled back) can reset to a full resync. Sent only to
|
|
3135
|
+
// watermark-using clients.
|
|
3136
|
+
...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
|
|
2676
3137
|
// Advertise central blob storage so the client uploads the bytes behind
|
|
2677
3138
|
// its blob fields, keeping them available after the author goes offline.
|
|
2678
3139
|
...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
|
|
@@ -2683,16 +3144,28 @@ var ClientSession = class {
|
|
|
2683
3144
|
this.sendToClient(response);
|
|
2684
3145
|
this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
|
|
2685
3146
|
this.state = "syncing";
|
|
2686
|
-
|
|
2687
|
-
|
|
3147
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
3148
|
+
this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
|
|
3149
|
+
const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
|
|
3150
|
+
await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
|
|
3151
|
+
} else {
|
|
3152
|
+
const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
|
|
3153
|
+
await this.sendDelta(clientVector);
|
|
3154
|
+
}
|
|
2688
3155
|
this.state = "streaming";
|
|
3156
|
+
if (this.takeOrphanedRelays && this.clientNodeId) {
|
|
3157
|
+
const buffered = this.takeOrphanedRelays(this.clientNodeId);
|
|
3158
|
+
if (buffered.length > 0) {
|
|
3159
|
+
this.relayOperations(buffered);
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
2689
3162
|
}
|
|
2690
3163
|
async handleOperationBatch(msg) {
|
|
2691
3164
|
const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2692
3165
|
const applied = [];
|
|
2693
3166
|
const rejected = [];
|
|
2694
3167
|
for (const op of operations2) {
|
|
2695
|
-
if (!this.operationVisibleToClient(op)) {
|
|
3168
|
+
if (!await this.operationVisibleToClient(op)) {
|
|
2696
3169
|
rejected.push(op);
|
|
2697
3170
|
continue;
|
|
2698
3171
|
}
|
|
@@ -2746,20 +3219,17 @@ var ClientSession = class {
|
|
|
2746
3219
|
continue;
|
|
2747
3220
|
}
|
|
2748
3221
|
}
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
applied.push(...applyResult.appliedOperations);
|
|
2761
|
-
}
|
|
2762
|
-
} catch {
|
|
3222
|
+
const applyResult = await applyServerOperation(this.store, op);
|
|
3223
|
+
if (applyResult.rejection) {
|
|
3224
|
+
this.sendError(
|
|
3225
|
+
applyResult.rejection.code,
|
|
3226
|
+
applyResult.rejection.message,
|
|
3227
|
+
applyResult.rejection.retriable
|
|
3228
|
+
);
|
|
3229
|
+
continue;
|
|
3230
|
+
}
|
|
3231
|
+
if (applyResult.result === "applied") {
|
|
3232
|
+
applied.push(...applyResult.appliedOperations);
|
|
2763
3233
|
}
|
|
2764
3234
|
}
|
|
2765
3235
|
if (rejected.length > 0) {
|
|
@@ -2797,8 +3267,11 @@ var ClientSession = class {
|
|
|
2797
3267
|
const clientSeq = clientVector.get(nodeId) ?? 0;
|
|
2798
3268
|
if (serverSeq > clientSeq) {
|
|
2799
3269
|
const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
|
|
2800
|
-
const
|
|
2801
|
-
|
|
3270
|
+
for (const op of ops) {
|
|
3271
|
+
if (await this.operationVisibleToClient(op)) {
|
|
3272
|
+
missing.push(op);
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
2802
3275
|
}
|
|
2803
3276
|
}
|
|
2804
3277
|
if (missing.length === 0) {
|
|
@@ -2850,11 +3323,111 @@ var ClientSession = class {
|
|
|
2850
3323
|
});
|
|
2851
3324
|
}
|
|
2852
3325
|
}
|
|
2853
|
-
|
|
2854
|
-
|
|
3326
|
+
/**
|
|
3327
|
+
* Send the gap-free server->client delivery stream, resuming just after
|
|
3328
|
+
* `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
|
|
3329
|
+
* (commit order), scope-filtered for this session, and sent in batches that chain:
|
|
3330
|
+
* each batch's `baseDeliverySequence` equals the previous batch's
|
|
3331
|
+
* `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
|
|
3332
|
+
* applies a batch only when its watermark equals the base and advances the
|
|
3333
|
+
* watermark to the max, so a dropped batch stalls the watermark and is recovered by
|
|
3334
|
+
* the next handshake resend. Because delivery-sequence order respects causal order
|
|
3335
|
+
* (a dependency is always committed, and thus sequenced, before its dependent), no
|
|
3336
|
+
* topological sort is needed and the client never defers on a missing dependency
|
|
3337
|
+
* that is itself in this stream.
|
|
3338
|
+
*
|
|
3339
|
+
* The final batch advances the watermark to the highest delivery sequence scanned,
|
|
3340
|
+
* not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
|
|
3341
|
+
* next reconnect. Returns the number of operations actually sent.
|
|
3342
|
+
*/
|
|
3343
|
+
async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
|
|
3344
|
+
const scanChunk = Math.max(this.batchSize, 1) * 5;
|
|
3345
|
+
let scanCursor = fromDeliverySeq;
|
|
3346
|
+
let maxScanned = fromDeliverySeq;
|
|
3347
|
+
const visible = [];
|
|
3348
|
+
while (true) {
|
|
3349
|
+
const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
|
|
3350
|
+
const last = chunk[chunk.length - 1];
|
|
3351
|
+
if (last === void 0) break;
|
|
3352
|
+
scanCursor = last.deliverySequence;
|
|
3353
|
+
maxScanned = scanCursor;
|
|
3354
|
+
for (const delivered of chunk) {
|
|
3355
|
+
if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
|
|
3356
|
+
continue;
|
|
3357
|
+
}
|
|
3358
|
+
if (await this.operationVisibleToClient(delivered.operation)) {
|
|
3359
|
+
visible.push(delivered);
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
if (chunk.length < scanChunk) break;
|
|
3363
|
+
}
|
|
3364
|
+
if (visible.length === 0) {
|
|
3365
|
+
if (finalizeWhenEmpty) {
|
|
3366
|
+
this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
|
|
3367
|
+
}
|
|
3368
|
+
return 0;
|
|
3369
|
+
}
|
|
3370
|
+
const totalBatches = Math.ceil(visible.length / this.batchSize);
|
|
3371
|
+
let base = fromDeliverySeq;
|
|
3372
|
+
for (let i = 0; i < totalBatches; i++) {
|
|
3373
|
+
const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
|
|
3374
|
+
const lastInSlice = slice[slice.length - 1];
|
|
3375
|
+
if (lastInSlice === void 0) continue;
|
|
3376
|
+
const isFinal = i === totalBatches - 1;
|
|
3377
|
+
const lastSeq = lastInSlice.deliverySequence;
|
|
3378
|
+
const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
|
|
3379
|
+
this.sendDeliveryBatch(slice, base, max, i, isFinal);
|
|
3380
|
+
base = max;
|
|
3381
|
+
}
|
|
3382
|
+
return visible.length;
|
|
3383
|
+
}
|
|
3384
|
+
/** Build, track, and send one chained delivery-stream batch. */
|
|
3385
|
+
sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
|
|
3386
|
+
const batchMsg = {
|
|
3387
|
+
type: "operation-batch",
|
|
3388
|
+
messageId: (0, import_core10.generateUUIDv7)(),
|
|
3389
|
+
operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
|
|
3390
|
+
isFinal,
|
|
3391
|
+
batchIndex,
|
|
3392
|
+
baseDeliverySequence: base,
|
|
3393
|
+
maxDeliverySequence: max
|
|
3394
|
+
};
|
|
3395
|
+
this.sendToClient(batchMsg);
|
|
3396
|
+
if (slice.length > 0) {
|
|
3397
|
+
this.emitter?.emit({
|
|
3398
|
+
type: "sync:sent",
|
|
3399
|
+
operations: slice.map((delivered) => delivered.operation),
|
|
3400
|
+
batchSize: slice.length
|
|
3401
|
+
});
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
async operationVisibleToClient(op) {
|
|
3405
|
+
const scopes = this.authContext?.scopes;
|
|
3406
|
+
const subsets = this.syncQuerySubsets;
|
|
3407
|
+
const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
|
|
3408
|
+
const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
|
|
3409
|
+
if (!operationMatchesScopes(op, scopes, fullRecord)) {
|
|
2855
3410
|
return false;
|
|
2856
3411
|
}
|
|
2857
|
-
return (0, import_sync3.operationMatchesQuerySubsets)(op,
|
|
3412
|
+
return (0, import_sync3.operationMatchesQuerySubsets)(op, subsets, fullRecord);
|
|
3413
|
+
}
|
|
3414
|
+
/**
|
|
3415
|
+
* Read a record's current field values from the materialized store for scope /
|
|
3416
|
+
* query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
|
|
3417
|
+
* carries no fields) is still judged against the record's actual scope. Returns
|
|
3418
|
+
* undefined when the record cannot be read (never materialized, or no schema).
|
|
3419
|
+
*/
|
|
3420
|
+
async lookupRecordFields(collection, recordId) {
|
|
3421
|
+
try {
|
|
3422
|
+
const rows = await this.store.queryCollection(collection, {
|
|
3423
|
+
where: { id: recordId },
|
|
3424
|
+
includeDeleted: true,
|
|
3425
|
+
limit: 1
|
|
3426
|
+
});
|
|
3427
|
+
return rows[0];
|
|
3428
|
+
} catch {
|
|
3429
|
+
return void 0;
|
|
3430
|
+
}
|
|
2858
3431
|
}
|
|
2859
3432
|
handleAwarenessUpdate(msg) {
|
|
2860
3433
|
this.onAwarenessUpdate?.(this.sessionId, msg);
|
|
@@ -2899,6 +3472,7 @@ var ClientSession = class {
|
|
|
2899
3472
|
handleTransportClose() {
|
|
2900
3473
|
if (this.state === "closed") return;
|
|
2901
3474
|
this.state = "closed";
|
|
3475
|
+
this.flushOrphanedRelays();
|
|
2902
3476
|
this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
|
|
2903
3477
|
this.onClose?.(this.sessionId);
|
|
2904
3478
|
}
|
|
@@ -3349,9 +3923,9 @@ function pickFields(record, keys) {
|
|
|
3349
3923
|
}
|
|
3350
3924
|
return picked;
|
|
3351
3925
|
}
|
|
3352
|
-
async function buildRouteOperation(store, mutation) {
|
|
3926
|
+
async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
|
|
3353
3927
|
const nodeId = store.getNodeId();
|
|
3354
|
-
const clock = new import_core12.HybridLogicalClock(nodeId);
|
|
3928
|
+
const clock = clockOverride ?? new import_core12.HybridLogicalClock(nodeId);
|
|
3355
3929
|
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
3356
3930
|
const sequenceNumber = nextServerSequenceNumber(store);
|
|
3357
3931
|
if (mutation.type === "insert") {
|
|
@@ -3376,7 +3950,7 @@ async function buildRouteOperation(store, mutation) {
|
|
|
3376
3950
|
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
3377
3951
|
);
|
|
3378
3952
|
}
|
|
3379
|
-
const current = await store.findRecord(mutation.collection, mutation.recordId);
|
|
3953
|
+
const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
|
|
3380
3954
|
if (mutation.type === "update") {
|
|
3381
3955
|
const rawData = mutation.data ?? {};
|
|
3382
3956
|
const data = {};
|
|
@@ -3493,11 +4067,88 @@ function createRouteContext(server, store) {
|
|
|
3493
4067
|
}
|
|
3494
4068
|
return commitOperation(prepared.op, mutation.collection);
|
|
3495
4069
|
}
|
|
4070
|
+
async function runConditionalViaStore(storeApplyConditional, spec, options) {
|
|
4071
|
+
const scope = options?.scope;
|
|
4072
|
+
const mutations = [];
|
|
4073
|
+
const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
|
|
4074
|
+
if (hasTargetUpdate) {
|
|
4075
|
+
mutations.push({
|
|
4076
|
+
collection: spec.collection,
|
|
4077
|
+
type: "update",
|
|
4078
|
+
recordId: spec.id,
|
|
4079
|
+
data: spec.update
|
|
4080
|
+
});
|
|
4081
|
+
}
|
|
4082
|
+
if (spec.also) {
|
|
4083
|
+
mutations.push(...spec.also);
|
|
4084
|
+
}
|
|
4085
|
+
let result;
|
|
4086
|
+
try {
|
|
4087
|
+
result = await storeApplyConditional({
|
|
4088
|
+
target: { collection: spec.collection, id: spec.id },
|
|
4089
|
+
...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
|
|
4090
|
+
admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
|
|
4091
|
+
buildOperations: async (current, context) => {
|
|
4092
|
+
const ops = [];
|
|
4093
|
+
for (let i = 0; i < mutations.length; i++) {
|
|
4094
|
+
const mutation = mutations[i];
|
|
4095
|
+
const useLockedCurrent = i === 0 && hasTargetUpdate;
|
|
4096
|
+
const op = await buildRouteOperation(
|
|
4097
|
+
store,
|
|
4098
|
+
mutation,
|
|
4099
|
+
useLockedCurrent ? { record: current } : void 0,
|
|
4100
|
+
context.clock
|
|
4101
|
+
);
|
|
4102
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
4103
|
+
throw new RouteMutationError(
|
|
4104
|
+
"SCOPE_VIOLATION",
|
|
4105
|
+
`Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
4106
|
+
);
|
|
4107
|
+
}
|
|
4108
|
+
ops.push(op);
|
|
4109
|
+
}
|
|
4110
|
+
return ops;
|
|
4111
|
+
}
|
|
4112
|
+
});
|
|
4113
|
+
} catch (error) {
|
|
4114
|
+
if (error instanceof RouteMutationError) {
|
|
4115
|
+
return {
|
|
4116
|
+
ok: false,
|
|
4117
|
+
code: error.code,
|
|
4118
|
+
message: error.message,
|
|
4119
|
+
retriable: isRetriableRejection(error.code)
|
|
4120
|
+
};
|
|
4121
|
+
}
|
|
4122
|
+
throw error;
|
|
4123
|
+
}
|
|
4124
|
+
if (!result.admitted) {
|
|
4125
|
+
return {
|
|
4126
|
+
ok: false,
|
|
4127
|
+
code: spec.reject?.code ?? "CONDITION_NOT_MET",
|
|
4128
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
4129
|
+
retriable: false
|
|
4130
|
+
};
|
|
4131
|
+
}
|
|
4132
|
+
if (result.idempotent) {
|
|
4133
|
+
const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
|
|
4134
|
+
return { ok: true, idempotent: true, operations: [], records: records2 };
|
|
4135
|
+
}
|
|
4136
|
+
server.relayServerOperations(result.applied);
|
|
4137
|
+
const records = [];
|
|
4138
|
+
for (const op of result.applied) {
|
|
4139
|
+
records.push(await store.findRecord(op.collection, op.recordId));
|
|
4140
|
+
}
|
|
4141
|
+
return { ok: true, idempotent: false, operations: result.applied, records };
|
|
4142
|
+
}
|
|
3496
4143
|
return {
|
|
3497
4144
|
apply(mutation, options) {
|
|
3498
4145
|
return serialize(() => rawApply(mutation, options?.scope));
|
|
3499
4146
|
},
|
|
3500
4147
|
applyConditional(spec, options) {
|
|
4148
|
+
const storeApplyConditional = store.applyConditional;
|
|
4149
|
+
if (storeApplyConditional) {
|
|
4150
|
+
return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
|
|
4151
|
+
}
|
|
3501
4152
|
return serialize(async () => {
|
|
3502
4153
|
if (spec.idempotencyKey) {
|
|
3503
4154
|
const existing = await store.findRecord(
|
|
@@ -3585,7 +4236,8 @@ var DEFAULT_BATCH_SIZE2 = 100;
|
|
|
3585
4236
|
var DEFAULT_SCHEMA_VERSION2 = 1;
|
|
3586
4237
|
var DEFAULT_HOST = "0.0.0.0";
|
|
3587
4238
|
var DEFAULT_PATH = "/";
|
|
3588
|
-
var
|
|
4239
|
+
var RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
|
|
4240
|
+
var KoraSyncServer = class _KoraSyncServer {
|
|
3589
4241
|
store;
|
|
3590
4242
|
auth;
|
|
3591
4243
|
serializer;
|
|
@@ -3615,6 +4267,20 @@ var KoraSyncServer = class {
|
|
|
3615
4267
|
serverVersion = "1.0.0-beta.0";
|
|
3616
4268
|
wsServer = null;
|
|
3617
4269
|
running = false;
|
|
4270
|
+
/**
|
|
4271
|
+
* Periodic tick that retransmits relay batches connected clients have not
|
|
4272
|
+
* acknowledged, so a relay dropped by a lossy transport is redelivered instead of
|
|
4273
|
+
* leaving that client with a permanent version-vector gap (a lost operation).
|
|
4274
|
+
*/
|
|
4275
|
+
relayRetransmitTimer = null;
|
|
4276
|
+
/**
|
|
4277
|
+
* Relay operations a client never acknowledged before it disconnected, buffered by
|
|
4278
|
+
* client node id so they can be redelivered on its next connection. Bounded per
|
|
4279
|
+
* node and expired by age so a client that never returns cannot grow this forever.
|
|
4280
|
+
*/
|
|
4281
|
+
orphanedRelaysByNode = /* @__PURE__ */ new Map();
|
|
4282
|
+
static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
|
|
4283
|
+
static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
|
|
3618
4284
|
constructor(config) {
|
|
3619
4285
|
this.store = config.store;
|
|
3620
4286
|
this.auth = config.auth ?? null;
|
|
@@ -3642,6 +4308,57 @@ var KoraSyncServer = class {
|
|
|
3642
4308
|
if (!this.emitter) {
|
|
3643
4309
|
this.emitter = new import_internal2.SimpleEventEmitter();
|
|
3644
4310
|
}
|
|
4311
|
+
this.relayRetransmitTimer = setInterval(() => {
|
|
4312
|
+
this.retransmitPendingRelays(RELAY_RETRANSMIT_INTERVAL_MS);
|
|
4313
|
+
}, RELAY_RETRANSMIT_INTERVAL_MS);
|
|
4314
|
+
this.relayRetransmitTimer.unref?.();
|
|
4315
|
+
}
|
|
4316
|
+
/**
|
|
4317
|
+
* Retransmit relay batches that connected clients have not acknowledged within
|
|
4318
|
+
* `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
|
|
4319
|
+
* already-applied operation is harmless (clients dedup by content-addressed id).
|
|
4320
|
+
*/
|
|
4321
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
4322
|
+
for (const session of this.sessions.values()) {
|
|
4323
|
+
session.retransmitPendingRelays(staleMs);
|
|
4324
|
+
}
|
|
4325
|
+
this.expireOrphanedRelays();
|
|
4326
|
+
}
|
|
4327
|
+
/**
|
|
4328
|
+
* Buffer relay operations a disconnected client never acknowledged, keyed by its
|
|
4329
|
+
* node id, deduped by operation id and bounded by count. Redelivered when the
|
|
4330
|
+
* client reconnects (see {@link takeOrphanedRelays}).
|
|
4331
|
+
*/
|
|
4332
|
+
bufferOrphanedRelays(nodeId, ops) {
|
|
4333
|
+
if (ops.length === 0) return;
|
|
4334
|
+
const existing = this.orphanedRelaysByNode.get(nodeId);
|
|
4335
|
+
const merged = existing ? existing.ops : [];
|
|
4336
|
+
const seen = new Set(merged.map((op) => op.id));
|
|
4337
|
+
for (const op of ops) {
|
|
4338
|
+
if (!seen.has(op.id)) {
|
|
4339
|
+
merged.push(op);
|
|
4340
|
+
seen.add(op.id);
|
|
4341
|
+
}
|
|
4342
|
+
}
|
|
4343
|
+
const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
|
|
4344
|
+
this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
|
|
4345
|
+
}
|
|
4346
|
+
/** Remove and return a node's buffered orphaned relay operations, if any. */
|
|
4347
|
+
takeOrphanedRelays(nodeId) {
|
|
4348
|
+
const entry = this.orphanedRelaysByNode.get(nodeId);
|
|
4349
|
+
if (!entry) return [];
|
|
4350
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4351
|
+
return entry.ops;
|
|
4352
|
+
}
|
|
4353
|
+
/** Drop buffered orphaned relays older than the TTL (client never returned). */
|
|
4354
|
+
expireOrphanedRelays() {
|
|
4355
|
+
if (this.orphanedRelaysByNode.size === 0) return;
|
|
4356
|
+
const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
|
|
4357
|
+
for (const [nodeId, entry] of this.orphanedRelaysByNode) {
|
|
4358
|
+
if (entry.bufferedAtMs <= cutoff) {
|
|
4359
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4360
|
+
}
|
|
4361
|
+
}
|
|
3645
4362
|
}
|
|
3646
4363
|
/**
|
|
3647
4364
|
* The trusted, scoped data-plane context (`apply` / `query` / `findById`).
|
|
@@ -3768,6 +4485,11 @@ var KoraSyncServer = class {
|
|
|
3768
4485
|
event: "server.stopping",
|
|
3769
4486
|
details: { connectedClients: this.sessions.size }
|
|
3770
4487
|
});
|
|
4488
|
+
if (this.relayRetransmitTimer) {
|
|
4489
|
+
clearInterval(this.relayRetransmitTimer);
|
|
4490
|
+
this.relayRetransmitTimer = null;
|
|
4491
|
+
}
|
|
4492
|
+
this.orphanedRelaysByNode.clear();
|
|
3771
4493
|
this.awarenessRelay.clear();
|
|
3772
4494
|
this.yjsDocRelay.clear();
|
|
3773
4495
|
this.blobChunkRelay.clear();
|
|
@@ -3903,6 +4625,7 @@ var KoraSyncServer = class {
|
|
|
3903
4625
|
auth: this.auth ?? void 0,
|
|
3904
4626
|
serializer: this.serializer,
|
|
3905
4627
|
emitter: sessionEmitter,
|
|
4628
|
+
logger: this.logger,
|
|
3906
4629
|
batchSize: this.batchSize,
|
|
3907
4630
|
schemaVersion: this.schemaVersion,
|
|
3908
4631
|
supportedSchemaVersions: this.supportedSchemaVersions,
|
|
@@ -3929,7 +4652,11 @@ var KoraSyncServer = class {
|
|
|
3929
4652
|
...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
|
|
3930
4653
|
onClose: (sid) => {
|
|
3931
4654
|
this.handleSessionClose(sid);
|
|
3932
|
-
}
|
|
4655
|
+
},
|
|
4656
|
+
onOrphanedRelays: (nodeId, ops) => {
|
|
4657
|
+
this.bufferOrphanedRelays(nodeId, ops);
|
|
4658
|
+
},
|
|
4659
|
+
takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
|
|
3933
4660
|
});
|
|
3934
4661
|
this.sessions.set(sessionId, session);
|
|
3935
4662
|
this.yjsDocRelay.addClient(sessionId, transport);
|
|
@@ -3989,6 +4716,22 @@ var KoraSyncServer = class {
|
|
|
3989
4716
|
}
|
|
3990
4717
|
return result;
|
|
3991
4718
|
}
|
|
4719
|
+
/**
|
|
4720
|
+
* Relay already-applied, server-originated operations to every connected client.
|
|
4721
|
+
* Used by the conditional route apply, which commits its operations atomically
|
|
4722
|
+
* through the store (bypassing {@link applyLocalOperation}) and then fans them
|
|
4723
|
+
* out. Each session still enforces its own per-scope visibility filter.
|
|
4724
|
+
*
|
|
4725
|
+
* @param operations - Operations that have already been committed to the store
|
|
4726
|
+
*/
|
|
4727
|
+
relayServerOperations(operations2) {
|
|
4728
|
+
if (operations2.length === 0) {
|
|
4729
|
+
return;
|
|
4730
|
+
}
|
|
4731
|
+
for (const session of this.sessions.values()) {
|
|
4732
|
+
session.relayOperations(operations2);
|
|
4733
|
+
}
|
|
4734
|
+
}
|
|
3992
4735
|
/**
|
|
3993
4736
|
* Collect every blob reference still reachable from live records on the server.
|
|
3994
4737
|
*
|
|
@@ -4211,6 +4954,7 @@ function createProductionServer(config) {
|
|
|
4211
4954
|
const port = config.port ?? (Number(process.env.PORT) || 3001);
|
|
4212
4955
|
const staticDir = config.staticDir ?? "./dist";
|
|
4213
4956
|
const syncPath = config.syncPath ?? "/kora-sync";
|
|
4957
|
+
const crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy ?? "credentialless";
|
|
4214
4958
|
const syncServer = new KoraSyncServer({
|
|
4215
4959
|
store: config.store,
|
|
4216
4960
|
enableDashboard: true,
|
|
@@ -4381,7 +5125,7 @@ function createProductionServer(config) {
|
|
|
4381
5125
|
});
|
|
4382
5126
|
async function handleRequest(req, res) {
|
|
4383
5127
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
4384
|
-
res.setHeader("Cross-Origin-Embedder-Policy",
|
|
5128
|
+
res.setHeader("Cross-Origin-Embedder-Policy", crossOriginEmbedderPolicy);
|
|
4385
5129
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
4386
5130
|
if (url.pathname === "/health") {
|
|
4387
5131
|
const status = await syncServer.getStatus();
|