@korajs/server 1.0.0-beta.2 → 1.0.0-beta.5
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 +1012 -146
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +388 -3
- package/dist/index.d.ts +388 -3
- package/dist/index.js +1018 -146
- 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])
|
|
@@ -464,6 +440,14 @@ var MemoryServerStore = class {
|
|
|
464
440
|
operations = [];
|
|
465
441
|
operationIndex = /* @__PURE__ */ new Map();
|
|
466
442
|
versionVector = /* @__PURE__ */ new Map();
|
|
443
|
+
/**
|
|
444
|
+
* Server-assigned delivery sequence per operation id. Single-process, so a plain
|
|
445
|
+
* counter incremented at insert time gives commit-order delivery sequence with no
|
|
446
|
+
* gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
|
|
447
|
+
* is a forward walk.
|
|
448
|
+
*/
|
|
449
|
+
deliverySeqByOpId = /* @__PURE__ */ new Map();
|
|
450
|
+
deliverySeqCounter = 0;
|
|
467
451
|
schema = null;
|
|
468
452
|
/** Materialized records: collection -> recordId -> record data */
|
|
469
453
|
materializedRecords = /* @__PURE__ */ new Map();
|
|
@@ -497,6 +481,8 @@ var MemoryServerStore = class {
|
|
|
497
481
|
}
|
|
498
482
|
this.operations.push(op);
|
|
499
483
|
this.operationIndex.set(op.id, op);
|
|
484
|
+
this.deliverySeqCounter += 1;
|
|
485
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
500
486
|
const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
|
|
501
487
|
if (op.sequenceNumber > currentSeq) {
|
|
502
488
|
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
@@ -516,6 +502,23 @@ var MemoryServerStore = class {
|
|
|
516
502
|
this.assertOpen();
|
|
517
503
|
return this.operations.length;
|
|
518
504
|
}
|
|
505
|
+
async getMaxDeliverySequence() {
|
|
506
|
+
this.assertOpen();
|
|
507
|
+
return this.deliverySeqCounter;
|
|
508
|
+
}
|
|
509
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
510
|
+
this.assertOpen();
|
|
511
|
+
const result = [];
|
|
512
|
+
if (limit <= 0) return result;
|
|
513
|
+
for (const op of this.operations) {
|
|
514
|
+
const deliverySequence = this.deliverySeqByOpId.get(op.id) ?? 0;
|
|
515
|
+
if (deliverySequence > afterDeliverySequence) {
|
|
516
|
+
result.push({ operation: op, deliverySequence });
|
|
517
|
+
if (result.length >= limit) break;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return result;
|
|
521
|
+
}
|
|
519
522
|
async materializeCollection(collection) {
|
|
520
523
|
this.assertOpen();
|
|
521
524
|
if (this.schema?.collections[collection]) {
|
|
@@ -660,12 +663,16 @@ var MemoryServerStore = class {
|
|
|
660
663
|
this.operations.length = 0;
|
|
661
664
|
this.operationIndex.clear();
|
|
662
665
|
this.versionVector.clear();
|
|
666
|
+
this.deliverySeqByOpId.clear();
|
|
667
|
+
this.deliverySeqCounter = 0;
|
|
663
668
|
for (const [nid, seq] of versionVector) {
|
|
664
669
|
this.versionVector.set(nid, seq);
|
|
665
670
|
}
|
|
666
671
|
for (const op of operations2) {
|
|
667
672
|
this.operations.push(op);
|
|
668
673
|
this.operationIndex.set(op.id, op);
|
|
674
|
+
this.deliverySeqCounter += 1;
|
|
675
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
669
676
|
if (this.schema?.collections[op.collection]) {
|
|
670
677
|
this.rebuildMaterializedRecord(op.collection, op.recordId);
|
|
671
678
|
}
|
|
@@ -695,13 +702,14 @@ var MemoryServerStore = class {
|
|
|
695
702
|
return a.timestamp.wallTime - b.timestamp.wallTime;
|
|
696
703
|
if (a.timestamp.logical !== b.timestamp.logical)
|
|
697
704
|
return a.timestamp.logical - b.timestamp.logical;
|
|
698
|
-
return a.
|
|
705
|
+
return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
|
|
699
706
|
});
|
|
700
707
|
const parsedOps = recordOps.map((op) => ({
|
|
701
708
|
type: op.type,
|
|
702
|
-
data: op.data
|
|
709
|
+
data: op.data,
|
|
710
|
+
atomicOps: op.atomicOps ?? null
|
|
703
711
|
}));
|
|
704
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
712
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
705
713
|
if (recordData) {
|
|
706
714
|
const createdAt = recordOps.length > 0 ? recordOps[0].timestamp.wallTime : Date.now();
|
|
707
715
|
const updatedAt = recordOps.length > 0 ? recordOps[recordOps.length - 1].timestamp.wallTime : Date.now();
|
|
@@ -824,6 +832,8 @@ var pgOperations = (0, import_pg_core.pgTable)(
|
|
|
824
832
|
// JSON-serialized, null for deletes
|
|
825
833
|
previousData: (0, import_pg_core.text)("previous_data"),
|
|
826
834
|
// JSON-serialized, null for insert/delete
|
|
835
|
+
atomicOps: (0, import_pg_core.text)("atomic_ops"),
|
|
836
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
827
837
|
wallTime: (0, import_pg_core.bigint)("wall_time", { mode: "number" }).notNull(),
|
|
828
838
|
logical: (0, import_pg_core.integer)("logical").notNull(),
|
|
829
839
|
timestampNodeId: (0, import_pg_core.text)("timestamp_node_id").notNull(),
|
|
@@ -831,12 +841,18 @@ var pgOperations = (0, import_pg_core.pgTable)(
|
|
|
831
841
|
causalDeps: (0, import_pg_core.text)("causal_deps").notNull().default("[]"),
|
|
832
842
|
// JSON array of op IDs
|
|
833
843
|
schemaVersion: (0, import_pg_core.integer)("schema_version").notNull(),
|
|
834
|
-
receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull()
|
|
844
|
+
receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull(),
|
|
845
|
+
// Server-assigned monotonic delivery sequence, ordered by commit (assigned from
|
|
846
|
+
// a counter row locked inside the append transaction, so delivery order equals
|
|
847
|
+
// visibility order even across instances). Drives the gap-free server->client
|
|
848
|
+
// delivery watermark. Nullable only for pre-column rows, backfilled on startup.
|
|
849
|
+
deliverySeq: (0, import_pg_core.bigint)("delivery_seq", { mode: "number" })
|
|
835
850
|
},
|
|
836
851
|
(table) => ({
|
|
837
852
|
nodeSeqIdx: (0, import_pg_core.index)("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
838
853
|
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)
|
|
854
|
+
receivedIdx: (0, import_pg_core.index)("idx_pg_received").on(table.receivedAt),
|
|
855
|
+
deliveryIdx: (0, import_pg_core.index)("idx_pg_delivery_seq").on(table.deliverySeq)
|
|
840
856
|
})
|
|
841
857
|
);
|
|
842
858
|
var pgSyncState = (0, import_pg_core.pgTable)("sync_state", {
|
|
@@ -853,15 +869,43 @@ var PostgresServerStore = class {
|
|
|
853
869
|
ready;
|
|
854
870
|
schema = null;
|
|
855
871
|
closed = false;
|
|
856
|
-
|
|
872
|
+
/**
|
|
873
|
+
* Monotonic counter for this node's server-originated sequence numbers. Lazily
|
|
874
|
+
* seeded from the persisted version vector on first use, then advanced in memory
|
|
875
|
+
* (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
|
|
876
|
+
* applies never receive the same number. Null means "not yet seeded"; reset to
|
|
877
|
+
* null after a backup import so it re-seeds from the restored version vector.
|
|
878
|
+
*/
|
|
879
|
+
sequenceCounter = null;
|
|
880
|
+
/**
|
|
881
|
+
* Time source for HLC timestamps on server-originated operations. Injectable so a
|
|
882
|
+
* test can freeze wall-clock time and prove the conditional-apply ordering holds
|
|
883
|
+
* under same-millisecond commits (the case the advisory lock and clock advance
|
|
884
|
+
* exist to make correct). Defaults to the system clock in production.
|
|
885
|
+
*/
|
|
886
|
+
timeSource;
|
|
887
|
+
constructor(db, nodeId, timeSource) {
|
|
857
888
|
this.db = db;
|
|
858
889
|
this.nodeId = nodeId ?? (0, import_core3.generateUUIDv7)();
|
|
890
|
+
this.timeSource = timeSource ?? { now: () => Date.now() };
|
|
859
891
|
this.ready = this.initialize();
|
|
860
892
|
}
|
|
861
893
|
getVersionVector() {
|
|
862
894
|
this.assertOpen();
|
|
863
895
|
return new Map(this.versionVector);
|
|
864
896
|
}
|
|
897
|
+
/**
|
|
898
|
+
* Atomically reserve this node's next server-operation sequence number. The
|
|
899
|
+
* synchronous increment cannot interleave on the single JS thread, so two
|
|
900
|
+
* concurrent conditional applies are always handed distinct numbers.
|
|
901
|
+
*/
|
|
902
|
+
reserveSequenceNumber() {
|
|
903
|
+
if (this.sequenceCounter === null) {
|
|
904
|
+
this.sequenceCounter = this.versionVector.get(this.nodeId) ?? 0;
|
|
905
|
+
}
|
|
906
|
+
this.sequenceCounter += 1;
|
|
907
|
+
return this.sequenceCounter;
|
|
908
|
+
}
|
|
865
909
|
getNodeId() {
|
|
866
910
|
return this.nodeId;
|
|
867
911
|
}
|
|
@@ -899,8 +943,9 @@ var PostgresServerStore = class {
|
|
|
899
943
|
return "duplicate";
|
|
900
944
|
}
|
|
901
945
|
const now = Date.now();
|
|
902
|
-
const row = this.serializeOperation(op, now);
|
|
903
946
|
await this.db.transaction(async (tx) => {
|
|
947
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
948
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
904
949
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
905
950
|
await tx.insert(pgSyncState).values({
|
|
906
951
|
nodeId: op.nodeId,
|
|
@@ -923,6 +968,73 @@ var PostgresServerStore = class {
|
|
|
923
968
|
}
|
|
924
969
|
return "applied";
|
|
925
970
|
}
|
|
971
|
+
/**
|
|
972
|
+
* Conditionally apply operations atomically, serialized across server instances
|
|
973
|
+
* on the target record via a transaction-scoped advisory lock. The lock makes
|
|
974
|
+
* the read-decide-write cycle atomic against concurrent instances, so a cap
|
|
975
|
+
* check like `responseCount < max` cannot be passed by two admissions at once.
|
|
976
|
+
*/
|
|
977
|
+
async applyConditional(input) {
|
|
978
|
+
this.assertOpen();
|
|
979
|
+
await this.ready;
|
|
980
|
+
this.assertSchema();
|
|
981
|
+
this.assertCollection(input.target.collection);
|
|
982
|
+
const schema = this.schema;
|
|
983
|
+
const collectionDef = schema.collections[input.target.collection];
|
|
984
|
+
const lockKey = `kora:${input.target.collection}:${input.target.id}`;
|
|
985
|
+
const result = await this.db.transaction(async (tx) => {
|
|
986
|
+
await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
|
987
|
+
if (input.idempotencyKey) {
|
|
988
|
+
const existing = await tx.execute(
|
|
989
|
+
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`
|
|
990
|
+
);
|
|
991
|
+
if (existing.length > 0) {
|
|
992
|
+
return { admitted: true, idempotent: true, applied: [] };
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const rows = await tx.execute(
|
|
996
|
+
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`
|
|
997
|
+
);
|
|
998
|
+
const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
|
|
999
|
+
if (!input.admit(current)) {
|
|
1000
|
+
return { admitted: false, idempotent: false, applied: [] };
|
|
1001
|
+
}
|
|
1002
|
+
const clock = new import_core3.HybridLogicalClock(this.nodeId, this.timeSource);
|
|
1003
|
+
const latest = await this.latestOperationTimestamp(
|
|
1004
|
+
tx,
|
|
1005
|
+
input.target.collection,
|
|
1006
|
+
input.target.id
|
|
1007
|
+
);
|
|
1008
|
+
if (latest) {
|
|
1009
|
+
clock.advanceTo(latest);
|
|
1010
|
+
}
|
|
1011
|
+
const ops = await input.buildOperations(current, { clock });
|
|
1012
|
+
const now = Date.now();
|
|
1013
|
+
for (const op of ops) {
|
|
1014
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
1015
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
1016
|
+
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
1017
|
+
await tx.insert(pgSyncState).values({ nodeId: op.nodeId, maxSequenceNumber: op.sequenceNumber, lastSeenAt: now }).onConflictDoUpdate({
|
|
1018
|
+
target: pgSyncState.nodeId,
|
|
1019
|
+
set: {
|
|
1020
|
+
maxSequenceNumber: import_drizzle_orm.sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
|
|
1021
|
+
lastSeenAt: import_drizzle_orm.sql`${now}`
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
if (this.schema?.collections[op.collection]) {
|
|
1025
|
+
await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return { admitted: true, idempotent: false, applied: ops };
|
|
1029
|
+
});
|
|
1030
|
+
for (const op of result.applied) {
|
|
1031
|
+
const currentMax = this.versionVector.get(op.nodeId) ?? 0;
|
|
1032
|
+
if (op.sequenceNumber > currentMax) {
|
|
1033
|
+
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return result;
|
|
1037
|
+
}
|
|
926
1038
|
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
927
1039
|
this.assertOpen();
|
|
928
1040
|
await this.ready;
|
|
@@ -937,6 +1049,23 @@ var PostgresServerStore = class {
|
|
|
937
1049
|
const result = await this.db.select({ value: (0, import_drizzle_orm.count)() }).from(pgOperations);
|
|
938
1050
|
return result[0]?.value ?? 0;
|
|
939
1051
|
}
|
|
1052
|
+
async getMaxDeliverySequence() {
|
|
1053
|
+
this.assertOpen();
|
|
1054
|
+
await this.ready;
|
|
1055
|
+
const rows = await this.db.execute(
|
|
1056
|
+
import_drizzle_orm.sql`SELECT COALESCE(MAX(delivery_seq), 0) AS m FROM operations`
|
|
1057
|
+
);
|
|
1058
|
+
return Number(rows[0]?.m ?? 0);
|
|
1059
|
+
}
|
|
1060
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
1061
|
+
this.assertOpen();
|
|
1062
|
+
await this.ready;
|
|
1063
|
+
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);
|
|
1064
|
+
return rows.map((row) => ({
|
|
1065
|
+
operation: this.deserializeOperation(row),
|
|
1066
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
1067
|
+
}));
|
|
1068
|
+
}
|
|
940
1069
|
async materializeCollection(collection) {
|
|
941
1070
|
this.assertOpen();
|
|
942
1071
|
await this.ready;
|
|
@@ -1000,7 +1129,7 @@ var PostgresServerStore = class {
|
|
|
1000
1129
|
this.assertOpen();
|
|
1001
1130
|
await this.ready;
|
|
1002
1131
|
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.
|
|
1132
|
+
const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.deliverySeq));
|
|
1004
1133
|
const operations2 = rows.map((row) => this.deserializeOperation(row));
|
|
1005
1134
|
return buildServerBackup2(this.nodeId, operations2, this.versionVector);
|
|
1006
1135
|
}
|
|
@@ -1015,6 +1144,7 @@ var PostgresServerStore = class {
|
|
|
1015
1144
|
const result = await this.applyRemoteOperation(op);
|
|
1016
1145
|
if (result === "applied") restored++;
|
|
1017
1146
|
}
|
|
1147
|
+
this.sequenceCounter = null;
|
|
1018
1148
|
return { operationsRestored: restored, success: true };
|
|
1019
1149
|
}
|
|
1020
1150
|
const now = Date.now();
|
|
@@ -1024,20 +1154,49 @@ var PostgresServerStore = class {
|
|
|
1024
1154
|
for (const [nid, seq] of versionVector) {
|
|
1025
1155
|
await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
|
|
1026
1156
|
}
|
|
1157
|
+
let deliverySeq = 0;
|
|
1027
1158
|
for (const op of operations2) {
|
|
1028
|
-
|
|
1159
|
+
deliverySeq += 1;
|
|
1160
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
1029
1161
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
1030
1162
|
}
|
|
1163
|
+
await tx.execute(
|
|
1164
|
+
import_drizzle_orm.sql`INSERT INTO delivery_counter (id, value) VALUES (1, ${deliverySeq})
|
|
1165
|
+
ON CONFLICT (id) DO UPDATE SET value = ${deliverySeq}`
|
|
1166
|
+
);
|
|
1031
1167
|
});
|
|
1032
1168
|
this.versionVector.clear();
|
|
1033
1169
|
for (const [nid, seq] of versionVector) {
|
|
1034
1170
|
this.versionVector.set(nid, seq);
|
|
1035
1171
|
}
|
|
1172
|
+
this.sequenceCounter = null;
|
|
1036
1173
|
return { operationsRestored: operations2.length, success: true };
|
|
1037
1174
|
}
|
|
1038
1175
|
// ---------------------------------------------------------------------------
|
|
1039
1176
|
// Materialization internals
|
|
1040
1177
|
// ---------------------------------------------------------------------------
|
|
1178
|
+
/**
|
|
1179
|
+
* The HLC timestamp of the most recent operation on a record, by total order
|
|
1180
|
+
* (wallTime, logical, nodeId), or null when the record has no operations. Used
|
|
1181
|
+
* by {@link applyConditional} to advance its clock past the record's latest
|
|
1182
|
+
* write so newly built operations sort strictly after it.
|
|
1183
|
+
*/
|
|
1184
|
+
async latestOperationTimestamp(txOrDb, collection, recordId) {
|
|
1185
|
+
const rows = await txOrDb.select({
|
|
1186
|
+
wallTime: pgOperations.wallTime,
|
|
1187
|
+
logical: pgOperations.logical,
|
|
1188
|
+
timestampNodeId: pgOperations.timestampNodeId
|
|
1189
|
+
}).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(
|
|
1190
|
+
(0, import_drizzle_orm.desc)(pgOperations.wallTime),
|
|
1191
|
+
(0, import_drizzle_orm.desc)(pgOperations.logical),
|
|
1192
|
+
(0, import_drizzle_orm.desc)(pgOperations.timestampNodeId)
|
|
1193
|
+
).limit(1);
|
|
1194
|
+
const row = rows[0];
|
|
1195
|
+
if (!row) {
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
return { wallTime: row.wallTime, logical: row.logical, nodeId: row.timestampNodeId };
|
|
1199
|
+
}
|
|
1041
1200
|
/**
|
|
1042
1201
|
* Rebuild a single record in the materialized collection table by replaying
|
|
1043
1202
|
* all operations for that record.
|
|
@@ -1048,17 +1207,19 @@ var PostgresServerStore = class {
|
|
|
1048
1207
|
const ops = await txOrDb.select({
|
|
1049
1208
|
type: pgOperations.type,
|
|
1050
1209
|
data: pgOperations.data,
|
|
1210
|
+
atomicOps: pgOperations.atomicOps,
|
|
1051
1211
|
wallTime: pgOperations.wallTime
|
|
1052
1212
|
}).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
1213
|
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
1054
1214
|
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
1055
|
-
(0, import_drizzle_orm.asc)(pgOperations.
|
|
1215
|
+
(0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
|
|
1056
1216
|
);
|
|
1057
1217
|
const parsedOps = ops.map((op) => ({
|
|
1058
1218
|
type: op.type,
|
|
1059
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1219
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1220
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1060
1221
|
}));
|
|
1061
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1222
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1062
1223
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
1063
1224
|
if (recordData) {
|
|
1064
1225
|
const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
|
|
@@ -1125,11 +1286,12 @@ var PostgresServerStore = class {
|
|
|
1125
1286
|
recordId: pgOperations.recordId,
|
|
1126
1287
|
type: pgOperations.type,
|
|
1127
1288
|
data: pgOperations.data,
|
|
1289
|
+
atomicOps: pgOperations.atomicOps,
|
|
1128
1290
|
wallTime: pgOperations.wallTime
|
|
1129
1291
|
}).from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collectionName)).orderBy(
|
|
1130
1292
|
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
1131
1293
|
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
1132
|
-
(0, import_drizzle_orm.asc)(pgOperations.
|
|
1294
|
+
(0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
|
|
1133
1295
|
);
|
|
1134
1296
|
if (allOps.length === 0) return;
|
|
1135
1297
|
const grouped = /* @__PURE__ */ new Map();
|
|
@@ -1145,9 +1307,10 @@ var PostgresServerStore = class {
|
|
|
1145
1307
|
for (const [recordId, recordOps] of grouped) {
|
|
1146
1308
|
const parsedOps = recordOps.map((op) => ({
|
|
1147
1309
|
type: op.type,
|
|
1148
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1310
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1311
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1149
1312
|
}));
|
|
1150
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1313
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1151
1314
|
if (recordData) {
|
|
1152
1315
|
const createdAt = recordOps[0].wallTime;
|
|
1153
1316
|
const updatedAt = recordOps[recordOps.length - 1].wallTime;
|
|
@@ -1270,44 +1433,92 @@ var PostgresServerStore = class {
|
|
|
1270
1433
|
}
|
|
1271
1434
|
}
|
|
1272
1435
|
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
|
-
|
|
1436
|
+
await this.db.transaction(async (tx) => {
|
|
1437
|
+
await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended('kora:ensure-tables', 0))`);
|
|
1438
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1439
|
+
CREATE TABLE IF NOT EXISTS operations (
|
|
1440
|
+
id TEXT PRIMARY KEY,
|
|
1441
|
+
node_id TEXT NOT NULL,
|
|
1442
|
+
type TEXT NOT NULL,
|
|
1443
|
+
collection TEXT NOT NULL,
|
|
1444
|
+
record_id TEXT NOT NULL,
|
|
1445
|
+
data TEXT,
|
|
1446
|
+
previous_data TEXT,
|
|
1447
|
+
atomic_ops TEXT,
|
|
1448
|
+
wall_time BIGINT NOT NULL,
|
|
1449
|
+
logical INTEGER NOT NULL,
|
|
1450
|
+
timestamp_node_id TEXT NOT NULL,
|
|
1451
|
+
sequence_number INTEGER NOT NULL,
|
|
1452
|
+
causal_deps TEXT NOT NULL DEFAULT '[]',
|
|
1453
|
+
schema_version INTEGER NOT NULL,
|
|
1454
|
+
received_at BIGINT NOT NULL
|
|
1455
|
+
)
|
|
1456
|
+
`);
|
|
1457
|
+
await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS atomic_ops TEXT`);
|
|
1458
|
+
await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS delivery_seq BIGINT`);
|
|
1459
|
+
await tx.execute(
|
|
1460
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
|
|
1461
|
+
);
|
|
1462
|
+
await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
|
|
1463
|
+
await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
|
|
1464
|
+
await tx.execute(
|
|
1465
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)`
|
|
1466
|
+
);
|
|
1467
|
+
await tx.execute(
|
|
1468
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
|
|
1469
|
+
);
|
|
1470
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1471
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
1472
|
+
node_id TEXT PRIMARY KEY,
|
|
1473
|
+
max_sequence_number INTEGER NOT NULL,
|
|
1474
|
+
last_seen_at BIGINT NOT NULL
|
|
1475
|
+
)
|
|
1476
|
+
`);
|
|
1477
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1478
|
+
CREATE TABLE IF NOT EXISTS delivery_counter (
|
|
1479
|
+
id INTEGER PRIMARY KEY,
|
|
1480
|
+
value BIGINT NOT NULL
|
|
1481
|
+
)
|
|
1482
|
+
`);
|
|
1483
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1484
|
+
WITH ordered AS (
|
|
1485
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
1486
|
+
ORDER BY received_at ASC, sequence_number ASC, id ASC
|
|
1487
|
+
) + COALESCE((SELECT MAX(delivery_seq) FROM operations), 0) AS rn
|
|
1488
|
+
FROM operations WHERE delivery_seq IS NULL
|
|
1489
|
+
)
|
|
1490
|
+
UPDATE operations o SET delivery_seq = ordered.rn
|
|
1491
|
+
FROM ordered WHERE o.id = ordered.id
|
|
1492
|
+
`);
|
|
1493
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1494
|
+
INSERT INTO delivery_counter (id, value)
|
|
1495
|
+
VALUES (1, COALESCE((SELECT MAX(delivery_seq) FROM operations), 0))
|
|
1496
|
+
ON CONFLICT (id) DO UPDATE
|
|
1497
|
+
SET value = GREATEST(delivery_counter.value, EXCLUDED.value)
|
|
1498
|
+
`);
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Reserve the next delivery sequence inside an open append transaction. The
|
|
1503
|
+
* `UPDATE ... RETURNING` locks the counter row until this transaction commits,
|
|
1504
|
+
* serializing assignment into commit order (see the counter table comment).
|
|
1505
|
+
*/
|
|
1506
|
+
async nextDeliverySeq(tx) {
|
|
1507
|
+
const rows = await tx.execute(
|
|
1508
|
+
import_drizzle_orm.sql`UPDATE delivery_counter SET value = value + 1 WHERE id = 1 RETURNING value`
|
|
1298
1509
|
);
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1510
|
+
const value = rows[0]?.value;
|
|
1511
|
+
if (value === void 0 || value === null) {
|
|
1512
|
+
throw new Error(
|
|
1513
|
+
"delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
return Number(value);
|
|
1306
1517
|
}
|
|
1307
1518
|
// ---------------------------------------------------------------------------
|
|
1308
1519
|
// Operation serialization
|
|
1309
1520
|
// ---------------------------------------------------------------------------
|
|
1310
|
-
serializeOperation(op, receivedAt) {
|
|
1521
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1311
1522
|
return {
|
|
1312
1523
|
id: op.id,
|
|
1313
1524
|
nodeId: op.nodeId,
|
|
@@ -1316,16 +1527,19 @@ var PostgresServerStore = class {
|
|
|
1316
1527
|
recordId: op.recordId,
|
|
1317
1528
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1318
1529
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
1530
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1319
1531
|
wallTime: op.timestamp.wallTime,
|
|
1320
1532
|
logical: op.timestamp.logical,
|
|
1321
1533
|
timestampNodeId: op.timestamp.nodeId,
|
|
1322
1534
|
sequenceNumber: op.sequenceNumber,
|
|
1323
1535
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1324
1536
|
schemaVersion: op.schemaVersion,
|
|
1325
|
-
receivedAt
|
|
1537
|
+
receivedAt,
|
|
1538
|
+
deliverySeq
|
|
1326
1539
|
};
|
|
1327
1540
|
}
|
|
1328
1541
|
deserializeOperation(row) {
|
|
1542
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1329
1543
|
return {
|
|
1330
1544
|
id: row.id,
|
|
1331
1545
|
nodeId: row.nodeId,
|
|
@@ -1341,7 +1555,8 @@ var PostgresServerStore = class {
|
|
|
1341
1555
|
},
|
|
1342
1556
|
sequenceNumber: row.sequenceNumber,
|
|
1343
1557
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1344
|
-
schemaVersion: row.schemaVersion
|
|
1558
|
+
schemaVersion: row.schemaVersion,
|
|
1559
|
+
...atomicOps ? { atomicOps } : {}
|
|
1345
1560
|
};
|
|
1346
1561
|
}
|
|
1347
1562
|
// ---------------------------------------------------------------------------
|
|
@@ -1411,6 +1626,8 @@ var operations = (0, import_sqlite_core.sqliteTable)(
|
|
|
1411
1626
|
// JSON-serialized, null for deletes
|
|
1412
1627
|
previousData: (0, import_sqlite_core.text)("previous_data"),
|
|
1413
1628
|
// JSON-serialized, null for insert/delete
|
|
1629
|
+
atomicOps: (0, import_sqlite_core.text)("atomic_ops"),
|
|
1630
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
1414
1631
|
wallTime: (0, import_sqlite_core.integer)("wall_time").notNull(),
|
|
1415
1632
|
logical: (0, import_sqlite_core.integer)("logical").notNull(),
|
|
1416
1633
|
timestampNodeId: (0, import_sqlite_core.text)("timestamp_node_id").notNull(),
|
|
@@ -1418,12 +1635,17 @@ var operations = (0, import_sqlite_core.sqliteTable)(
|
|
|
1418
1635
|
causalDeps: (0, import_sqlite_core.text)("causal_deps").notNull().default("[]"),
|
|
1419
1636
|
// JSON array of op IDs
|
|
1420
1637
|
schemaVersion: (0, import_sqlite_core.integer)("schema_version").notNull(),
|
|
1421
|
-
receivedAt: (0, import_sqlite_core.integer)("received_at").notNull()
|
|
1638
|
+
receivedAt: (0, import_sqlite_core.integer)("received_at").notNull(),
|
|
1639
|
+
// Server-assigned monotonic delivery sequence, ordered by commit. Drives the
|
|
1640
|
+
// gap-free server->client delivery watermark. Nullable only for rows written
|
|
1641
|
+
// before the column existed; those are backfilled on startup.
|
|
1642
|
+
deliverySeq: (0, import_sqlite_core.integer)("delivery_seq")
|
|
1422
1643
|
},
|
|
1423
1644
|
(table) => ({
|
|
1424
1645
|
nodeSeqIdx: (0, import_sqlite_core.index)("idx_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
1425
1646
|
collectionIdx: (0, import_sqlite_core.index)("idx_collection").on(table.collection),
|
|
1426
|
-
receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt)
|
|
1647
|
+
receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt),
|
|
1648
|
+
deliveryIdx: (0, import_sqlite_core.index)("idx_delivery_seq").on(table.deliverySeq)
|
|
1427
1649
|
})
|
|
1428
1650
|
);
|
|
1429
1651
|
var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
|
|
@@ -1439,6 +1661,12 @@ var SqliteServerStore = class {
|
|
|
1439
1661
|
db;
|
|
1440
1662
|
schema = null;
|
|
1441
1663
|
closed = false;
|
|
1664
|
+
/**
|
|
1665
|
+
* Next delivery sequence to assign. Single-writer (better-sqlite3 is synchronous),
|
|
1666
|
+
* so a plain in-memory counter, seeded from MAX(delivery_seq) at startup, gives
|
|
1667
|
+
* commit-order delivery sequence. Assigned inside the append transaction.
|
|
1668
|
+
*/
|
|
1669
|
+
deliverySeqCounter = 0;
|
|
1442
1670
|
constructor(db, nodeId) {
|
|
1443
1671
|
this.db = db;
|
|
1444
1672
|
this.nodeId = nodeId ?? (0, import_core4.generateUUIDv7)();
|
|
@@ -1484,12 +1712,16 @@ var SqliteServerStore = class {
|
|
|
1484
1712
|
async applyRemoteOperation(op) {
|
|
1485
1713
|
this.assertOpen();
|
|
1486
1714
|
const now = Date.now();
|
|
1487
|
-
const row = this.serializeOperation(op, now);
|
|
1488
1715
|
const result = this.db.transaction((tx) => {
|
|
1489
|
-
const
|
|
1490
|
-
|
|
1716
|
+
const existing = tx.all(
|
|
1717
|
+
import_drizzle_orm2.sql`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
|
|
1718
|
+
);
|
|
1719
|
+
if (existing.length > 0) {
|
|
1491
1720
|
return "duplicate";
|
|
1492
1721
|
}
|
|
1722
|
+
this.deliverySeqCounter += 1;
|
|
1723
|
+
const row = this.serializeOperation(op, now, this.deliverySeqCounter);
|
|
1724
|
+
tx.insert(operations).values(row).run();
|
|
1493
1725
|
tx.insert(syncState).values({
|
|
1494
1726
|
nodeId: op.nodeId,
|
|
1495
1727
|
maxSequenceNumber: op.sequenceNumber,
|
|
@@ -1518,6 +1750,21 @@ var SqliteServerStore = class {
|
|
|
1518
1750
|
const result = this.db.select({ value: (0, import_drizzle_orm2.count)() }).from(operations).all();
|
|
1519
1751
|
return result[0]?.value ?? 0;
|
|
1520
1752
|
}
|
|
1753
|
+
async getMaxDeliverySequence() {
|
|
1754
|
+
this.assertOpen();
|
|
1755
|
+
const rows = this.db.all(
|
|
1756
|
+
import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
1757
|
+
);
|
|
1758
|
+
return rows[0]?.m ?? 0;
|
|
1759
|
+
}
|
|
1760
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
1761
|
+
this.assertOpen();
|
|
1762
|
+
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();
|
|
1763
|
+
return rows.map((row) => ({
|
|
1764
|
+
operation: this.deserializeOperation(row),
|
|
1765
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
1766
|
+
}));
|
|
1767
|
+
}
|
|
1521
1768
|
async materializeCollection(collection) {
|
|
1522
1769
|
this.assertOpen();
|
|
1523
1770
|
if (this.schema?.collections[collection]) {
|
|
@@ -1575,7 +1822,7 @@ var SqliteServerStore = class {
|
|
|
1575
1822
|
async exportBackup() {
|
|
1576
1823
|
this.assertOpen();
|
|
1577
1824
|
const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
1578
|
-
const rows = this.db.select().from(operations).all();
|
|
1825
|
+
const rows = this.db.select().from(operations).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).all();
|
|
1579
1826
|
const deserialized = rows.map((row) => this.deserializeOperation(row));
|
|
1580
1827
|
const vv = this.getVersionVector();
|
|
1581
1828
|
return buildServerBackup2(this.nodeId, deserialized, vv);
|
|
@@ -1598,10 +1845,13 @@ var SqliteServerStore = class {
|
|
|
1598
1845
|
for (const [nid, seq] of versionVector) {
|
|
1599
1846
|
tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
|
|
1600
1847
|
}
|
|
1848
|
+
let deliverySeq = 0;
|
|
1601
1849
|
for (const op of ops) {
|
|
1602
|
-
|
|
1850
|
+
deliverySeq += 1;
|
|
1851
|
+
const row = this.serializeOperation(op, Date.now(), deliverySeq);
|
|
1603
1852
|
tx.insert(operations).values(row).run();
|
|
1604
1853
|
}
|
|
1854
|
+
this.deliverySeqCounter = deliverySeq;
|
|
1605
1855
|
});
|
|
1606
1856
|
return { operationsRestored: ops.length, success: true };
|
|
1607
1857
|
}
|
|
@@ -1619,13 +1869,15 @@ var SqliteServerStore = class {
|
|
|
1619
1869
|
const ops = txOrDb.select({
|
|
1620
1870
|
type: operations.type,
|
|
1621
1871
|
data: operations.data,
|
|
1872
|
+
atomicOps: operations.atomicOps,
|
|
1622
1873
|
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.
|
|
1874
|
+
}).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
1875
|
const parsedOps = ops.map((op) => ({
|
|
1625
1876
|
type: op.type,
|
|
1626
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1877
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1878
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1627
1879
|
}));
|
|
1628
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1880
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1629
1881
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
1630
1882
|
if (recordData) {
|
|
1631
1883
|
const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
|
|
@@ -1695,8 +1947,9 @@ var SqliteServerStore = class {
|
|
|
1695
1947
|
recordId: operations.recordId,
|
|
1696
1948
|
type: operations.type,
|
|
1697
1949
|
data: operations.data,
|
|
1950
|
+
atomicOps: operations.atomicOps,
|
|
1698
1951
|
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.
|
|
1952
|
+
}).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
1953
|
if (allOps.length === 0) return;
|
|
1701
1954
|
const grouped = /* @__PURE__ */ new Map();
|
|
1702
1955
|
for (const op of allOps) {
|
|
@@ -1712,9 +1965,10 @@ var SqliteServerStore = class {
|
|
|
1712
1965
|
for (const [recordId, recordOps] of grouped) {
|
|
1713
1966
|
const parsedOps = recordOps.map((op) => ({
|
|
1714
1967
|
type: op.type,
|
|
1715
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1968
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1969
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1716
1970
|
}));
|
|
1717
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1971
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1718
1972
|
if (recordData) {
|
|
1719
1973
|
const createdAt = recordOps[0].wallTime;
|
|
1720
1974
|
const updatedAt = recordOps[recordOps.length - 1].wallTime;
|
|
@@ -1836,6 +2090,7 @@ var SqliteServerStore = class {
|
|
|
1836
2090
|
record_id TEXT NOT NULL,
|
|
1837
2091
|
data TEXT,
|
|
1838
2092
|
previous_data TEXT,
|
|
2093
|
+
atomic_ops TEXT,
|
|
1839
2094
|
wall_time INTEGER NOT NULL,
|
|
1840
2095
|
logical INTEGER NOT NULL,
|
|
1841
2096
|
timestamp_node_id TEXT NOT NULL,
|
|
@@ -1845,9 +2100,30 @@ var SqliteServerStore = class {
|
|
|
1845
2100
|
received_at INTEGER NOT NULL
|
|
1846
2101
|
)
|
|
1847
2102
|
`);
|
|
2103
|
+
try {
|
|
2104
|
+
this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
|
|
2105
|
+
} catch (e) {
|
|
2106
|
+
const msg = e instanceof Error ? e.message : "";
|
|
2107
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
2108
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
2109
|
+
throw e;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
try {
|
|
2113
|
+
this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
|
|
2114
|
+
} catch (e) {
|
|
2115
|
+
const msg = e instanceof Error ? e.message : "";
|
|
2116
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
2117
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
2118
|
+
throw e;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
1848
2121
|
this.db.run(import_drizzle_orm2.sql`
|
|
1849
2122
|
CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
|
|
1850
2123
|
`);
|
|
2124
|
+
this.db.run(import_drizzle_orm2.sql`
|
|
2125
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
|
|
2126
|
+
`);
|
|
1851
2127
|
this.db.run(import_drizzle_orm2.sql`
|
|
1852
2128
|
CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
|
|
1853
2129
|
`);
|
|
@@ -1864,11 +2140,34 @@ var SqliteServerStore = class {
|
|
|
1864
2140
|
last_seen_at INTEGER NOT NULL
|
|
1865
2141
|
)
|
|
1866
2142
|
`);
|
|
2143
|
+
this.backfillDeliverySequence();
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Assign delivery sequences to any operations written before the column existed,
|
|
2147
|
+
* then seed the in-memory counter from the current max. Ordered by
|
|
2148
|
+
* (received_at, sequence_number, id) so the backfill is deterministic and stable
|
|
2149
|
+
* across restarts. A one-time migration: after it runs, all rows have a value.
|
|
2150
|
+
*/
|
|
2151
|
+
backfillDeliverySequence() {
|
|
2152
|
+
this.db.transaction((tx) => {
|
|
2153
|
+
const maxRow = tx.all(
|
|
2154
|
+
import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
2155
|
+
);
|
|
2156
|
+
let next = maxRow[0]?.m ?? 0;
|
|
2157
|
+
const nullRows = tx.all(
|
|
2158
|
+
import_drizzle_orm2.sql`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
|
|
2159
|
+
);
|
|
2160
|
+
for (const row of nullRows) {
|
|
2161
|
+
next += 1;
|
|
2162
|
+
tx.run(import_drizzle_orm2.sql`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
|
|
2163
|
+
}
|
|
2164
|
+
this.deliverySeqCounter = next;
|
|
2165
|
+
});
|
|
1867
2166
|
}
|
|
1868
2167
|
// ---------------------------------------------------------------------------
|
|
1869
2168
|
// Operation serialization
|
|
1870
2169
|
// ---------------------------------------------------------------------------
|
|
1871
|
-
serializeOperation(op, receivedAt) {
|
|
2170
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1872
2171
|
return {
|
|
1873
2172
|
id: op.id,
|
|
1874
2173
|
nodeId: op.nodeId,
|
|
@@ -1877,16 +2176,19 @@ var SqliteServerStore = class {
|
|
|
1877
2176
|
recordId: op.recordId,
|
|
1878
2177
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1879
2178
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
2179
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1880
2180
|
wallTime: op.timestamp.wallTime,
|
|
1881
2181
|
logical: op.timestamp.logical,
|
|
1882
2182
|
timestampNodeId: op.timestamp.nodeId,
|
|
1883
2183
|
sequenceNumber: op.sequenceNumber,
|
|
1884
2184
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1885
2185
|
schemaVersion: op.schemaVersion,
|
|
1886
|
-
receivedAt
|
|
2186
|
+
receivedAt,
|
|
2187
|
+
deliverySeq
|
|
1887
2188
|
};
|
|
1888
2189
|
}
|
|
1889
2190
|
deserializeOperation(row) {
|
|
2191
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1890
2192
|
return {
|
|
1891
2193
|
id: row.id,
|
|
1892
2194
|
nodeId: row.nodeId,
|
|
@@ -1902,7 +2204,8 @@ var SqliteServerStore = class {
|
|
|
1902
2204
|
},
|
|
1903
2205
|
sequenceNumber: row.sequenceNumber,
|
|
1904
2206
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1905
|
-
schemaVersion: row.schemaVersion
|
|
2207
|
+
schemaVersion: row.schemaVersion,
|
|
2208
|
+
...atomicOps ? { atomicOps } : {}
|
|
1906
2209
|
};
|
|
1907
2210
|
}
|
|
1908
2211
|
// ---------------------------------------------------------------------------
|
|
@@ -2201,6 +2504,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
|
|
|
2201
2504
|
);
|
|
2202
2505
|
}
|
|
2203
2506
|
function nextServerSequenceNumber(store) {
|
|
2507
|
+
if (store.reserveSequenceNumber) {
|
|
2508
|
+
return store.reserveSequenceNumber();
|
|
2509
|
+
}
|
|
2204
2510
|
const nodeId = store.getNodeId();
|
|
2205
2511
|
const current = store.getVersionVector().get(nodeId) ?? 0;
|
|
2206
2512
|
return current + 1;
|
|
@@ -2242,16 +2548,14 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2242
2548
|
return { result: primaryResult, appliedOperations: [] };
|
|
2243
2549
|
}
|
|
2244
2550
|
const appliedOperations = [op];
|
|
2245
|
-
let serverSeq = nextServerSequenceNumber(store);
|
|
2246
2551
|
for (const effect of referential.sideEffectOps) {
|
|
2247
2552
|
const sideOp = await createServerSideEffectOperation(
|
|
2248
2553
|
store,
|
|
2249
2554
|
op,
|
|
2250
2555
|
effect,
|
|
2251
2556
|
op.schemaVersion,
|
|
2252
|
-
|
|
2557
|
+
nextServerSequenceNumber(store)
|
|
2253
2558
|
);
|
|
2254
|
-
serverSeq += 1;
|
|
2255
2559
|
const sideResult = await store.applyRemoteOperation(sideOp);
|
|
2256
2560
|
if (sideResult === "applied") {
|
|
2257
2561
|
appliedOperations.push(sideOp);
|
|
@@ -2312,11 +2616,11 @@ function mergeScopeMaps(base, overlay, source) {
|
|
|
2312
2616
|
|
|
2313
2617
|
// src/scopes/server-scope-filter.ts
|
|
2314
2618
|
init_cjs_shims();
|
|
2315
|
-
function operationMatchesScopes(op, scopes) {
|
|
2619
|
+
function operationMatchesScopes(op, scopes, fullRecord) {
|
|
2316
2620
|
if (!scopes) return true;
|
|
2317
2621
|
const collectionScope = scopes[op.collection];
|
|
2318
2622
|
if (!collectionScope) return false;
|
|
2319
|
-
const snapshot = buildSnapshot(op);
|
|
2623
|
+
const snapshot = buildSnapshot(op, fullRecord);
|
|
2320
2624
|
if (!snapshot) return false;
|
|
2321
2625
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
2322
2626
|
if (snapshot[field] !== expected) {
|
|
@@ -2325,11 +2629,19 @@ function operationMatchesScopes(op, scopes) {
|
|
|
2325
2629
|
}
|
|
2326
2630
|
return true;
|
|
2327
2631
|
}
|
|
2328
|
-
function
|
|
2632
|
+
function missingScopeFields(op, scopes) {
|
|
2633
|
+
if (!scopes) return [];
|
|
2634
|
+
const collectionScope = scopes[op.collection];
|
|
2635
|
+
if (!collectionScope) return [];
|
|
2636
|
+
const snapshot = buildSnapshot(op);
|
|
2637
|
+
return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
|
|
2638
|
+
}
|
|
2639
|
+
function buildSnapshot(op, fullRecord) {
|
|
2329
2640
|
const previous = asRecord(op.previousData);
|
|
2330
2641
|
const next = asRecord(op.data);
|
|
2331
|
-
if (!previous && !next) return null;
|
|
2642
|
+
if (!previous && !next && !fullRecord) return null;
|
|
2332
2643
|
return {
|
|
2644
|
+
...fullRecord ?? {},
|
|
2333
2645
|
...previous ?? {},
|
|
2334
2646
|
...next ?? {}
|
|
2335
2647
|
};
|
|
@@ -2422,12 +2734,41 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
|
|
|
2422
2734
|
"[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
2735
|
);
|
|
2424
2736
|
}
|
|
2425
|
-
var ClientSession = class {
|
|
2737
|
+
var ClientSession = class _ClientSession {
|
|
2426
2738
|
state = "connected";
|
|
2427
2739
|
clientNodeId = null;
|
|
2428
2740
|
authContext = null;
|
|
2429
2741
|
syncQuerySubsets = [];
|
|
2430
2742
|
resumeDeltaCursor = null;
|
|
2743
|
+
/**
|
|
2744
|
+
* The delivery watermark the client reported at handshake, or null when the client
|
|
2745
|
+
* does not use the delivery watermark (old client, or no watermark yet). When set,
|
|
2746
|
+
* the server drives the gap-free server->client stream from delivery sequences
|
|
2747
|
+
* instead of the version-vector delta.
|
|
2748
|
+
*/
|
|
2749
|
+
clientDeliveryWatermark = null;
|
|
2750
|
+
/**
|
|
2751
|
+
* The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
|
|
2752
|
+
* watermark, as reported in acks). Incremental streaming pushes resume from here, not
|
|
2753
|
+
* from the highest sequence sent, so a dropped or unapplied batch is always
|
|
2754
|
+
* re-included by the next push (which re-scans from the acknowledged position). This
|
|
2755
|
+
* is what makes streaming recovery independent of any bounded retransmit buffer: there
|
|
2756
|
+
* is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
|
|
2757
|
+
* client's reported watermark at handshake.
|
|
2758
|
+
*/
|
|
2759
|
+
lastAckedDeliverySeq = 0;
|
|
2760
|
+
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
2761
|
+
deliveryPushChain = Promise.resolve();
|
|
2762
|
+
/**
|
|
2763
|
+
* Relay batches sent to this client but not yet acknowledged, keyed by messageId.
|
|
2764
|
+
* The client acks every operation-batch it applies; on ack the entry is cleared.
|
|
2765
|
+
* {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
|
|
2766
|
+
* dropped by a lossy transport is redelivered instead of leaving the client with a
|
|
2767
|
+
* permanent version-vector gap (a lost operation) that delta sync cannot recover.
|
|
2768
|
+
* Bounded so a silent client cannot grow it without limit.
|
|
2769
|
+
*/
|
|
2770
|
+
pendingRelays = /* @__PURE__ */ new Map();
|
|
2771
|
+
static MAX_PENDING_RELAYS = 1e3;
|
|
2431
2772
|
sessionId;
|
|
2432
2773
|
transport;
|
|
2433
2774
|
store;
|
|
@@ -2444,6 +2785,8 @@ var ClientSession = class {
|
|
|
2444
2785
|
onBlobChunkResponse;
|
|
2445
2786
|
persistBlobChunk;
|
|
2446
2787
|
onClose;
|
|
2788
|
+
onOrphanedRelays;
|
|
2789
|
+
takeOrphanedRelays;
|
|
2447
2790
|
maxOperationBytes;
|
|
2448
2791
|
maxOpsPerMinute;
|
|
2449
2792
|
rateLimiter;
|
|
@@ -2470,6 +2813,8 @@ var ClientSession = class {
|
|
|
2470
2813
|
this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
|
|
2471
2814
|
this.persistBlobChunk = options.persistBlobChunk ?? null;
|
|
2472
2815
|
this.onClose = options.onClose ?? null;
|
|
2816
|
+
this.onOrphanedRelays = options.onOrphanedRelays ?? null;
|
|
2817
|
+
this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
|
|
2473
2818
|
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2474
2819
|
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
2475
2820
|
this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
|
|
@@ -2494,9 +2839,40 @@ var ClientSession = class {
|
|
|
2494
2839
|
*/
|
|
2495
2840
|
relayOperations(operations2) {
|
|
2496
2841
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2842
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2843
|
+
this.pushDeliveryStream();
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2497
2846
|
if (operations2.length === 0) return;
|
|
2498
|
-
|
|
2847
|
+
void this.relayVisibleOperations(operations2);
|
|
2848
|
+
}
|
|
2849
|
+
/**
|
|
2850
|
+
* Push newly-available in-scope operations to a delivery-watermark client, resuming
|
|
2851
|
+
* from the highest delivery sequence already sent. Pushes are serialized so their
|
|
2852
|
+
* batches never interleave (which would break the base/max chain).
|
|
2853
|
+
*/
|
|
2854
|
+
pushDeliveryStream() {
|
|
2855
|
+
this.deliveryPushChain = this.deliveryPushChain.then(async () => {
|
|
2856
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2857
|
+
try {
|
|
2858
|
+
await this.sendDeliveryStream(
|
|
2859
|
+
this.lastAckedDeliverySeq,
|
|
2860
|
+
false,
|
|
2861
|
+
this.clientNodeId ?? void 0
|
|
2862
|
+
);
|
|
2863
|
+
} catch {
|
|
2864
|
+
}
|
|
2865
|
+
});
|
|
2866
|
+
}
|
|
2867
|
+
async relayVisibleOperations(operations2) {
|
|
2868
|
+
const visibleOperations = [];
|
|
2869
|
+
for (const op of operations2) {
|
|
2870
|
+
if (await this.operationVisibleToClient(op)) {
|
|
2871
|
+
visibleOperations.push(op);
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2499
2874
|
if (visibleOperations.length === 0) return;
|
|
2875
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2500
2876
|
const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
|
|
2501
2877
|
const msg = {
|
|
2502
2878
|
type: "operation-batch",
|
|
@@ -2505,14 +2881,62 @@ var ClientSession = class {
|
|
|
2505
2881
|
isFinal: true,
|
|
2506
2882
|
batchIndex: 0
|
|
2507
2883
|
};
|
|
2884
|
+
this.trackPendingRelay(msg, visibleOperations);
|
|
2508
2885
|
this.sendToClient(msg);
|
|
2509
2886
|
}
|
|
2887
|
+
/** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
|
|
2888
|
+
trackPendingRelay(msg, ops) {
|
|
2889
|
+
if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
|
|
2890
|
+
const oldest = this.pendingRelays.keys().next().value;
|
|
2891
|
+
if (oldest !== void 0) {
|
|
2892
|
+
this.pendingRelays.delete(oldest);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
|
|
2896
|
+
}
|
|
2897
|
+
/**
|
|
2898
|
+
* Retransmit relay batches this client has not acknowledged within `staleMs`.
|
|
2899
|
+
* Called on a periodic tick by the server (and directly by tests). Redelivering an
|
|
2900
|
+
* already-applied op is harmless: the client dedups by content-addressed id.
|
|
2901
|
+
*/
|
|
2902
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
2903
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2904
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2905
|
+
this.pushDeliveryStream();
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
if (this.pendingRelays.size === 0) return;
|
|
2909
|
+
const cutoff = Date.now() - staleMs;
|
|
2910
|
+
for (const { message, sentAtMs } of this.pendingRelays.values()) {
|
|
2911
|
+
if (sentAtMs <= cutoff) {
|
|
2912
|
+
this.sendToClient(message);
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2510
2916
|
/**
|
|
2511
2917
|
* Close this session.
|
|
2512
2918
|
*/
|
|
2919
|
+
/**
|
|
2920
|
+
* Hand any unacknowledged relays to the server so they survive this session and are
|
|
2921
|
+
* redelivered when this client reconnects (per-node buffer), then clear them. Called
|
|
2922
|
+
* from every session-teardown path (explicit close and transport close).
|
|
2923
|
+
*/
|
|
2924
|
+
flushOrphanedRelays() {
|
|
2925
|
+
if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
|
|
2926
|
+
const ops = [];
|
|
2927
|
+
for (const entry of this.pendingRelays.values()) {
|
|
2928
|
+
ops.push(...entry.ops);
|
|
2929
|
+
}
|
|
2930
|
+
if (ops.length > 0) {
|
|
2931
|
+
this.onOrphanedRelays(this.clientNodeId, ops);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
this.pendingRelays.clear();
|
|
2935
|
+
}
|
|
2513
2936
|
close(reason) {
|
|
2514
2937
|
if (this.state === "closed") return;
|
|
2515
2938
|
this.state = "closed";
|
|
2939
|
+
this.flushOrphanedRelays();
|
|
2516
2940
|
if (this.transport.isConnected()) {
|
|
2517
2941
|
this.transport.close(1e3, reason ?? "session closed");
|
|
2518
2942
|
}
|
|
@@ -2567,6 +2991,10 @@ var ClientSession = class {
|
|
|
2567
2991
|
await this.handleOperationBatch(message);
|
|
2568
2992
|
break;
|
|
2569
2993
|
case "acknowledgment":
|
|
2994
|
+
this.pendingRelays.delete(message.acknowledgedMessageId);
|
|
2995
|
+
if (message.deliverySequence !== void 0) {
|
|
2996
|
+
this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
|
|
2997
|
+
}
|
|
2570
2998
|
break;
|
|
2571
2999
|
case "error":
|
|
2572
3000
|
break;
|
|
@@ -2643,6 +3071,15 @@ var ClientSession = class {
|
|
|
2643
3071
|
this.syncQuerySubsets = [];
|
|
2644
3072
|
}
|
|
2645
3073
|
this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
|
|
3074
|
+
this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
|
|
3075
|
+
const clientUsesWatermark = this.clientDeliveryWatermark !== null;
|
|
3076
|
+
let serverMaxDelivery = 0;
|
|
3077
|
+
if (clientUsesWatermark) {
|
|
3078
|
+
serverMaxDelivery = await this.store.getMaxDeliverySequence();
|
|
3079
|
+
if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
|
|
3080
|
+
this.clientDeliveryWatermark = 0;
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
2646
3083
|
const serverVector = this.store.getVersionVector();
|
|
2647
3084
|
const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
|
|
2648
3085
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
@@ -2673,6 +3110,10 @@ var ClientSession = class {
|
|
|
2673
3110
|
accepted: true,
|
|
2674
3111
|
selectedWireFormat,
|
|
2675
3112
|
serverTime: Date.now(),
|
|
3113
|
+
// The server's highest delivery sequence, so a client whose persisted watermark
|
|
3114
|
+
// is ahead of it (server rolled back) can reset to a full resync. Sent only to
|
|
3115
|
+
// watermark-using clients.
|
|
3116
|
+
...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
|
|
2676
3117
|
// Advertise central blob storage so the client uploads the bytes behind
|
|
2677
3118
|
// its blob fields, keeping them available after the author goes offline.
|
|
2678
3119
|
...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
|
|
@@ -2683,16 +3124,28 @@ var ClientSession = class {
|
|
|
2683
3124
|
this.sendToClient(response);
|
|
2684
3125
|
this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
|
|
2685
3126
|
this.state = "syncing";
|
|
2686
|
-
|
|
2687
|
-
|
|
3127
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
3128
|
+
this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
|
|
3129
|
+
const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
|
|
3130
|
+
await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
|
|
3131
|
+
} else {
|
|
3132
|
+
const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
|
|
3133
|
+
await this.sendDelta(clientVector);
|
|
3134
|
+
}
|
|
2688
3135
|
this.state = "streaming";
|
|
3136
|
+
if (this.takeOrphanedRelays && this.clientNodeId) {
|
|
3137
|
+
const buffered = this.takeOrphanedRelays(this.clientNodeId);
|
|
3138
|
+
if (buffered.length > 0) {
|
|
3139
|
+
this.relayOperations(buffered);
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
2689
3142
|
}
|
|
2690
3143
|
async handleOperationBatch(msg) {
|
|
2691
3144
|
const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2692
3145
|
const applied = [];
|
|
2693
3146
|
const rejected = [];
|
|
2694
3147
|
for (const op of operations2) {
|
|
2695
|
-
if (!this.operationVisibleToClient(op)) {
|
|
3148
|
+
if (!await this.operationVisibleToClient(op)) {
|
|
2696
3149
|
rejected.push(op);
|
|
2697
3150
|
continue;
|
|
2698
3151
|
}
|
|
@@ -2797,8 +3250,11 @@ var ClientSession = class {
|
|
|
2797
3250
|
const clientSeq = clientVector.get(nodeId) ?? 0;
|
|
2798
3251
|
if (serverSeq > clientSeq) {
|
|
2799
3252
|
const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
|
|
2800
|
-
const
|
|
2801
|
-
|
|
3253
|
+
for (const op of ops) {
|
|
3254
|
+
if (await this.operationVisibleToClient(op)) {
|
|
3255
|
+
missing.push(op);
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
2802
3258
|
}
|
|
2803
3259
|
}
|
|
2804
3260
|
if (missing.length === 0) {
|
|
@@ -2850,11 +3306,111 @@ var ClientSession = class {
|
|
|
2850
3306
|
});
|
|
2851
3307
|
}
|
|
2852
3308
|
}
|
|
2853
|
-
|
|
2854
|
-
|
|
3309
|
+
/**
|
|
3310
|
+
* Send the gap-free server->client delivery stream, resuming just after
|
|
3311
|
+
* `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
|
|
3312
|
+
* (commit order), scope-filtered for this session, and sent in batches that chain:
|
|
3313
|
+
* each batch's `baseDeliverySequence` equals the previous batch's
|
|
3314
|
+
* `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
|
|
3315
|
+
* applies a batch only when its watermark equals the base and advances the
|
|
3316
|
+
* watermark to the max, so a dropped batch stalls the watermark and is recovered by
|
|
3317
|
+
* the next handshake resend. Because delivery-sequence order respects causal order
|
|
3318
|
+
* (a dependency is always committed, and thus sequenced, before its dependent), no
|
|
3319
|
+
* topological sort is needed and the client never defers on a missing dependency
|
|
3320
|
+
* that is itself in this stream.
|
|
3321
|
+
*
|
|
3322
|
+
* The final batch advances the watermark to the highest delivery sequence scanned,
|
|
3323
|
+
* not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
|
|
3324
|
+
* next reconnect. Returns the number of operations actually sent.
|
|
3325
|
+
*/
|
|
3326
|
+
async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
|
|
3327
|
+
const scanChunk = Math.max(this.batchSize, 1) * 5;
|
|
3328
|
+
let scanCursor = fromDeliverySeq;
|
|
3329
|
+
let maxScanned = fromDeliverySeq;
|
|
3330
|
+
const visible = [];
|
|
3331
|
+
while (true) {
|
|
3332
|
+
const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
|
|
3333
|
+
const last = chunk[chunk.length - 1];
|
|
3334
|
+
if (last === void 0) break;
|
|
3335
|
+
scanCursor = last.deliverySequence;
|
|
3336
|
+
maxScanned = scanCursor;
|
|
3337
|
+
for (const delivered of chunk) {
|
|
3338
|
+
if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
|
|
3339
|
+
continue;
|
|
3340
|
+
}
|
|
3341
|
+
if (await this.operationVisibleToClient(delivered.operation)) {
|
|
3342
|
+
visible.push(delivered);
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
if (chunk.length < scanChunk) break;
|
|
3346
|
+
}
|
|
3347
|
+
if (visible.length === 0) {
|
|
3348
|
+
if (finalizeWhenEmpty) {
|
|
3349
|
+
this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
|
|
3350
|
+
}
|
|
3351
|
+
return 0;
|
|
3352
|
+
}
|
|
3353
|
+
const totalBatches = Math.ceil(visible.length / this.batchSize);
|
|
3354
|
+
let base = fromDeliverySeq;
|
|
3355
|
+
for (let i = 0; i < totalBatches; i++) {
|
|
3356
|
+
const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
|
|
3357
|
+
const lastInSlice = slice[slice.length - 1];
|
|
3358
|
+
if (lastInSlice === void 0) continue;
|
|
3359
|
+
const isFinal = i === totalBatches - 1;
|
|
3360
|
+
const lastSeq = lastInSlice.deliverySequence;
|
|
3361
|
+
const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
|
|
3362
|
+
this.sendDeliveryBatch(slice, base, max, i, isFinal);
|
|
3363
|
+
base = max;
|
|
3364
|
+
}
|
|
3365
|
+
return visible.length;
|
|
3366
|
+
}
|
|
3367
|
+
/** Build, track, and send one chained delivery-stream batch. */
|
|
3368
|
+
sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
|
|
3369
|
+
const batchMsg = {
|
|
3370
|
+
type: "operation-batch",
|
|
3371
|
+
messageId: (0, import_core10.generateUUIDv7)(),
|
|
3372
|
+
operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
|
|
3373
|
+
isFinal,
|
|
3374
|
+
batchIndex,
|
|
3375
|
+
baseDeliverySequence: base,
|
|
3376
|
+
maxDeliverySequence: max
|
|
3377
|
+
};
|
|
3378
|
+
this.sendToClient(batchMsg);
|
|
3379
|
+
if (slice.length > 0) {
|
|
3380
|
+
this.emitter?.emit({
|
|
3381
|
+
type: "sync:sent",
|
|
3382
|
+
operations: slice.map((delivered) => delivered.operation),
|
|
3383
|
+
batchSize: slice.length
|
|
3384
|
+
});
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
async operationVisibleToClient(op) {
|
|
3388
|
+
const scopes = this.authContext?.scopes;
|
|
3389
|
+
const subsets = this.syncQuerySubsets;
|
|
3390
|
+
const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
|
|
3391
|
+
const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
|
|
3392
|
+
if (!operationMatchesScopes(op, scopes, fullRecord)) {
|
|
2855
3393
|
return false;
|
|
2856
3394
|
}
|
|
2857
|
-
return (0, import_sync3.operationMatchesQuerySubsets)(op,
|
|
3395
|
+
return (0, import_sync3.operationMatchesQuerySubsets)(op, subsets, fullRecord);
|
|
3396
|
+
}
|
|
3397
|
+
/**
|
|
3398
|
+
* Read a record's current field values from the materialized store for scope /
|
|
3399
|
+
* query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
|
|
3400
|
+
* carries no fields) is still judged against the record's actual scope. Returns
|
|
3401
|
+
* undefined when the record cannot be read (never materialized, or no schema).
|
|
3402
|
+
*/
|
|
3403
|
+
async lookupRecordFields(collection, recordId) {
|
|
3404
|
+
try {
|
|
3405
|
+
const rows = await this.store.queryCollection(collection, {
|
|
3406
|
+
where: { id: recordId },
|
|
3407
|
+
includeDeleted: true,
|
|
3408
|
+
limit: 1
|
|
3409
|
+
});
|
|
3410
|
+
return rows[0];
|
|
3411
|
+
} catch {
|
|
3412
|
+
return void 0;
|
|
3413
|
+
}
|
|
2858
3414
|
}
|
|
2859
3415
|
handleAwarenessUpdate(msg) {
|
|
2860
3416
|
this.onAwarenessUpdate?.(this.sessionId, msg);
|
|
@@ -2899,6 +3455,7 @@ var ClientSession = class {
|
|
|
2899
3455
|
handleTransportClose() {
|
|
2900
3456
|
if (this.state === "closed") return;
|
|
2901
3457
|
this.state = "closed";
|
|
3458
|
+
this.flushOrphanedRelays();
|
|
2902
3459
|
this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
|
|
2903
3460
|
this.onClose?.(this.sessionId);
|
|
2904
3461
|
}
|
|
@@ -3277,6 +3834,57 @@ var YjsDocRelay = class {
|
|
|
3277
3834
|
// src/server/route-context.ts
|
|
3278
3835
|
init_cjs_shims();
|
|
3279
3836
|
var import_core12 = require("@korajs/core");
|
|
3837
|
+
|
|
3838
|
+
// src/apply/route-predicate.ts
|
|
3839
|
+
init_cjs_shims();
|
|
3840
|
+
function evaluateField(actual, operators) {
|
|
3841
|
+
if ("$eq" in operators && !Object.is(actual, operators.$eq)) {
|
|
3842
|
+
return false;
|
|
3843
|
+
}
|
|
3844
|
+
if ("$ne" in operators && Object.is(actual, operators.$ne)) {
|
|
3845
|
+
return false;
|
|
3846
|
+
}
|
|
3847
|
+
if (operators.$in !== void 0 && !operators.$in.some((candidate) => Object.is(actual, candidate))) {
|
|
3848
|
+
return false;
|
|
3849
|
+
}
|
|
3850
|
+
const numericChecks = [
|
|
3851
|
+
["$lt", (a, b) => a < b],
|
|
3852
|
+
["$lte", (a, b) => a <= b],
|
|
3853
|
+
["$gt", (a, b) => a > b],
|
|
3854
|
+
["$gte", (a, b) => a >= b]
|
|
3855
|
+
];
|
|
3856
|
+
for (const [operator, compare] of numericChecks) {
|
|
3857
|
+
const bound = operators[operator];
|
|
3858
|
+
if (bound === void 0) {
|
|
3859
|
+
continue;
|
|
3860
|
+
}
|
|
3861
|
+
if (typeof actual !== "number" || typeof bound !== "number") {
|
|
3862
|
+
return false;
|
|
3863
|
+
}
|
|
3864
|
+
if (!compare(actual, bound)) {
|
|
3865
|
+
return false;
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
return true;
|
|
3869
|
+
}
|
|
3870
|
+
function evaluateRoutePredicate(record, predicate) {
|
|
3871
|
+
const fields = Object.keys(predicate);
|
|
3872
|
+
if (fields.length === 0) {
|
|
3873
|
+
return true;
|
|
3874
|
+
}
|
|
3875
|
+
if (record === null) {
|
|
3876
|
+
return false;
|
|
3877
|
+
}
|
|
3878
|
+
for (const field of fields) {
|
|
3879
|
+
const operators = predicate[field];
|
|
3880
|
+
if (operators && !evaluateField(record[field], operators)) {
|
|
3881
|
+
return false;
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
return true;
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
// src/server/route-context.ts
|
|
3280
3888
|
function recordMatchesScope(collection, record, scope) {
|
|
3281
3889
|
const collectionScope = scope[collection];
|
|
3282
3890
|
if (!collectionScope) {
|
|
@@ -3298,9 +3906,9 @@ function pickFields(record, keys) {
|
|
|
3298
3906
|
}
|
|
3299
3907
|
return picked;
|
|
3300
3908
|
}
|
|
3301
|
-
async function buildRouteOperation(store, mutation) {
|
|
3909
|
+
async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
|
|
3302
3910
|
const nodeId = store.getNodeId();
|
|
3303
|
-
const clock = new import_core12.HybridLogicalClock(nodeId);
|
|
3911
|
+
const clock = clockOverride ?? new import_core12.HybridLogicalClock(nodeId);
|
|
3304
3912
|
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
3305
3913
|
const sequenceNumber = nextServerSequenceNumber(store);
|
|
3306
3914
|
if (mutation.type === "insert") {
|
|
@@ -3325,9 +3933,20 @@ async function buildRouteOperation(store, mutation) {
|
|
|
3325
3933
|
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
3326
3934
|
);
|
|
3327
3935
|
}
|
|
3328
|
-
const current = await store.findRecord(mutation.collection, mutation.recordId);
|
|
3936
|
+
const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
|
|
3329
3937
|
if (mutation.type === "update") {
|
|
3330
|
-
const
|
|
3938
|
+
const rawData = mutation.data ?? {};
|
|
3939
|
+
const data = {};
|
|
3940
|
+
const atomicOps = {};
|
|
3941
|
+
for (const key of Object.keys(rawData)) {
|
|
3942
|
+
const value = rawData[key];
|
|
3943
|
+
if ((0, import_core12.isAtomicOp)(value)) {
|
|
3944
|
+
data[key] = (0, import_core12.resolveAtomicOp)(current ? current[key] : void 0, value);
|
|
3945
|
+
atomicOps[key] = (0, import_core12.toAtomicOp)(value);
|
|
3946
|
+
} else {
|
|
3947
|
+
data[key] = value;
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3331
3950
|
return (0, import_core12.createOperation)(
|
|
3332
3951
|
{
|
|
3333
3952
|
nodeId,
|
|
@@ -3335,10 +3954,11 @@ async function buildRouteOperation(store, mutation) {
|
|
|
3335
3954
|
collection: mutation.collection,
|
|
3336
3955
|
recordId: mutation.recordId,
|
|
3337
3956
|
data,
|
|
3338
|
-
previousData: current ? pickFields(current, Object.keys(
|
|
3957
|
+
previousData: current ? pickFields(current, Object.keys(rawData)) : {},
|
|
3339
3958
|
sequenceNumber,
|
|
3340
3959
|
causalDeps: [],
|
|
3341
|
-
schemaVersion
|
|
3960
|
+
schemaVersion,
|
|
3961
|
+
...Object.keys(atomicOps).length > 0 ? { atomicOps } : {}
|
|
3342
3962
|
},
|
|
3343
3963
|
clock
|
|
3344
3964
|
);
|
|
@@ -3376,45 +3996,200 @@ function createRouteContext(server, store) {
|
|
|
3376
3996
|
);
|
|
3377
3997
|
return run;
|
|
3378
3998
|
}
|
|
3999
|
+
async function prepareMutation(mutation, scope) {
|
|
4000
|
+
let op;
|
|
4001
|
+
try {
|
|
4002
|
+
op = await buildRouteOperation(store, mutation);
|
|
4003
|
+
} catch (error) {
|
|
4004
|
+
if (error instanceof RouteMutationError) {
|
|
4005
|
+
return {
|
|
4006
|
+
ok: false,
|
|
4007
|
+
rejection: {
|
|
4008
|
+
ok: false,
|
|
4009
|
+
code: error.code,
|
|
4010
|
+
message: error.message,
|
|
4011
|
+
retriable: isRetriableRejection(error.code)
|
|
4012
|
+
}
|
|
4013
|
+
};
|
|
4014
|
+
}
|
|
4015
|
+
throw error;
|
|
4016
|
+
}
|
|
4017
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
4018
|
+
return {
|
|
4019
|
+
ok: false,
|
|
4020
|
+
rejection: {
|
|
4021
|
+
ok: false,
|
|
4022
|
+
code: "SCOPE_VIOLATION",
|
|
4023
|
+
message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
|
|
4024
|
+
retriable: isRetriableRejection("SCOPE_VIOLATION")
|
|
4025
|
+
}
|
|
4026
|
+
};
|
|
4027
|
+
}
|
|
4028
|
+
return { ok: true, op };
|
|
4029
|
+
}
|
|
4030
|
+
async function commitOperation(op, collection) {
|
|
4031
|
+
const result = await server.applyLocalOperation(op);
|
|
4032
|
+
if (result.result !== "applied") {
|
|
4033
|
+
const code = result.rejection?.code ?? "NOT_APPLIED";
|
|
4034
|
+
return {
|
|
4035
|
+
ok: false,
|
|
4036
|
+
code,
|
|
4037
|
+
message: result.rejection?.message ?? `Operation on "${collection}" was not applied (${result.result}).`,
|
|
4038
|
+
// Prefer the pipeline's own classification when present; fall back
|
|
4039
|
+
// to classifying the code so this stays correct if new codes appear.
|
|
4040
|
+
retriable: result.rejection?.retriable ?? isRetriableRejection(code)
|
|
4041
|
+
};
|
|
4042
|
+
}
|
|
4043
|
+
const record = await store.findRecord(collection, op.recordId);
|
|
4044
|
+
return { ok: true, operation: op, record };
|
|
4045
|
+
}
|
|
4046
|
+
async function rawApply(mutation, scope) {
|
|
4047
|
+
const prepared = await prepareMutation(mutation, scope);
|
|
4048
|
+
if (!prepared.ok) {
|
|
4049
|
+
return prepared.rejection;
|
|
4050
|
+
}
|
|
4051
|
+
return commitOperation(prepared.op, mutation.collection);
|
|
4052
|
+
}
|
|
4053
|
+
async function runConditionalViaStore(storeApplyConditional, spec, options) {
|
|
4054
|
+
const scope = options?.scope;
|
|
4055
|
+
const mutations = [];
|
|
4056
|
+
const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
|
|
4057
|
+
if (hasTargetUpdate) {
|
|
4058
|
+
mutations.push({
|
|
4059
|
+
collection: spec.collection,
|
|
4060
|
+
type: "update",
|
|
4061
|
+
recordId: spec.id,
|
|
4062
|
+
data: spec.update
|
|
4063
|
+
});
|
|
4064
|
+
}
|
|
4065
|
+
if (spec.also) {
|
|
4066
|
+
mutations.push(...spec.also);
|
|
4067
|
+
}
|
|
4068
|
+
let result;
|
|
4069
|
+
try {
|
|
4070
|
+
result = await storeApplyConditional({
|
|
4071
|
+
target: { collection: spec.collection, id: spec.id },
|
|
4072
|
+
...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
|
|
4073
|
+
admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
|
|
4074
|
+
buildOperations: async (current, context) => {
|
|
4075
|
+
const ops = [];
|
|
4076
|
+
for (let i = 0; i < mutations.length; i++) {
|
|
4077
|
+
const mutation = mutations[i];
|
|
4078
|
+
const useLockedCurrent = i === 0 && hasTargetUpdate;
|
|
4079
|
+
const op = await buildRouteOperation(
|
|
4080
|
+
store,
|
|
4081
|
+
mutation,
|
|
4082
|
+
useLockedCurrent ? { record: current } : void 0,
|
|
4083
|
+
context.clock
|
|
4084
|
+
);
|
|
4085
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
4086
|
+
throw new RouteMutationError(
|
|
4087
|
+
"SCOPE_VIOLATION",
|
|
4088
|
+
`Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
4089
|
+
);
|
|
4090
|
+
}
|
|
4091
|
+
ops.push(op);
|
|
4092
|
+
}
|
|
4093
|
+
return ops;
|
|
4094
|
+
}
|
|
4095
|
+
});
|
|
4096
|
+
} catch (error) {
|
|
4097
|
+
if (error instanceof RouteMutationError) {
|
|
4098
|
+
return {
|
|
4099
|
+
ok: false,
|
|
4100
|
+
code: error.code,
|
|
4101
|
+
message: error.message,
|
|
4102
|
+
retriable: isRetriableRejection(error.code)
|
|
4103
|
+
};
|
|
4104
|
+
}
|
|
4105
|
+
throw error;
|
|
4106
|
+
}
|
|
4107
|
+
if (!result.admitted) {
|
|
4108
|
+
return {
|
|
4109
|
+
ok: false,
|
|
4110
|
+
code: spec.reject?.code ?? "CONDITION_NOT_MET",
|
|
4111
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
4112
|
+
retriable: false
|
|
4113
|
+
};
|
|
4114
|
+
}
|
|
4115
|
+
if (result.idempotent) {
|
|
4116
|
+
const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
|
|
4117
|
+
return { ok: true, idempotent: true, operations: [], records: records2 };
|
|
4118
|
+
}
|
|
4119
|
+
server.relayServerOperations(result.applied);
|
|
4120
|
+
const records = [];
|
|
4121
|
+
for (const op of result.applied) {
|
|
4122
|
+
records.push(await store.findRecord(op.collection, op.recordId));
|
|
4123
|
+
}
|
|
4124
|
+
return { ok: true, idempotent: false, operations: result.applied, records };
|
|
4125
|
+
}
|
|
3379
4126
|
return {
|
|
3380
4127
|
apply(mutation, options) {
|
|
4128
|
+
return serialize(() => rawApply(mutation, options?.scope));
|
|
4129
|
+
},
|
|
4130
|
+
applyConditional(spec, options) {
|
|
4131
|
+
const storeApplyConditional = store.applyConditional;
|
|
4132
|
+
if (storeApplyConditional) {
|
|
4133
|
+
return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
|
|
4134
|
+
}
|
|
3381
4135
|
return serialize(async () => {
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
4136
|
+
if (spec.idempotencyKey) {
|
|
4137
|
+
const existing = await store.findRecord(
|
|
4138
|
+
spec.idempotencyKey.collection,
|
|
4139
|
+
spec.idempotencyKey.id
|
|
4140
|
+
);
|
|
4141
|
+
if (existing) {
|
|
4142
|
+
return { ok: true, idempotent: true, operations: [], records: [existing] };
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
if (spec.if && Object.keys(spec.if).length > 0) {
|
|
4146
|
+
const target = await store.findRecord(spec.collection, spec.id);
|
|
4147
|
+
if (!evaluateRoutePredicate(target, spec.if)) {
|
|
4148
|
+
const code = spec.reject?.code ?? "CONDITION_NOT_MET";
|
|
3387
4149
|
return {
|
|
3388
4150
|
ok: false,
|
|
3389
|
-
code
|
|
3390
|
-
message:
|
|
3391
|
-
retriable:
|
|
4151
|
+
code,
|
|
4152
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
4153
|
+
retriable: false
|
|
3392
4154
|
};
|
|
3393
4155
|
}
|
|
3394
|
-
throw error;
|
|
3395
4156
|
}
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
4157
|
+
const mutations = [];
|
|
4158
|
+
if (spec.update !== void 0 && spec.update !== null) {
|
|
4159
|
+
mutations.push({
|
|
4160
|
+
collection: spec.collection,
|
|
4161
|
+
type: "update",
|
|
4162
|
+
recordId: spec.id,
|
|
4163
|
+
data: spec.update
|
|
4164
|
+
});
|
|
3403
4165
|
}
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
4166
|
+
if (spec.also) {
|
|
4167
|
+
mutations.push(...spec.also);
|
|
4168
|
+
}
|
|
4169
|
+
const prepared = [];
|
|
4170
|
+
for (const mutation of mutations) {
|
|
4171
|
+
const p = await prepareMutation(mutation, options?.scope);
|
|
4172
|
+
if (!p.ok) {
|
|
4173
|
+
return p.rejection;
|
|
4174
|
+
}
|
|
4175
|
+
prepared.push({ op: p.op, collection: mutation.collection });
|
|
4176
|
+
}
|
|
4177
|
+
const operations2 = [];
|
|
4178
|
+
const records = [];
|
|
4179
|
+
for (const { op, collection } of prepared) {
|
|
4180
|
+
const result = await commitOperation(op, collection);
|
|
4181
|
+
if (!result.ok) {
|
|
4182
|
+
return {
|
|
4183
|
+
ok: false,
|
|
4184
|
+
code: result.code,
|
|
4185
|
+
message: result.message,
|
|
4186
|
+
retriable: result.retriable
|
|
4187
|
+
};
|
|
4188
|
+
}
|
|
4189
|
+
operations2.push(result.operation);
|
|
4190
|
+
records.push(result.record);
|
|
3415
4191
|
}
|
|
3416
|
-
|
|
3417
|
-
return { ok: true, operation: op, record };
|
|
4192
|
+
return { ok: true, idempotent: false, operations: operations2, records };
|
|
3418
4193
|
});
|
|
3419
4194
|
},
|
|
3420
4195
|
async query(collection, options) {
|
|
@@ -3444,7 +4219,8 @@ var DEFAULT_BATCH_SIZE2 = 100;
|
|
|
3444
4219
|
var DEFAULT_SCHEMA_VERSION2 = 1;
|
|
3445
4220
|
var DEFAULT_HOST = "0.0.0.0";
|
|
3446
4221
|
var DEFAULT_PATH = "/";
|
|
3447
|
-
var
|
|
4222
|
+
var RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
|
|
4223
|
+
var KoraSyncServer = class _KoraSyncServer {
|
|
3448
4224
|
store;
|
|
3449
4225
|
auth;
|
|
3450
4226
|
serializer;
|
|
@@ -3474,6 +4250,20 @@ var KoraSyncServer = class {
|
|
|
3474
4250
|
serverVersion = "1.0.0-beta.0";
|
|
3475
4251
|
wsServer = null;
|
|
3476
4252
|
running = false;
|
|
4253
|
+
/**
|
|
4254
|
+
* Periodic tick that retransmits relay batches connected clients have not
|
|
4255
|
+
* acknowledged, so a relay dropped by a lossy transport is redelivered instead of
|
|
4256
|
+
* leaving that client with a permanent version-vector gap (a lost operation).
|
|
4257
|
+
*/
|
|
4258
|
+
relayRetransmitTimer = null;
|
|
4259
|
+
/**
|
|
4260
|
+
* Relay operations a client never acknowledged before it disconnected, buffered by
|
|
4261
|
+
* client node id so they can be redelivered on its next connection. Bounded per
|
|
4262
|
+
* node and expired by age so a client that never returns cannot grow this forever.
|
|
4263
|
+
*/
|
|
4264
|
+
orphanedRelaysByNode = /* @__PURE__ */ new Map();
|
|
4265
|
+
static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
|
|
4266
|
+
static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
|
|
3477
4267
|
constructor(config) {
|
|
3478
4268
|
this.store = config.store;
|
|
3479
4269
|
this.auth = config.auth ?? null;
|
|
@@ -3501,6 +4291,57 @@ var KoraSyncServer = class {
|
|
|
3501
4291
|
if (!this.emitter) {
|
|
3502
4292
|
this.emitter = new import_internal2.SimpleEventEmitter();
|
|
3503
4293
|
}
|
|
4294
|
+
this.relayRetransmitTimer = setInterval(() => {
|
|
4295
|
+
this.retransmitPendingRelays(RELAY_RETRANSMIT_INTERVAL_MS);
|
|
4296
|
+
}, RELAY_RETRANSMIT_INTERVAL_MS);
|
|
4297
|
+
this.relayRetransmitTimer.unref?.();
|
|
4298
|
+
}
|
|
4299
|
+
/**
|
|
4300
|
+
* Retransmit relay batches that connected clients have not acknowledged within
|
|
4301
|
+
* `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
|
|
4302
|
+
* already-applied operation is harmless (clients dedup by content-addressed id).
|
|
4303
|
+
*/
|
|
4304
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
4305
|
+
for (const session of this.sessions.values()) {
|
|
4306
|
+
session.retransmitPendingRelays(staleMs);
|
|
4307
|
+
}
|
|
4308
|
+
this.expireOrphanedRelays();
|
|
4309
|
+
}
|
|
4310
|
+
/**
|
|
4311
|
+
* Buffer relay operations a disconnected client never acknowledged, keyed by its
|
|
4312
|
+
* node id, deduped by operation id and bounded by count. Redelivered when the
|
|
4313
|
+
* client reconnects (see {@link takeOrphanedRelays}).
|
|
4314
|
+
*/
|
|
4315
|
+
bufferOrphanedRelays(nodeId, ops) {
|
|
4316
|
+
if (ops.length === 0) return;
|
|
4317
|
+
const existing = this.orphanedRelaysByNode.get(nodeId);
|
|
4318
|
+
const merged = existing ? existing.ops : [];
|
|
4319
|
+
const seen = new Set(merged.map((op) => op.id));
|
|
4320
|
+
for (const op of ops) {
|
|
4321
|
+
if (!seen.has(op.id)) {
|
|
4322
|
+
merged.push(op);
|
|
4323
|
+
seen.add(op.id);
|
|
4324
|
+
}
|
|
4325
|
+
}
|
|
4326
|
+
const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
|
|
4327
|
+
this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
|
|
4328
|
+
}
|
|
4329
|
+
/** Remove and return a node's buffered orphaned relay operations, if any. */
|
|
4330
|
+
takeOrphanedRelays(nodeId) {
|
|
4331
|
+
const entry = this.orphanedRelaysByNode.get(nodeId);
|
|
4332
|
+
if (!entry) return [];
|
|
4333
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4334
|
+
return entry.ops;
|
|
4335
|
+
}
|
|
4336
|
+
/** Drop buffered orphaned relays older than the TTL (client never returned). */
|
|
4337
|
+
expireOrphanedRelays() {
|
|
4338
|
+
if (this.orphanedRelaysByNode.size === 0) return;
|
|
4339
|
+
const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
|
|
4340
|
+
for (const [nodeId, entry] of this.orphanedRelaysByNode) {
|
|
4341
|
+
if (entry.bufferedAtMs <= cutoff) {
|
|
4342
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
3504
4345
|
}
|
|
3505
4346
|
/**
|
|
3506
4347
|
* The trusted, scoped data-plane context (`apply` / `query` / `findById`).
|
|
@@ -3627,6 +4468,11 @@ var KoraSyncServer = class {
|
|
|
3627
4468
|
event: "server.stopping",
|
|
3628
4469
|
details: { connectedClients: this.sessions.size }
|
|
3629
4470
|
});
|
|
4471
|
+
if (this.relayRetransmitTimer) {
|
|
4472
|
+
clearInterval(this.relayRetransmitTimer);
|
|
4473
|
+
this.relayRetransmitTimer = null;
|
|
4474
|
+
}
|
|
4475
|
+
this.orphanedRelaysByNode.clear();
|
|
3630
4476
|
this.awarenessRelay.clear();
|
|
3631
4477
|
this.yjsDocRelay.clear();
|
|
3632
4478
|
this.blobChunkRelay.clear();
|
|
@@ -3788,7 +4634,11 @@ var KoraSyncServer = class {
|
|
|
3788
4634
|
...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
|
|
3789
4635
|
onClose: (sid) => {
|
|
3790
4636
|
this.handleSessionClose(sid);
|
|
3791
|
-
}
|
|
4637
|
+
},
|
|
4638
|
+
onOrphanedRelays: (nodeId, ops) => {
|
|
4639
|
+
this.bufferOrphanedRelays(nodeId, ops);
|
|
4640
|
+
},
|
|
4641
|
+
takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
|
|
3792
4642
|
});
|
|
3793
4643
|
this.sessions.set(sessionId, session);
|
|
3794
4644
|
this.yjsDocRelay.addClient(sessionId, transport);
|
|
@@ -3848,6 +4698,22 @@ var KoraSyncServer = class {
|
|
|
3848
4698
|
}
|
|
3849
4699
|
return result;
|
|
3850
4700
|
}
|
|
4701
|
+
/**
|
|
4702
|
+
* Relay already-applied, server-originated operations to every connected client.
|
|
4703
|
+
* Used by the conditional route apply, which commits its operations atomically
|
|
4704
|
+
* through the store (bypassing {@link applyLocalOperation}) and then fans them
|
|
4705
|
+
* out. Each session still enforces its own per-scope visibility filter.
|
|
4706
|
+
*
|
|
4707
|
+
* @param operations - Operations that have already been committed to the store
|
|
4708
|
+
*/
|
|
4709
|
+
relayServerOperations(operations2) {
|
|
4710
|
+
if (operations2.length === 0) {
|
|
4711
|
+
return;
|
|
4712
|
+
}
|
|
4713
|
+
for (const session of this.sessions.values()) {
|
|
4714
|
+
session.relayOperations(operations2);
|
|
4715
|
+
}
|
|
4716
|
+
}
|
|
3851
4717
|
/**
|
|
3852
4718
|
* Collect every blob reference still reachable from live records on the server.
|
|
3853
4719
|
*
|