@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.js
CHANGED
|
@@ -64,7 +64,7 @@ function isRetriableRejection(code) {
|
|
|
64
64
|
import { generateUUIDv7 } from "@korajs/core";
|
|
65
65
|
|
|
66
66
|
// src/store/materialization.ts
|
|
67
|
-
import { StorageError, quoteIdent } from "@korajs/core";
|
|
67
|
+
import { StorageError, quoteIdent, replayOperationsForRecord } from "@korajs/core";
|
|
68
68
|
function fieldTypeToSql(descriptor, dialect) {
|
|
69
69
|
switch (descriptor.kind) {
|
|
70
70
|
case "string":
|
|
@@ -146,30 +146,6 @@ function generateAllCollectionDDL(schema, dialect) {
|
|
|
146
146
|
}
|
|
147
147
|
return statements;
|
|
148
148
|
}
|
|
149
|
-
function replayOperationsForRecord(ops) {
|
|
150
|
-
let record = null;
|
|
151
|
-
let deleted = false;
|
|
152
|
-
for (const op of ops) {
|
|
153
|
-
switch (op.type) {
|
|
154
|
-
case "insert":
|
|
155
|
-
if (op.data) {
|
|
156
|
-
record = { ...op.data };
|
|
157
|
-
deleted = false;
|
|
158
|
-
}
|
|
159
|
-
break;
|
|
160
|
-
case "update":
|
|
161
|
-
if (op.data) {
|
|
162
|
-
record = { ...record ?? {}, ...op.data };
|
|
163
|
-
deleted = false;
|
|
164
|
-
}
|
|
165
|
-
break;
|
|
166
|
-
case "delete":
|
|
167
|
-
deleted = true;
|
|
168
|
-
break;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return deleted ? null : record;
|
|
172
|
-
}
|
|
173
149
|
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
174
150
|
var BASE64_REVERSE = new Map(
|
|
175
151
|
Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
|
|
@@ -265,6 +241,14 @@ var MemoryServerStore = class {
|
|
|
265
241
|
operations = [];
|
|
266
242
|
operationIndex = /* @__PURE__ */ new Map();
|
|
267
243
|
versionVector = /* @__PURE__ */ new Map();
|
|
244
|
+
/**
|
|
245
|
+
* Server-assigned delivery sequence per operation id. Single-process, so a plain
|
|
246
|
+
* counter incremented at insert time gives commit-order delivery sequence with no
|
|
247
|
+
* gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
|
|
248
|
+
* is a forward walk.
|
|
249
|
+
*/
|
|
250
|
+
deliverySeqByOpId = /* @__PURE__ */ new Map();
|
|
251
|
+
deliverySeqCounter = 0;
|
|
268
252
|
schema = null;
|
|
269
253
|
/** Materialized records: collection -> recordId -> record data */
|
|
270
254
|
materializedRecords = /* @__PURE__ */ new Map();
|
|
@@ -298,6 +282,8 @@ var MemoryServerStore = class {
|
|
|
298
282
|
}
|
|
299
283
|
this.operations.push(op);
|
|
300
284
|
this.operationIndex.set(op.id, op);
|
|
285
|
+
this.deliverySeqCounter += 1;
|
|
286
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
301
287
|
const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
|
|
302
288
|
if (op.sequenceNumber > currentSeq) {
|
|
303
289
|
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
@@ -317,6 +303,23 @@ var MemoryServerStore = class {
|
|
|
317
303
|
this.assertOpen();
|
|
318
304
|
return this.operations.length;
|
|
319
305
|
}
|
|
306
|
+
async getMaxDeliverySequence() {
|
|
307
|
+
this.assertOpen();
|
|
308
|
+
return this.deliverySeqCounter;
|
|
309
|
+
}
|
|
310
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
311
|
+
this.assertOpen();
|
|
312
|
+
const result = [];
|
|
313
|
+
if (limit <= 0) return result;
|
|
314
|
+
for (const op of this.operations) {
|
|
315
|
+
const deliverySequence = this.deliverySeqByOpId.get(op.id) ?? 0;
|
|
316
|
+
if (deliverySequence > afterDeliverySequence) {
|
|
317
|
+
result.push({ operation: op, deliverySequence });
|
|
318
|
+
if (result.length >= limit) break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
320
323
|
async materializeCollection(collection) {
|
|
321
324
|
this.assertOpen();
|
|
322
325
|
if (this.schema?.collections[collection]) {
|
|
@@ -461,12 +464,16 @@ var MemoryServerStore = class {
|
|
|
461
464
|
this.operations.length = 0;
|
|
462
465
|
this.operationIndex.clear();
|
|
463
466
|
this.versionVector.clear();
|
|
467
|
+
this.deliverySeqByOpId.clear();
|
|
468
|
+
this.deliverySeqCounter = 0;
|
|
464
469
|
for (const [nid, seq] of versionVector) {
|
|
465
470
|
this.versionVector.set(nid, seq);
|
|
466
471
|
}
|
|
467
472
|
for (const op of operations2) {
|
|
468
473
|
this.operations.push(op);
|
|
469
474
|
this.operationIndex.set(op.id, op);
|
|
475
|
+
this.deliverySeqCounter += 1;
|
|
476
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
470
477
|
if (this.schema?.collections[op.collection]) {
|
|
471
478
|
this.rebuildMaterializedRecord(op.collection, op.recordId);
|
|
472
479
|
}
|
|
@@ -496,11 +503,12 @@ var MemoryServerStore = class {
|
|
|
496
503
|
return a.timestamp.wallTime - b.timestamp.wallTime;
|
|
497
504
|
if (a.timestamp.logical !== b.timestamp.logical)
|
|
498
505
|
return a.timestamp.logical - b.timestamp.logical;
|
|
499
|
-
return a.
|
|
506
|
+
return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
|
|
500
507
|
});
|
|
501
508
|
const parsedOps = recordOps.map((op) => ({
|
|
502
509
|
type: op.type,
|
|
503
|
-
data: op.data
|
|
510
|
+
data: op.data,
|
|
511
|
+
atomicOps: op.atomicOps ?? null
|
|
504
512
|
}));
|
|
505
513
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
506
514
|
if (recordData) {
|
|
@@ -606,8 +614,8 @@ var MemoryServerStore = class {
|
|
|
606
614
|
};
|
|
607
615
|
|
|
608
616
|
// src/store/postgres-server-store.ts
|
|
609
|
-
import { generateUUIDv7 as generateUUIDv72, quoteIdent as quoteIdent2 } from "@korajs/core";
|
|
610
|
-
import { and, asc, between, count, eq, sql } from "drizzle-orm";
|
|
617
|
+
import { HybridLogicalClock, generateUUIDv7 as generateUUIDv72, quoteIdent as quoteIdent2 } from "@korajs/core";
|
|
618
|
+
import { and, asc, between, count, desc, eq, gt, sql } from "drizzle-orm";
|
|
611
619
|
|
|
612
620
|
// src/store/drizzle-pg-schema.ts
|
|
613
621
|
import { bigint, index, integer, pgTable, text } from "drizzle-orm/pg-core";
|
|
@@ -623,6 +631,8 @@ var pgOperations = pgTable(
|
|
|
623
631
|
// JSON-serialized, null for deletes
|
|
624
632
|
previousData: text("previous_data"),
|
|
625
633
|
// JSON-serialized, null for insert/delete
|
|
634
|
+
atomicOps: text("atomic_ops"),
|
|
635
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
626
636
|
wallTime: bigint("wall_time", { mode: "number" }).notNull(),
|
|
627
637
|
logical: integer("logical").notNull(),
|
|
628
638
|
timestampNodeId: text("timestamp_node_id").notNull(),
|
|
@@ -630,12 +640,18 @@ var pgOperations = pgTable(
|
|
|
630
640
|
causalDeps: text("causal_deps").notNull().default("[]"),
|
|
631
641
|
// JSON array of op IDs
|
|
632
642
|
schemaVersion: integer("schema_version").notNull(),
|
|
633
|
-
receivedAt: bigint("received_at", { mode: "number" }).notNull()
|
|
643
|
+
receivedAt: bigint("received_at", { mode: "number" }).notNull(),
|
|
644
|
+
// Server-assigned monotonic delivery sequence, ordered by commit (assigned from
|
|
645
|
+
// a counter row locked inside the append transaction, so delivery order equals
|
|
646
|
+
// visibility order even across instances). Drives the gap-free server->client
|
|
647
|
+
// delivery watermark. Nullable only for pre-column rows, backfilled on startup.
|
|
648
|
+
deliverySeq: bigint("delivery_seq", { mode: "number" })
|
|
634
649
|
},
|
|
635
650
|
(table) => ({
|
|
636
651
|
nodeSeqIdx: index("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
637
652
|
collectionIdx: index("idx_pg_collection").on(table.collection),
|
|
638
|
-
receivedIdx: index("idx_pg_received").on(table.receivedAt)
|
|
653
|
+
receivedIdx: index("idx_pg_received").on(table.receivedAt),
|
|
654
|
+
deliveryIdx: index("idx_pg_delivery_seq").on(table.deliverySeq)
|
|
639
655
|
})
|
|
640
656
|
);
|
|
641
657
|
var pgSyncState = pgTable("sync_state", {
|
|
@@ -652,15 +668,43 @@ var PostgresServerStore = class {
|
|
|
652
668
|
ready;
|
|
653
669
|
schema = null;
|
|
654
670
|
closed = false;
|
|
655
|
-
|
|
671
|
+
/**
|
|
672
|
+
* Monotonic counter for this node's server-originated sequence numbers. Lazily
|
|
673
|
+
* seeded from the persisted version vector on first use, then advanced in memory
|
|
674
|
+
* (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
|
|
675
|
+
* applies never receive the same number. Null means "not yet seeded"; reset to
|
|
676
|
+
* null after a backup import so it re-seeds from the restored version vector.
|
|
677
|
+
*/
|
|
678
|
+
sequenceCounter = null;
|
|
679
|
+
/**
|
|
680
|
+
* Time source for HLC timestamps on server-originated operations. Injectable so a
|
|
681
|
+
* test can freeze wall-clock time and prove the conditional-apply ordering holds
|
|
682
|
+
* under same-millisecond commits (the case the advisory lock and clock advance
|
|
683
|
+
* exist to make correct). Defaults to the system clock in production.
|
|
684
|
+
*/
|
|
685
|
+
timeSource;
|
|
686
|
+
constructor(db, nodeId, timeSource) {
|
|
656
687
|
this.db = db;
|
|
657
688
|
this.nodeId = nodeId ?? generateUUIDv72();
|
|
689
|
+
this.timeSource = timeSource ?? { now: () => Date.now() };
|
|
658
690
|
this.ready = this.initialize();
|
|
659
691
|
}
|
|
660
692
|
getVersionVector() {
|
|
661
693
|
this.assertOpen();
|
|
662
694
|
return new Map(this.versionVector);
|
|
663
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* Atomically reserve this node's next server-operation sequence number. The
|
|
698
|
+
* synchronous increment cannot interleave on the single JS thread, so two
|
|
699
|
+
* concurrent conditional applies are always handed distinct numbers.
|
|
700
|
+
*/
|
|
701
|
+
reserveSequenceNumber() {
|
|
702
|
+
if (this.sequenceCounter === null) {
|
|
703
|
+
this.sequenceCounter = this.versionVector.get(this.nodeId) ?? 0;
|
|
704
|
+
}
|
|
705
|
+
this.sequenceCounter += 1;
|
|
706
|
+
return this.sequenceCounter;
|
|
707
|
+
}
|
|
664
708
|
getNodeId() {
|
|
665
709
|
return this.nodeId;
|
|
666
710
|
}
|
|
@@ -698,8 +742,9 @@ var PostgresServerStore = class {
|
|
|
698
742
|
return "duplicate";
|
|
699
743
|
}
|
|
700
744
|
const now = Date.now();
|
|
701
|
-
const row = this.serializeOperation(op, now);
|
|
702
745
|
await this.db.transaction(async (tx) => {
|
|
746
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
747
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
703
748
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
704
749
|
await tx.insert(pgSyncState).values({
|
|
705
750
|
nodeId: op.nodeId,
|
|
@@ -722,6 +767,73 @@ var PostgresServerStore = class {
|
|
|
722
767
|
}
|
|
723
768
|
return "applied";
|
|
724
769
|
}
|
|
770
|
+
/**
|
|
771
|
+
* Conditionally apply operations atomically, serialized across server instances
|
|
772
|
+
* on the target record via a transaction-scoped advisory lock. The lock makes
|
|
773
|
+
* the read-decide-write cycle atomic against concurrent instances, so a cap
|
|
774
|
+
* check like `responseCount < max` cannot be passed by two admissions at once.
|
|
775
|
+
*/
|
|
776
|
+
async applyConditional(input) {
|
|
777
|
+
this.assertOpen();
|
|
778
|
+
await this.ready;
|
|
779
|
+
this.assertSchema();
|
|
780
|
+
this.assertCollection(input.target.collection);
|
|
781
|
+
const schema = this.schema;
|
|
782
|
+
const collectionDef = schema.collections[input.target.collection];
|
|
783
|
+
const lockKey = `kora:${input.target.collection}:${input.target.id}`;
|
|
784
|
+
const result = await this.db.transaction(async (tx) => {
|
|
785
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
|
786
|
+
if (input.idempotencyKey) {
|
|
787
|
+
const existing = await tx.execute(
|
|
788
|
+
sql`SELECT 1 FROM ${sql.raw(quoteIdent2(input.idempotencyKey.collection))} WHERE id = ${input.idempotencyKey.id} AND _deleted = 0 LIMIT 1`
|
|
789
|
+
);
|
|
790
|
+
if (existing.length > 0) {
|
|
791
|
+
return { admitted: true, idempotent: true, applied: [] };
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
const rows = await tx.execute(
|
|
795
|
+
sql`SELECT * FROM ${sql.raw(quoteIdent2(input.target.collection))} WHERE id = ${input.target.id} AND _deleted = 0`
|
|
796
|
+
);
|
|
797
|
+
const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
|
|
798
|
+
if (!input.admit(current)) {
|
|
799
|
+
return { admitted: false, idempotent: false, applied: [] };
|
|
800
|
+
}
|
|
801
|
+
const clock = new HybridLogicalClock(this.nodeId, this.timeSource);
|
|
802
|
+
const latest = await this.latestOperationTimestamp(
|
|
803
|
+
tx,
|
|
804
|
+
input.target.collection,
|
|
805
|
+
input.target.id
|
|
806
|
+
);
|
|
807
|
+
if (latest) {
|
|
808
|
+
clock.advanceTo(latest);
|
|
809
|
+
}
|
|
810
|
+
const ops = await input.buildOperations(current, { clock });
|
|
811
|
+
const now = Date.now();
|
|
812
|
+
for (const op of ops) {
|
|
813
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
814
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
815
|
+
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
816
|
+
await tx.insert(pgSyncState).values({ nodeId: op.nodeId, maxSequenceNumber: op.sequenceNumber, lastSeenAt: now }).onConflictDoUpdate({
|
|
817
|
+
target: pgSyncState.nodeId,
|
|
818
|
+
set: {
|
|
819
|
+
maxSequenceNumber: sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
|
|
820
|
+
lastSeenAt: sql`${now}`
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
if (this.schema?.collections[op.collection]) {
|
|
824
|
+
await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return { admitted: true, idempotent: false, applied: ops };
|
|
828
|
+
});
|
|
829
|
+
for (const op of result.applied) {
|
|
830
|
+
const currentMax = this.versionVector.get(op.nodeId) ?? 0;
|
|
831
|
+
if (op.sequenceNumber > currentMax) {
|
|
832
|
+
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return result;
|
|
836
|
+
}
|
|
725
837
|
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
726
838
|
this.assertOpen();
|
|
727
839
|
await this.ready;
|
|
@@ -736,6 +848,23 @@ var PostgresServerStore = class {
|
|
|
736
848
|
const result = await this.db.select({ value: count() }).from(pgOperations);
|
|
737
849
|
return result[0]?.value ?? 0;
|
|
738
850
|
}
|
|
851
|
+
async getMaxDeliverySequence() {
|
|
852
|
+
this.assertOpen();
|
|
853
|
+
await this.ready;
|
|
854
|
+
const rows = await this.db.execute(
|
|
855
|
+
sql`SELECT COALESCE(MAX(delivery_seq), 0) AS m FROM operations`
|
|
856
|
+
);
|
|
857
|
+
return Number(rows[0]?.m ?? 0);
|
|
858
|
+
}
|
|
859
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
860
|
+
this.assertOpen();
|
|
861
|
+
await this.ready;
|
|
862
|
+
const rows = await this.db.select().from(pgOperations).where(gt(pgOperations.deliverySeq, afterDeliverySequence)).orderBy(asc(pgOperations.deliverySeq)).limit(limit);
|
|
863
|
+
return rows.map((row) => ({
|
|
864
|
+
operation: this.deserializeOperation(row),
|
|
865
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
866
|
+
}));
|
|
867
|
+
}
|
|
739
868
|
async materializeCollection(collection) {
|
|
740
869
|
this.assertOpen();
|
|
741
870
|
await this.ready;
|
|
@@ -799,7 +928,7 @@ var PostgresServerStore = class {
|
|
|
799
928
|
this.assertOpen();
|
|
800
929
|
await this.ready;
|
|
801
930
|
const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
|
|
802
|
-
const rows = await this.db.select().from(pgOperations).orderBy(asc(pgOperations.
|
|
931
|
+
const rows = await this.db.select().from(pgOperations).orderBy(asc(pgOperations.deliverySeq));
|
|
803
932
|
const operations2 = rows.map((row) => this.deserializeOperation(row));
|
|
804
933
|
return buildServerBackup(this.nodeId, operations2, this.versionVector);
|
|
805
934
|
}
|
|
@@ -814,6 +943,7 @@ var PostgresServerStore = class {
|
|
|
814
943
|
const result = await this.applyRemoteOperation(op);
|
|
815
944
|
if (result === "applied") restored++;
|
|
816
945
|
}
|
|
946
|
+
this.sequenceCounter = null;
|
|
817
947
|
return { operationsRestored: restored, success: true };
|
|
818
948
|
}
|
|
819
949
|
const now = Date.now();
|
|
@@ -823,20 +953,49 @@ var PostgresServerStore = class {
|
|
|
823
953
|
for (const [nid, seq] of versionVector) {
|
|
824
954
|
await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
|
|
825
955
|
}
|
|
956
|
+
let deliverySeq = 0;
|
|
826
957
|
for (const op of operations2) {
|
|
827
|
-
|
|
958
|
+
deliverySeq += 1;
|
|
959
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
828
960
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
829
961
|
}
|
|
962
|
+
await tx.execute(
|
|
963
|
+
sql`INSERT INTO delivery_counter (id, value) VALUES (1, ${deliverySeq})
|
|
964
|
+
ON CONFLICT (id) DO UPDATE SET value = ${deliverySeq}`
|
|
965
|
+
);
|
|
830
966
|
});
|
|
831
967
|
this.versionVector.clear();
|
|
832
968
|
for (const [nid, seq] of versionVector) {
|
|
833
969
|
this.versionVector.set(nid, seq);
|
|
834
970
|
}
|
|
971
|
+
this.sequenceCounter = null;
|
|
835
972
|
return { operationsRestored: operations2.length, success: true };
|
|
836
973
|
}
|
|
837
974
|
// ---------------------------------------------------------------------------
|
|
838
975
|
// Materialization internals
|
|
839
976
|
// ---------------------------------------------------------------------------
|
|
977
|
+
/**
|
|
978
|
+
* The HLC timestamp of the most recent operation on a record, by total order
|
|
979
|
+
* (wallTime, logical, nodeId), or null when the record has no operations. Used
|
|
980
|
+
* by {@link applyConditional} to advance its clock past the record's latest
|
|
981
|
+
* write so newly built operations sort strictly after it.
|
|
982
|
+
*/
|
|
983
|
+
async latestOperationTimestamp(txOrDb, collection, recordId) {
|
|
984
|
+
const rows = await txOrDb.select({
|
|
985
|
+
wallTime: pgOperations.wallTime,
|
|
986
|
+
logical: pgOperations.logical,
|
|
987
|
+
timestampNodeId: pgOperations.timestampNodeId
|
|
988
|
+
}).from(pgOperations).where(and(eq(pgOperations.collection, collection), eq(pgOperations.recordId, recordId))).orderBy(
|
|
989
|
+
desc(pgOperations.wallTime),
|
|
990
|
+
desc(pgOperations.logical),
|
|
991
|
+
desc(pgOperations.timestampNodeId)
|
|
992
|
+
).limit(1);
|
|
993
|
+
const row = rows[0];
|
|
994
|
+
if (!row) {
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
return { wallTime: row.wallTime, logical: row.logical, nodeId: row.timestampNodeId };
|
|
998
|
+
}
|
|
840
999
|
/**
|
|
841
1000
|
* Rebuild a single record in the materialized collection table by replaying
|
|
842
1001
|
* all operations for that record.
|
|
@@ -847,15 +1006,17 @@ var PostgresServerStore = class {
|
|
|
847
1006
|
const ops = await txOrDb.select({
|
|
848
1007
|
type: pgOperations.type,
|
|
849
1008
|
data: pgOperations.data,
|
|
1009
|
+
atomicOps: pgOperations.atomicOps,
|
|
850
1010
|
wallTime: pgOperations.wallTime
|
|
851
1011
|
}).from(pgOperations).where(and(eq(pgOperations.collection, collection), eq(pgOperations.recordId, recordId))).orderBy(
|
|
852
1012
|
asc(pgOperations.wallTime),
|
|
853
1013
|
asc(pgOperations.logical),
|
|
854
|
-
asc(pgOperations.
|
|
1014
|
+
asc(pgOperations.timestampNodeId)
|
|
855
1015
|
);
|
|
856
1016
|
const parsedOps = ops.map((op) => ({
|
|
857
1017
|
type: op.type,
|
|
858
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1018
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1019
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
859
1020
|
}));
|
|
860
1021
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
861
1022
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
@@ -924,11 +1085,12 @@ var PostgresServerStore = class {
|
|
|
924
1085
|
recordId: pgOperations.recordId,
|
|
925
1086
|
type: pgOperations.type,
|
|
926
1087
|
data: pgOperations.data,
|
|
1088
|
+
atomicOps: pgOperations.atomicOps,
|
|
927
1089
|
wallTime: pgOperations.wallTime
|
|
928
1090
|
}).from(pgOperations).where(eq(pgOperations.collection, collectionName)).orderBy(
|
|
929
1091
|
asc(pgOperations.wallTime),
|
|
930
1092
|
asc(pgOperations.logical),
|
|
931
|
-
asc(pgOperations.
|
|
1093
|
+
asc(pgOperations.timestampNodeId)
|
|
932
1094
|
);
|
|
933
1095
|
if (allOps.length === 0) return;
|
|
934
1096
|
const grouped = /* @__PURE__ */ new Map();
|
|
@@ -944,7 +1106,8 @@ var PostgresServerStore = class {
|
|
|
944
1106
|
for (const [recordId, recordOps] of grouped) {
|
|
945
1107
|
const parsedOps = recordOps.map((op) => ({
|
|
946
1108
|
type: op.type,
|
|
947
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1109
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1110
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
948
1111
|
}));
|
|
949
1112
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
950
1113
|
if (recordData) {
|
|
@@ -1069,44 +1232,92 @@ var PostgresServerStore = class {
|
|
|
1069
1232
|
}
|
|
1070
1233
|
}
|
|
1071
1234
|
async ensureTables() {
|
|
1072
|
-
await this.db.
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1235
|
+
await this.db.transaction(async (tx) => {
|
|
1236
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended('kora:ensure-tables', 0))`);
|
|
1237
|
+
await tx.execute(sql`
|
|
1238
|
+
CREATE TABLE IF NOT EXISTS operations (
|
|
1239
|
+
id TEXT PRIMARY KEY,
|
|
1240
|
+
node_id TEXT NOT NULL,
|
|
1241
|
+
type TEXT NOT NULL,
|
|
1242
|
+
collection TEXT NOT NULL,
|
|
1243
|
+
record_id TEXT NOT NULL,
|
|
1244
|
+
data TEXT,
|
|
1245
|
+
previous_data TEXT,
|
|
1246
|
+
atomic_ops TEXT,
|
|
1247
|
+
wall_time BIGINT NOT NULL,
|
|
1248
|
+
logical INTEGER NOT NULL,
|
|
1249
|
+
timestamp_node_id TEXT NOT NULL,
|
|
1250
|
+
sequence_number INTEGER NOT NULL,
|
|
1251
|
+
causal_deps TEXT NOT NULL DEFAULT '[]',
|
|
1252
|
+
schema_version INTEGER NOT NULL,
|
|
1253
|
+
received_at BIGINT NOT NULL
|
|
1254
|
+
)
|
|
1255
|
+
`);
|
|
1256
|
+
await tx.execute(sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS atomic_ops TEXT`);
|
|
1257
|
+
await tx.execute(sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS delivery_seq BIGINT`);
|
|
1258
|
+
await tx.execute(
|
|
1259
|
+
sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
|
|
1260
|
+
);
|
|
1261
|
+
await tx.execute(sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
|
|
1262
|
+
await tx.execute(sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
|
|
1263
|
+
await tx.execute(
|
|
1264
|
+
sql`CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)`
|
|
1265
|
+
);
|
|
1266
|
+
await tx.execute(
|
|
1267
|
+
sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
|
|
1268
|
+
);
|
|
1269
|
+
await tx.execute(sql`
|
|
1270
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
1271
|
+
node_id TEXT PRIMARY KEY,
|
|
1272
|
+
max_sequence_number INTEGER NOT NULL,
|
|
1273
|
+
last_seen_at BIGINT NOT NULL
|
|
1274
|
+
)
|
|
1275
|
+
`);
|
|
1276
|
+
await tx.execute(sql`
|
|
1277
|
+
CREATE TABLE IF NOT EXISTS delivery_counter (
|
|
1278
|
+
id INTEGER PRIMARY KEY,
|
|
1279
|
+
value BIGINT NOT NULL
|
|
1280
|
+
)
|
|
1281
|
+
`);
|
|
1282
|
+
await tx.execute(sql`
|
|
1283
|
+
WITH ordered AS (
|
|
1284
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
1285
|
+
ORDER BY received_at ASC, sequence_number ASC, id ASC
|
|
1286
|
+
) + COALESCE((SELECT MAX(delivery_seq) FROM operations), 0) AS rn
|
|
1287
|
+
FROM operations WHERE delivery_seq IS NULL
|
|
1288
|
+
)
|
|
1289
|
+
UPDATE operations o SET delivery_seq = ordered.rn
|
|
1290
|
+
FROM ordered WHERE o.id = ordered.id
|
|
1291
|
+
`);
|
|
1292
|
+
await tx.execute(sql`
|
|
1293
|
+
INSERT INTO delivery_counter (id, value)
|
|
1294
|
+
VALUES (1, COALESCE((SELECT MAX(delivery_seq) FROM operations), 0))
|
|
1295
|
+
ON CONFLICT (id) DO UPDATE
|
|
1296
|
+
SET value = GREATEST(delivery_counter.value, EXCLUDED.value)
|
|
1297
|
+
`);
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Reserve the next delivery sequence inside an open append transaction. The
|
|
1302
|
+
* `UPDATE ... RETURNING` locks the counter row until this transaction commits,
|
|
1303
|
+
* serializing assignment into commit order (see the counter table comment).
|
|
1304
|
+
*/
|
|
1305
|
+
async nextDeliverySeq(tx) {
|
|
1306
|
+
const rows = await tx.execute(
|
|
1307
|
+
sql`UPDATE delivery_counter SET value = value + 1 WHERE id = 1 RETURNING value`
|
|
1097
1308
|
);
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1309
|
+
const value = rows[0]?.value;
|
|
1310
|
+
if (value === void 0 || value === null) {
|
|
1311
|
+
throw new Error(
|
|
1312
|
+
"delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
return Number(value);
|
|
1105
1316
|
}
|
|
1106
1317
|
// ---------------------------------------------------------------------------
|
|
1107
1318
|
// Operation serialization
|
|
1108
1319
|
// ---------------------------------------------------------------------------
|
|
1109
|
-
serializeOperation(op, receivedAt) {
|
|
1320
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1110
1321
|
return {
|
|
1111
1322
|
id: op.id,
|
|
1112
1323
|
nodeId: op.nodeId,
|
|
@@ -1115,16 +1326,19 @@ var PostgresServerStore = class {
|
|
|
1115
1326
|
recordId: op.recordId,
|
|
1116
1327
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1117
1328
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
1329
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1118
1330
|
wallTime: op.timestamp.wallTime,
|
|
1119
1331
|
logical: op.timestamp.logical,
|
|
1120
1332
|
timestampNodeId: op.timestamp.nodeId,
|
|
1121
1333
|
sequenceNumber: op.sequenceNumber,
|
|
1122
1334
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1123
1335
|
schemaVersion: op.schemaVersion,
|
|
1124
|
-
receivedAt
|
|
1336
|
+
receivedAt,
|
|
1337
|
+
deliverySeq
|
|
1125
1338
|
};
|
|
1126
1339
|
}
|
|
1127
1340
|
deserializeOperation(row) {
|
|
1341
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1128
1342
|
return {
|
|
1129
1343
|
id: row.id,
|
|
1130
1344
|
nodeId: row.nodeId,
|
|
@@ -1140,7 +1354,8 @@ var PostgresServerStore = class {
|
|
|
1140
1354
|
},
|
|
1141
1355
|
sequenceNumber: row.sequenceNumber,
|
|
1142
1356
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1143
|
-
schemaVersion: row.schemaVersion
|
|
1357
|
+
schemaVersion: row.schemaVersion,
|
|
1358
|
+
...atomicOps ? { atomicOps } : {}
|
|
1144
1359
|
};
|
|
1145
1360
|
}
|
|
1146
1361
|
// ---------------------------------------------------------------------------
|
|
@@ -1192,7 +1407,7 @@ async function loadPostgresDeps() {
|
|
|
1192
1407
|
// src/store/sqlite-server-store.ts
|
|
1193
1408
|
import { createRequire } from "module";
|
|
1194
1409
|
import { generateUUIDv7 as generateUUIDv73, quoteIdent as quoteIdent3 } from "@korajs/core";
|
|
1195
|
-
import { and as and2, asc as asc2, between as between2, count as count2, eq as eq2, sql as sql2 } from "drizzle-orm";
|
|
1410
|
+
import { and as and2, asc as asc2, between as between2, count as count2, eq as eq2, gt as gt2, sql as sql2 } from "drizzle-orm";
|
|
1196
1411
|
|
|
1197
1412
|
// src/store/drizzle-schema.ts
|
|
1198
1413
|
import { index as index2, integer as integer2, sqliteTable, text as text2 } from "drizzle-orm/sqlite-core";
|
|
@@ -1208,6 +1423,8 @@ var operations = sqliteTable(
|
|
|
1208
1423
|
// JSON-serialized, null for deletes
|
|
1209
1424
|
previousData: text2("previous_data"),
|
|
1210
1425
|
// JSON-serialized, null for insert/delete
|
|
1426
|
+
atomicOps: text2("atomic_ops"),
|
|
1427
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
1211
1428
|
wallTime: integer2("wall_time").notNull(),
|
|
1212
1429
|
logical: integer2("logical").notNull(),
|
|
1213
1430
|
timestampNodeId: text2("timestamp_node_id").notNull(),
|
|
@@ -1215,12 +1432,17 @@ var operations = sqliteTable(
|
|
|
1215
1432
|
causalDeps: text2("causal_deps").notNull().default("[]"),
|
|
1216
1433
|
// JSON array of op IDs
|
|
1217
1434
|
schemaVersion: integer2("schema_version").notNull(),
|
|
1218
|
-
receivedAt: integer2("received_at").notNull()
|
|
1435
|
+
receivedAt: integer2("received_at").notNull(),
|
|
1436
|
+
// Server-assigned monotonic delivery sequence, ordered by commit. Drives the
|
|
1437
|
+
// gap-free server->client delivery watermark. Nullable only for rows written
|
|
1438
|
+
// before the column existed; those are backfilled on startup.
|
|
1439
|
+
deliverySeq: integer2("delivery_seq")
|
|
1219
1440
|
},
|
|
1220
1441
|
(table) => ({
|
|
1221
1442
|
nodeSeqIdx: index2("idx_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
1222
1443
|
collectionIdx: index2("idx_collection").on(table.collection),
|
|
1223
|
-
receivedIdx: index2("idx_received").on(table.receivedAt)
|
|
1444
|
+
receivedIdx: index2("idx_received").on(table.receivedAt),
|
|
1445
|
+
deliveryIdx: index2("idx_delivery_seq").on(table.deliverySeq)
|
|
1224
1446
|
})
|
|
1225
1447
|
);
|
|
1226
1448
|
var syncState = sqliteTable("sync_state", {
|
|
@@ -1236,6 +1458,12 @@ var SqliteServerStore = class {
|
|
|
1236
1458
|
db;
|
|
1237
1459
|
schema = null;
|
|
1238
1460
|
closed = false;
|
|
1461
|
+
/**
|
|
1462
|
+
* Next delivery sequence to assign. Single-writer (better-sqlite3 is synchronous),
|
|
1463
|
+
* so a plain in-memory counter, seeded from MAX(delivery_seq) at startup, gives
|
|
1464
|
+
* commit-order delivery sequence. Assigned inside the append transaction.
|
|
1465
|
+
*/
|
|
1466
|
+
deliverySeqCounter = 0;
|
|
1239
1467
|
constructor(db, nodeId) {
|
|
1240
1468
|
this.db = db;
|
|
1241
1469
|
this.nodeId = nodeId ?? generateUUIDv73();
|
|
@@ -1281,12 +1509,16 @@ var SqliteServerStore = class {
|
|
|
1281
1509
|
async applyRemoteOperation(op) {
|
|
1282
1510
|
this.assertOpen();
|
|
1283
1511
|
const now = Date.now();
|
|
1284
|
-
const row = this.serializeOperation(op, now);
|
|
1285
1512
|
const result = this.db.transaction((tx) => {
|
|
1286
|
-
const
|
|
1287
|
-
|
|
1513
|
+
const existing = tx.all(
|
|
1514
|
+
sql2`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
|
|
1515
|
+
);
|
|
1516
|
+
if (existing.length > 0) {
|
|
1288
1517
|
return "duplicate";
|
|
1289
1518
|
}
|
|
1519
|
+
this.deliverySeqCounter += 1;
|
|
1520
|
+
const row = this.serializeOperation(op, now, this.deliverySeqCounter);
|
|
1521
|
+
tx.insert(operations).values(row).run();
|
|
1290
1522
|
tx.insert(syncState).values({
|
|
1291
1523
|
nodeId: op.nodeId,
|
|
1292
1524
|
maxSequenceNumber: op.sequenceNumber,
|
|
@@ -1315,6 +1547,21 @@ var SqliteServerStore = class {
|
|
|
1315
1547
|
const result = this.db.select({ value: count2() }).from(operations).all();
|
|
1316
1548
|
return result[0]?.value ?? 0;
|
|
1317
1549
|
}
|
|
1550
|
+
async getMaxDeliverySequence() {
|
|
1551
|
+
this.assertOpen();
|
|
1552
|
+
const rows = this.db.all(
|
|
1553
|
+
sql2`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
1554
|
+
);
|
|
1555
|
+
return rows[0]?.m ?? 0;
|
|
1556
|
+
}
|
|
1557
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
1558
|
+
this.assertOpen();
|
|
1559
|
+
const rows = this.db.select().from(operations).where(gt2(operations.deliverySeq, afterDeliverySequence)).orderBy(asc2(operations.deliverySeq)).limit(limit).all();
|
|
1560
|
+
return rows.map((row) => ({
|
|
1561
|
+
operation: this.deserializeOperation(row),
|
|
1562
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
1563
|
+
}));
|
|
1564
|
+
}
|
|
1318
1565
|
async materializeCollection(collection) {
|
|
1319
1566
|
this.assertOpen();
|
|
1320
1567
|
if (this.schema?.collections[collection]) {
|
|
@@ -1372,7 +1619,7 @@ var SqliteServerStore = class {
|
|
|
1372
1619
|
async exportBackup() {
|
|
1373
1620
|
this.assertOpen();
|
|
1374
1621
|
const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
|
|
1375
|
-
const rows = this.db.select().from(operations).all();
|
|
1622
|
+
const rows = this.db.select().from(operations).orderBy(asc2(operations.deliverySeq)).all();
|
|
1376
1623
|
const deserialized = rows.map((row) => this.deserializeOperation(row));
|
|
1377
1624
|
const vv = this.getVersionVector();
|
|
1378
1625
|
return buildServerBackup(this.nodeId, deserialized, vv);
|
|
@@ -1395,10 +1642,13 @@ var SqliteServerStore = class {
|
|
|
1395
1642
|
for (const [nid, seq] of versionVector) {
|
|
1396
1643
|
tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
|
|
1397
1644
|
}
|
|
1645
|
+
let deliverySeq = 0;
|
|
1398
1646
|
for (const op of ops) {
|
|
1399
|
-
|
|
1647
|
+
deliverySeq += 1;
|
|
1648
|
+
const row = this.serializeOperation(op, Date.now(), deliverySeq);
|
|
1400
1649
|
tx.insert(operations).values(row).run();
|
|
1401
1650
|
}
|
|
1651
|
+
this.deliverySeqCounter = deliverySeq;
|
|
1402
1652
|
});
|
|
1403
1653
|
return { operationsRestored: ops.length, success: true };
|
|
1404
1654
|
}
|
|
@@ -1416,11 +1666,13 @@ var SqliteServerStore = class {
|
|
|
1416
1666
|
const ops = txOrDb.select({
|
|
1417
1667
|
type: operations.type,
|
|
1418
1668
|
data: operations.data,
|
|
1669
|
+
atomicOps: operations.atomicOps,
|
|
1419
1670
|
wallTime: operations.wallTime
|
|
1420
|
-
}).from(operations).where(and2(eq2(operations.collection, collection), eq2(operations.recordId, recordId))).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.
|
|
1671
|
+
}).from(operations).where(and2(eq2(operations.collection, collection), eq2(operations.recordId, recordId))).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.timestampNodeId)).all();
|
|
1421
1672
|
const parsedOps = ops.map((op) => ({
|
|
1422
1673
|
type: op.type,
|
|
1423
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1674
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1675
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1424
1676
|
}));
|
|
1425
1677
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
1426
1678
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
@@ -1492,8 +1744,9 @@ var SqliteServerStore = class {
|
|
|
1492
1744
|
recordId: operations.recordId,
|
|
1493
1745
|
type: operations.type,
|
|
1494
1746
|
data: operations.data,
|
|
1747
|
+
atomicOps: operations.atomicOps,
|
|
1495
1748
|
wallTime: operations.wallTime
|
|
1496
|
-
}).from(operations).where(eq2(operations.collection, collectionName)).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.
|
|
1749
|
+
}).from(operations).where(eq2(operations.collection, collectionName)).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.timestampNodeId)).all();
|
|
1497
1750
|
if (allOps.length === 0) return;
|
|
1498
1751
|
const grouped = /* @__PURE__ */ new Map();
|
|
1499
1752
|
for (const op of allOps) {
|
|
@@ -1509,7 +1762,8 @@ var SqliteServerStore = class {
|
|
|
1509
1762
|
for (const [recordId, recordOps] of grouped) {
|
|
1510
1763
|
const parsedOps = recordOps.map((op) => ({
|
|
1511
1764
|
type: op.type,
|
|
1512
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1765
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1766
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1513
1767
|
}));
|
|
1514
1768
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
1515
1769
|
if (recordData) {
|
|
@@ -1633,6 +1887,7 @@ var SqliteServerStore = class {
|
|
|
1633
1887
|
record_id TEXT NOT NULL,
|
|
1634
1888
|
data TEXT,
|
|
1635
1889
|
previous_data TEXT,
|
|
1890
|
+
atomic_ops TEXT,
|
|
1636
1891
|
wall_time INTEGER NOT NULL,
|
|
1637
1892
|
logical INTEGER NOT NULL,
|
|
1638
1893
|
timestamp_node_id TEXT NOT NULL,
|
|
@@ -1642,9 +1897,30 @@ var SqliteServerStore = class {
|
|
|
1642
1897
|
received_at INTEGER NOT NULL
|
|
1643
1898
|
)
|
|
1644
1899
|
`);
|
|
1900
|
+
try {
|
|
1901
|
+
this.db.run(sql2`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
|
|
1902
|
+
} catch (e) {
|
|
1903
|
+
const msg = e instanceof Error ? e.message : "";
|
|
1904
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
1905
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
1906
|
+
throw e;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
try {
|
|
1910
|
+
this.db.run(sql2`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
|
|
1911
|
+
} catch (e) {
|
|
1912
|
+
const msg = e instanceof Error ? e.message : "";
|
|
1913
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
1914
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
1915
|
+
throw e;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1645
1918
|
this.db.run(sql2`
|
|
1646
1919
|
CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
|
|
1647
1920
|
`);
|
|
1921
|
+
this.db.run(sql2`
|
|
1922
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
|
|
1923
|
+
`);
|
|
1648
1924
|
this.db.run(sql2`
|
|
1649
1925
|
CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
|
|
1650
1926
|
`);
|
|
@@ -1661,11 +1937,34 @@ var SqliteServerStore = class {
|
|
|
1661
1937
|
last_seen_at INTEGER NOT NULL
|
|
1662
1938
|
)
|
|
1663
1939
|
`);
|
|
1940
|
+
this.backfillDeliverySequence();
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* Assign delivery sequences to any operations written before the column existed,
|
|
1944
|
+
* then seed the in-memory counter from the current max. Ordered by
|
|
1945
|
+
* (received_at, sequence_number, id) so the backfill is deterministic and stable
|
|
1946
|
+
* across restarts. A one-time migration: after it runs, all rows have a value.
|
|
1947
|
+
*/
|
|
1948
|
+
backfillDeliverySequence() {
|
|
1949
|
+
this.db.transaction((tx) => {
|
|
1950
|
+
const maxRow = tx.all(
|
|
1951
|
+
sql2`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
1952
|
+
);
|
|
1953
|
+
let next = maxRow[0]?.m ?? 0;
|
|
1954
|
+
const nullRows = tx.all(
|
|
1955
|
+
sql2`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
|
|
1956
|
+
);
|
|
1957
|
+
for (const row of nullRows) {
|
|
1958
|
+
next += 1;
|
|
1959
|
+
tx.run(sql2`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
|
|
1960
|
+
}
|
|
1961
|
+
this.deliverySeqCounter = next;
|
|
1962
|
+
});
|
|
1664
1963
|
}
|
|
1665
1964
|
// ---------------------------------------------------------------------------
|
|
1666
1965
|
// Operation serialization
|
|
1667
1966
|
// ---------------------------------------------------------------------------
|
|
1668
|
-
serializeOperation(op, receivedAt) {
|
|
1967
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1669
1968
|
return {
|
|
1670
1969
|
id: op.id,
|
|
1671
1970
|
nodeId: op.nodeId,
|
|
@@ -1674,16 +1973,19 @@ var SqliteServerStore = class {
|
|
|
1674
1973
|
recordId: op.recordId,
|
|
1675
1974
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1676
1975
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
1976
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1677
1977
|
wallTime: op.timestamp.wallTime,
|
|
1678
1978
|
logical: op.timestamp.logical,
|
|
1679
1979
|
timestampNodeId: op.timestamp.nodeId,
|
|
1680
1980
|
sequenceNumber: op.sequenceNumber,
|
|
1681
1981
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1682
1982
|
schemaVersion: op.schemaVersion,
|
|
1683
|
-
receivedAt
|
|
1983
|
+
receivedAt,
|
|
1984
|
+
deliverySeq
|
|
1684
1985
|
};
|
|
1685
1986
|
}
|
|
1686
1987
|
deserializeOperation(row) {
|
|
1988
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1687
1989
|
return {
|
|
1688
1990
|
id: row.id,
|
|
1689
1991
|
nodeId: row.nodeId,
|
|
@@ -1699,7 +2001,8 @@ var SqliteServerStore = class {
|
|
|
1699
2001
|
},
|
|
1700
2002
|
sequenceNumber: row.sequenceNumber,
|
|
1701
2003
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1702
|
-
schemaVersion: row.schemaVersion
|
|
2004
|
+
schemaVersion: row.schemaVersion,
|
|
2005
|
+
...atomicOps ? { atomicOps } : {}
|
|
1703
2006
|
};
|
|
1704
2007
|
}
|
|
1705
2008
|
// ---------------------------------------------------------------------------
|
|
@@ -1981,10 +2284,10 @@ function createServerReferentialContext(store) {
|
|
|
1981
2284
|
}
|
|
1982
2285
|
|
|
1983
2286
|
// src/apply/server-side-effect-operation.ts
|
|
1984
|
-
import { HybridLogicalClock, createOperation } from "@korajs/core";
|
|
2287
|
+
import { HybridLogicalClock as HybridLogicalClock2, createOperation } from "@korajs/core";
|
|
1985
2288
|
async function createServerSideEffectOperation(store, parentOp, effect, schemaVersion, sequenceNumber) {
|
|
1986
2289
|
const nodeId = store.getNodeId();
|
|
1987
|
-
const clock = new
|
|
2290
|
+
const clock = new HybridLogicalClock2(nodeId);
|
|
1988
2291
|
clock.receive(parentOp.timestamp);
|
|
1989
2292
|
return createOperation(
|
|
1990
2293
|
{
|
|
@@ -2002,6 +2305,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
|
|
|
2002
2305
|
);
|
|
2003
2306
|
}
|
|
2004
2307
|
function nextServerSequenceNumber(store) {
|
|
2308
|
+
if (store.reserveSequenceNumber) {
|
|
2309
|
+
return store.reserveSequenceNumber();
|
|
2310
|
+
}
|
|
2005
2311
|
const nodeId = store.getNodeId();
|
|
2006
2312
|
const current = store.getVersionVector().get(nodeId) ?? 0;
|
|
2007
2313
|
return current + 1;
|
|
@@ -2043,16 +2349,14 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2043
2349
|
return { result: primaryResult, appliedOperations: [] };
|
|
2044
2350
|
}
|
|
2045
2351
|
const appliedOperations = [op];
|
|
2046
|
-
let serverSeq = nextServerSequenceNumber(store);
|
|
2047
2352
|
for (const effect of referential.sideEffectOps) {
|
|
2048
2353
|
const sideOp = await createServerSideEffectOperation(
|
|
2049
2354
|
store,
|
|
2050
2355
|
op,
|
|
2051
2356
|
effect,
|
|
2052
2357
|
op.schemaVersion,
|
|
2053
|
-
|
|
2358
|
+
nextServerSequenceNumber(store)
|
|
2054
2359
|
);
|
|
2055
|
-
serverSeq += 1;
|
|
2056
2360
|
const sideResult = await store.applyRemoteOperation(sideOp);
|
|
2057
2361
|
if (sideResult === "applied") {
|
|
2058
2362
|
appliedOperations.push(sideOp);
|
|
@@ -2110,11 +2414,11 @@ function mergeScopeMaps(base, overlay, source) {
|
|
|
2110
2414
|
}
|
|
2111
2415
|
|
|
2112
2416
|
// src/scopes/server-scope-filter.ts
|
|
2113
|
-
function operationMatchesScopes(op, scopes) {
|
|
2417
|
+
function operationMatchesScopes(op, scopes, fullRecord) {
|
|
2114
2418
|
if (!scopes) return true;
|
|
2115
2419
|
const collectionScope = scopes[op.collection];
|
|
2116
2420
|
if (!collectionScope) return false;
|
|
2117
|
-
const snapshot = buildSnapshot(op);
|
|
2421
|
+
const snapshot = buildSnapshot(op, fullRecord);
|
|
2118
2422
|
if (!snapshot) return false;
|
|
2119
2423
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
2120
2424
|
if (snapshot[field] !== expected) {
|
|
@@ -2123,11 +2427,19 @@ function operationMatchesScopes(op, scopes) {
|
|
|
2123
2427
|
}
|
|
2124
2428
|
return true;
|
|
2125
2429
|
}
|
|
2126
|
-
function
|
|
2430
|
+
function missingScopeFields(op, scopes) {
|
|
2431
|
+
if (!scopes) return [];
|
|
2432
|
+
const collectionScope = scopes[op.collection];
|
|
2433
|
+
if (!collectionScope) return [];
|
|
2434
|
+
const snapshot = buildSnapshot(op);
|
|
2435
|
+
return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
|
|
2436
|
+
}
|
|
2437
|
+
function buildSnapshot(op, fullRecord) {
|
|
2127
2438
|
const previous = asRecord(op.previousData);
|
|
2128
2439
|
const next = asRecord(op.data);
|
|
2129
|
-
if (!previous && !next) return null;
|
|
2440
|
+
if (!previous && !next && !fullRecord) return null;
|
|
2130
2441
|
return {
|
|
2442
|
+
...fullRecord ?? {},
|
|
2131
2443
|
...previous ?? {},
|
|
2132
2444
|
...next ?? {}
|
|
2133
2445
|
};
|
|
@@ -2218,12 +2530,41 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
|
|
|
2218
2530
|
"[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.)"
|
|
2219
2531
|
);
|
|
2220
2532
|
}
|
|
2221
|
-
var ClientSession = class {
|
|
2533
|
+
var ClientSession = class _ClientSession {
|
|
2222
2534
|
state = "connected";
|
|
2223
2535
|
clientNodeId = null;
|
|
2224
2536
|
authContext = null;
|
|
2225
2537
|
syncQuerySubsets = [];
|
|
2226
2538
|
resumeDeltaCursor = null;
|
|
2539
|
+
/**
|
|
2540
|
+
* The delivery watermark the client reported at handshake, or null when the client
|
|
2541
|
+
* does not use the delivery watermark (old client, or no watermark yet). When set,
|
|
2542
|
+
* the server drives the gap-free server->client stream from delivery sequences
|
|
2543
|
+
* instead of the version-vector delta.
|
|
2544
|
+
*/
|
|
2545
|
+
clientDeliveryWatermark = null;
|
|
2546
|
+
/**
|
|
2547
|
+
* The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
|
|
2548
|
+
* watermark, as reported in acks). Incremental streaming pushes resume from here, not
|
|
2549
|
+
* from the highest sequence sent, so a dropped or unapplied batch is always
|
|
2550
|
+
* re-included by the next push (which re-scans from the acknowledged position). This
|
|
2551
|
+
* is what makes streaming recovery independent of any bounded retransmit buffer: there
|
|
2552
|
+
* is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
|
|
2553
|
+
* client's reported watermark at handshake.
|
|
2554
|
+
*/
|
|
2555
|
+
lastAckedDeliverySeq = 0;
|
|
2556
|
+
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
2557
|
+
deliveryPushChain = Promise.resolve();
|
|
2558
|
+
/**
|
|
2559
|
+
* Relay batches sent to this client but not yet acknowledged, keyed by messageId.
|
|
2560
|
+
* The client acks every operation-batch it applies; on ack the entry is cleared.
|
|
2561
|
+
* {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
|
|
2562
|
+
* dropped by a lossy transport is redelivered instead of leaving the client with a
|
|
2563
|
+
* permanent version-vector gap (a lost operation) that delta sync cannot recover.
|
|
2564
|
+
* Bounded so a silent client cannot grow it without limit.
|
|
2565
|
+
*/
|
|
2566
|
+
pendingRelays = /* @__PURE__ */ new Map();
|
|
2567
|
+
static MAX_PENDING_RELAYS = 1e3;
|
|
2227
2568
|
sessionId;
|
|
2228
2569
|
transport;
|
|
2229
2570
|
store;
|
|
@@ -2240,6 +2581,8 @@ var ClientSession = class {
|
|
|
2240
2581
|
onBlobChunkResponse;
|
|
2241
2582
|
persistBlobChunk;
|
|
2242
2583
|
onClose;
|
|
2584
|
+
onOrphanedRelays;
|
|
2585
|
+
takeOrphanedRelays;
|
|
2243
2586
|
maxOperationBytes;
|
|
2244
2587
|
maxOpsPerMinute;
|
|
2245
2588
|
rateLimiter;
|
|
@@ -2266,6 +2609,8 @@ var ClientSession = class {
|
|
|
2266
2609
|
this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
|
|
2267
2610
|
this.persistBlobChunk = options.persistBlobChunk ?? null;
|
|
2268
2611
|
this.onClose = options.onClose ?? null;
|
|
2612
|
+
this.onOrphanedRelays = options.onOrphanedRelays ?? null;
|
|
2613
|
+
this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
|
|
2269
2614
|
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2270
2615
|
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
2271
2616
|
this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
|
|
@@ -2290,9 +2635,40 @@ var ClientSession = class {
|
|
|
2290
2635
|
*/
|
|
2291
2636
|
relayOperations(operations2) {
|
|
2292
2637
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2638
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2639
|
+
this.pushDeliveryStream();
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2293
2642
|
if (operations2.length === 0) return;
|
|
2294
|
-
|
|
2643
|
+
void this.relayVisibleOperations(operations2);
|
|
2644
|
+
}
|
|
2645
|
+
/**
|
|
2646
|
+
* Push newly-available in-scope operations to a delivery-watermark client, resuming
|
|
2647
|
+
* from the highest delivery sequence already sent. Pushes are serialized so their
|
|
2648
|
+
* batches never interleave (which would break the base/max chain).
|
|
2649
|
+
*/
|
|
2650
|
+
pushDeliveryStream() {
|
|
2651
|
+
this.deliveryPushChain = this.deliveryPushChain.then(async () => {
|
|
2652
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2653
|
+
try {
|
|
2654
|
+
await this.sendDeliveryStream(
|
|
2655
|
+
this.lastAckedDeliverySeq,
|
|
2656
|
+
false,
|
|
2657
|
+
this.clientNodeId ?? void 0
|
|
2658
|
+
);
|
|
2659
|
+
} catch {
|
|
2660
|
+
}
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
async relayVisibleOperations(operations2) {
|
|
2664
|
+
const visibleOperations = [];
|
|
2665
|
+
for (const op of operations2) {
|
|
2666
|
+
if (await this.operationVisibleToClient(op)) {
|
|
2667
|
+
visibleOperations.push(op);
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2295
2670
|
if (visibleOperations.length === 0) return;
|
|
2671
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2296
2672
|
const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
|
|
2297
2673
|
const msg = {
|
|
2298
2674
|
type: "operation-batch",
|
|
@@ -2301,14 +2677,62 @@ var ClientSession = class {
|
|
|
2301
2677
|
isFinal: true,
|
|
2302
2678
|
batchIndex: 0
|
|
2303
2679
|
};
|
|
2680
|
+
this.trackPendingRelay(msg, visibleOperations);
|
|
2304
2681
|
this.sendToClient(msg);
|
|
2305
2682
|
}
|
|
2683
|
+
/** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
|
|
2684
|
+
trackPendingRelay(msg, ops) {
|
|
2685
|
+
if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
|
|
2686
|
+
const oldest = this.pendingRelays.keys().next().value;
|
|
2687
|
+
if (oldest !== void 0) {
|
|
2688
|
+
this.pendingRelays.delete(oldest);
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
|
|
2692
|
+
}
|
|
2693
|
+
/**
|
|
2694
|
+
* Retransmit relay batches this client has not acknowledged within `staleMs`.
|
|
2695
|
+
* Called on a periodic tick by the server (and directly by tests). Redelivering an
|
|
2696
|
+
* already-applied op is harmless: the client dedups by content-addressed id.
|
|
2697
|
+
*/
|
|
2698
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
2699
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2700
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2701
|
+
this.pushDeliveryStream();
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
if (this.pendingRelays.size === 0) return;
|
|
2705
|
+
const cutoff = Date.now() - staleMs;
|
|
2706
|
+
for (const { message, sentAtMs } of this.pendingRelays.values()) {
|
|
2707
|
+
if (sentAtMs <= cutoff) {
|
|
2708
|
+
this.sendToClient(message);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2306
2712
|
/**
|
|
2307
2713
|
* Close this session.
|
|
2308
2714
|
*/
|
|
2715
|
+
/**
|
|
2716
|
+
* Hand any unacknowledged relays to the server so they survive this session and are
|
|
2717
|
+
* redelivered when this client reconnects (per-node buffer), then clear them. Called
|
|
2718
|
+
* from every session-teardown path (explicit close and transport close).
|
|
2719
|
+
*/
|
|
2720
|
+
flushOrphanedRelays() {
|
|
2721
|
+
if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
|
|
2722
|
+
const ops = [];
|
|
2723
|
+
for (const entry of this.pendingRelays.values()) {
|
|
2724
|
+
ops.push(...entry.ops);
|
|
2725
|
+
}
|
|
2726
|
+
if (ops.length > 0) {
|
|
2727
|
+
this.onOrphanedRelays(this.clientNodeId, ops);
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
this.pendingRelays.clear();
|
|
2731
|
+
}
|
|
2309
2732
|
close(reason) {
|
|
2310
2733
|
if (this.state === "closed") return;
|
|
2311
2734
|
this.state = "closed";
|
|
2735
|
+
this.flushOrphanedRelays();
|
|
2312
2736
|
if (this.transport.isConnected()) {
|
|
2313
2737
|
this.transport.close(1e3, reason ?? "session closed");
|
|
2314
2738
|
}
|
|
@@ -2363,6 +2787,10 @@ var ClientSession = class {
|
|
|
2363
2787
|
await this.handleOperationBatch(message);
|
|
2364
2788
|
break;
|
|
2365
2789
|
case "acknowledgment":
|
|
2790
|
+
this.pendingRelays.delete(message.acknowledgedMessageId);
|
|
2791
|
+
if (message.deliverySequence !== void 0) {
|
|
2792
|
+
this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
|
|
2793
|
+
}
|
|
2366
2794
|
break;
|
|
2367
2795
|
case "error":
|
|
2368
2796
|
break;
|
|
@@ -2439,6 +2867,15 @@ var ClientSession = class {
|
|
|
2439
2867
|
this.syncQuerySubsets = [];
|
|
2440
2868
|
}
|
|
2441
2869
|
this.resumeDeltaCursor = msg.deltaCursor ? decodeDeltaCursor(msg.deltaCursor) : null;
|
|
2870
|
+
this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
|
|
2871
|
+
const clientUsesWatermark = this.clientDeliveryWatermark !== null;
|
|
2872
|
+
let serverMaxDelivery = 0;
|
|
2873
|
+
if (clientUsesWatermark) {
|
|
2874
|
+
serverMaxDelivery = await this.store.getMaxDeliverySequence();
|
|
2875
|
+
if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
|
|
2876
|
+
this.clientDeliveryWatermark = 0;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2442
2879
|
const serverVector = this.store.getVersionVector();
|
|
2443
2880
|
const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
|
|
2444
2881
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
@@ -2469,6 +2906,10 @@ var ClientSession = class {
|
|
|
2469
2906
|
accepted: true,
|
|
2470
2907
|
selectedWireFormat,
|
|
2471
2908
|
serverTime: Date.now(),
|
|
2909
|
+
// The server's highest delivery sequence, so a client whose persisted watermark
|
|
2910
|
+
// is ahead of it (server rolled back) can reset to a full resync. Sent only to
|
|
2911
|
+
// watermark-using clients.
|
|
2912
|
+
...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
|
|
2472
2913
|
// Advertise central blob storage so the client uploads the bytes behind
|
|
2473
2914
|
// its blob fields, keeping them available after the author goes offline.
|
|
2474
2915
|
...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
|
|
@@ -2479,16 +2920,28 @@ var ClientSession = class {
|
|
|
2479
2920
|
this.sendToClient(response);
|
|
2480
2921
|
this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
|
|
2481
2922
|
this.state = "syncing";
|
|
2482
|
-
|
|
2483
|
-
|
|
2923
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2924
|
+
this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
|
|
2925
|
+
const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
|
|
2926
|
+
await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
|
|
2927
|
+
} else {
|
|
2928
|
+
const clientVector = wireToVersionVector(msg.versionVector);
|
|
2929
|
+
await this.sendDelta(clientVector);
|
|
2930
|
+
}
|
|
2484
2931
|
this.state = "streaming";
|
|
2932
|
+
if (this.takeOrphanedRelays && this.clientNodeId) {
|
|
2933
|
+
const buffered = this.takeOrphanedRelays(this.clientNodeId);
|
|
2934
|
+
if (buffered.length > 0) {
|
|
2935
|
+
this.relayOperations(buffered);
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2485
2938
|
}
|
|
2486
2939
|
async handleOperationBatch(msg) {
|
|
2487
2940
|
const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2488
2941
|
const applied = [];
|
|
2489
2942
|
const rejected = [];
|
|
2490
2943
|
for (const op of operations2) {
|
|
2491
|
-
if (!this.operationVisibleToClient(op)) {
|
|
2944
|
+
if (!await this.operationVisibleToClient(op)) {
|
|
2492
2945
|
rejected.push(op);
|
|
2493
2946
|
continue;
|
|
2494
2947
|
}
|
|
@@ -2593,8 +3046,11 @@ var ClientSession = class {
|
|
|
2593
3046
|
const clientSeq = clientVector.get(nodeId) ?? 0;
|
|
2594
3047
|
if (serverSeq > clientSeq) {
|
|
2595
3048
|
const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
|
|
2596
|
-
const
|
|
2597
|
-
|
|
3049
|
+
for (const op of ops) {
|
|
3050
|
+
if (await this.operationVisibleToClient(op)) {
|
|
3051
|
+
missing.push(op);
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
2598
3054
|
}
|
|
2599
3055
|
}
|
|
2600
3056
|
if (missing.length === 0) {
|
|
@@ -2646,11 +3102,111 @@ var ClientSession = class {
|
|
|
2646
3102
|
});
|
|
2647
3103
|
}
|
|
2648
3104
|
}
|
|
2649
|
-
|
|
2650
|
-
|
|
3105
|
+
/**
|
|
3106
|
+
* Send the gap-free server->client delivery stream, resuming just after
|
|
3107
|
+
* `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
|
|
3108
|
+
* (commit order), scope-filtered for this session, and sent in batches that chain:
|
|
3109
|
+
* each batch's `baseDeliverySequence` equals the previous batch's
|
|
3110
|
+
* `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
|
|
3111
|
+
* applies a batch only when its watermark equals the base and advances the
|
|
3112
|
+
* watermark to the max, so a dropped batch stalls the watermark and is recovered by
|
|
3113
|
+
* the next handshake resend. Because delivery-sequence order respects causal order
|
|
3114
|
+
* (a dependency is always committed, and thus sequenced, before its dependent), no
|
|
3115
|
+
* topological sort is needed and the client never defers on a missing dependency
|
|
3116
|
+
* that is itself in this stream.
|
|
3117
|
+
*
|
|
3118
|
+
* The final batch advances the watermark to the highest delivery sequence scanned,
|
|
3119
|
+
* not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
|
|
3120
|
+
* next reconnect. Returns the number of operations actually sent.
|
|
3121
|
+
*/
|
|
3122
|
+
async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
|
|
3123
|
+
const scanChunk = Math.max(this.batchSize, 1) * 5;
|
|
3124
|
+
let scanCursor = fromDeliverySeq;
|
|
3125
|
+
let maxScanned = fromDeliverySeq;
|
|
3126
|
+
const visible = [];
|
|
3127
|
+
while (true) {
|
|
3128
|
+
const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
|
|
3129
|
+
const last = chunk[chunk.length - 1];
|
|
3130
|
+
if (last === void 0) break;
|
|
3131
|
+
scanCursor = last.deliverySequence;
|
|
3132
|
+
maxScanned = scanCursor;
|
|
3133
|
+
for (const delivered of chunk) {
|
|
3134
|
+
if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
|
|
3135
|
+
continue;
|
|
3136
|
+
}
|
|
3137
|
+
if (await this.operationVisibleToClient(delivered.operation)) {
|
|
3138
|
+
visible.push(delivered);
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
if (chunk.length < scanChunk) break;
|
|
3142
|
+
}
|
|
3143
|
+
if (visible.length === 0) {
|
|
3144
|
+
if (finalizeWhenEmpty) {
|
|
3145
|
+
this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
|
|
3146
|
+
}
|
|
3147
|
+
return 0;
|
|
3148
|
+
}
|
|
3149
|
+
const totalBatches = Math.ceil(visible.length / this.batchSize);
|
|
3150
|
+
let base = fromDeliverySeq;
|
|
3151
|
+
for (let i = 0; i < totalBatches; i++) {
|
|
3152
|
+
const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
|
|
3153
|
+
const lastInSlice = slice[slice.length - 1];
|
|
3154
|
+
if (lastInSlice === void 0) continue;
|
|
3155
|
+
const isFinal = i === totalBatches - 1;
|
|
3156
|
+
const lastSeq = lastInSlice.deliverySequence;
|
|
3157
|
+
const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
|
|
3158
|
+
this.sendDeliveryBatch(slice, base, max, i, isFinal);
|
|
3159
|
+
base = max;
|
|
3160
|
+
}
|
|
3161
|
+
return visible.length;
|
|
3162
|
+
}
|
|
3163
|
+
/** Build, track, and send one chained delivery-stream batch. */
|
|
3164
|
+
sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
|
|
3165
|
+
const batchMsg = {
|
|
3166
|
+
type: "operation-batch",
|
|
3167
|
+
messageId: generateUUIDv74(),
|
|
3168
|
+
operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
|
|
3169
|
+
isFinal,
|
|
3170
|
+
batchIndex,
|
|
3171
|
+
baseDeliverySequence: base,
|
|
3172
|
+
maxDeliverySequence: max
|
|
3173
|
+
};
|
|
3174
|
+
this.sendToClient(batchMsg);
|
|
3175
|
+
if (slice.length > 0) {
|
|
3176
|
+
this.emitter?.emit({
|
|
3177
|
+
type: "sync:sent",
|
|
3178
|
+
operations: slice.map((delivered) => delivered.operation),
|
|
3179
|
+
batchSize: slice.length
|
|
3180
|
+
});
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
async operationVisibleToClient(op) {
|
|
3184
|
+
const scopes = this.authContext?.scopes;
|
|
3185
|
+
const subsets = this.syncQuerySubsets;
|
|
3186
|
+
const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
|
|
3187
|
+
const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
|
|
3188
|
+
if (!operationMatchesScopes(op, scopes, fullRecord)) {
|
|
2651
3189
|
return false;
|
|
2652
3190
|
}
|
|
2653
|
-
return operationMatchesQuerySubsets(op,
|
|
3191
|
+
return operationMatchesQuerySubsets(op, subsets, fullRecord);
|
|
3192
|
+
}
|
|
3193
|
+
/**
|
|
3194
|
+
* Read a record's current field values from the materialized store for scope /
|
|
3195
|
+
* query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
|
|
3196
|
+
* carries no fields) is still judged against the record's actual scope. Returns
|
|
3197
|
+
* undefined when the record cannot be read (never materialized, or no schema).
|
|
3198
|
+
*/
|
|
3199
|
+
async lookupRecordFields(collection, recordId) {
|
|
3200
|
+
try {
|
|
3201
|
+
const rows = await this.store.queryCollection(collection, {
|
|
3202
|
+
where: { id: recordId },
|
|
3203
|
+
includeDeleted: true,
|
|
3204
|
+
limit: 1
|
|
3205
|
+
});
|
|
3206
|
+
return rows[0];
|
|
3207
|
+
} catch {
|
|
3208
|
+
return void 0;
|
|
3209
|
+
}
|
|
2654
3210
|
}
|
|
2655
3211
|
handleAwarenessUpdate(msg) {
|
|
2656
3212
|
this.onAwarenessUpdate?.(this.sessionId, msg);
|
|
@@ -2695,6 +3251,7 @@ var ClientSession = class {
|
|
|
2695
3251
|
handleTransportClose() {
|
|
2696
3252
|
if (this.state === "closed") return;
|
|
2697
3253
|
this.state = "closed";
|
|
3254
|
+
this.flushOrphanedRelays();
|
|
2698
3255
|
this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
|
|
2699
3256
|
this.onClose?.(this.sessionId);
|
|
2700
3257
|
}
|
|
@@ -3068,7 +3625,64 @@ var YjsDocRelay = class {
|
|
|
3068
3625
|
};
|
|
3069
3626
|
|
|
3070
3627
|
// src/server/route-context.ts
|
|
3071
|
-
import {
|
|
3628
|
+
import {
|
|
3629
|
+
HybridLogicalClock as HybridLogicalClock3,
|
|
3630
|
+
createOperation as createOperation2,
|
|
3631
|
+
generateUUIDv7 as generateUUIDv76,
|
|
3632
|
+
isAtomicOp,
|
|
3633
|
+
resolveAtomicOp,
|
|
3634
|
+
toAtomicOp
|
|
3635
|
+
} from "@korajs/core";
|
|
3636
|
+
|
|
3637
|
+
// src/apply/route-predicate.ts
|
|
3638
|
+
function evaluateField(actual, operators) {
|
|
3639
|
+
if ("$eq" in operators && !Object.is(actual, operators.$eq)) {
|
|
3640
|
+
return false;
|
|
3641
|
+
}
|
|
3642
|
+
if ("$ne" in operators && Object.is(actual, operators.$ne)) {
|
|
3643
|
+
return false;
|
|
3644
|
+
}
|
|
3645
|
+
if (operators.$in !== void 0 && !operators.$in.some((candidate) => Object.is(actual, candidate))) {
|
|
3646
|
+
return false;
|
|
3647
|
+
}
|
|
3648
|
+
const numericChecks = [
|
|
3649
|
+
["$lt", (a, b) => a < b],
|
|
3650
|
+
["$lte", (a, b) => a <= b],
|
|
3651
|
+
["$gt", (a, b) => a > b],
|
|
3652
|
+
["$gte", (a, b) => a >= b]
|
|
3653
|
+
];
|
|
3654
|
+
for (const [operator, compare] of numericChecks) {
|
|
3655
|
+
const bound = operators[operator];
|
|
3656
|
+
if (bound === void 0) {
|
|
3657
|
+
continue;
|
|
3658
|
+
}
|
|
3659
|
+
if (typeof actual !== "number" || typeof bound !== "number") {
|
|
3660
|
+
return false;
|
|
3661
|
+
}
|
|
3662
|
+
if (!compare(actual, bound)) {
|
|
3663
|
+
return false;
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
return true;
|
|
3667
|
+
}
|
|
3668
|
+
function evaluateRoutePredicate(record, predicate) {
|
|
3669
|
+
const fields = Object.keys(predicate);
|
|
3670
|
+
if (fields.length === 0) {
|
|
3671
|
+
return true;
|
|
3672
|
+
}
|
|
3673
|
+
if (record === null) {
|
|
3674
|
+
return false;
|
|
3675
|
+
}
|
|
3676
|
+
for (const field of fields) {
|
|
3677
|
+
const operators = predicate[field];
|
|
3678
|
+
if (operators && !evaluateField(record[field], operators)) {
|
|
3679
|
+
return false;
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
return true;
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
// src/server/route-context.ts
|
|
3072
3686
|
function recordMatchesScope(collection, record, scope) {
|
|
3073
3687
|
const collectionScope = scope[collection];
|
|
3074
3688
|
if (!collectionScope) {
|
|
@@ -3090,9 +3704,9 @@ function pickFields(record, keys) {
|
|
|
3090
3704
|
}
|
|
3091
3705
|
return picked;
|
|
3092
3706
|
}
|
|
3093
|
-
async function buildRouteOperation(store, mutation) {
|
|
3707
|
+
async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
|
|
3094
3708
|
const nodeId = store.getNodeId();
|
|
3095
|
-
const clock = new
|
|
3709
|
+
const clock = clockOverride ?? new HybridLogicalClock3(nodeId);
|
|
3096
3710
|
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
3097
3711
|
const sequenceNumber = nextServerSequenceNumber(store);
|
|
3098
3712
|
if (mutation.type === "insert") {
|
|
@@ -3117,9 +3731,20 @@ async function buildRouteOperation(store, mutation) {
|
|
|
3117
3731
|
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
3118
3732
|
);
|
|
3119
3733
|
}
|
|
3120
|
-
const current = await store.findRecord(mutation.collection, mutation.recordId);
|
|
3734
|
+
const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
|
|
3121
3735
|
if (mutation.type === "update") {
|
|
3122
|
-
const
|
|
3736
|
+
const rawData = mutation.data ?? {};
|
|
3737
|
+
const data = {};
|
|
3738
|
+
const atomicOps = {};
|
|
3739
|
+
for (const key of Object.keys(rawData)) {
|
|
3740
|
+
const value = rawData[key];
|
|
3741
|
+
if (isAtomicOp(value)) {
|
|
3742
|
+
data[key] = resolveAtomicOp(current ? current[key] : void 0, value);
|
|
3743
|
+
atomicOps[key] = toAtomicOp(value);
|
|
3744
|
+
} else {
|
|
3745
|
+
data[key] = value;
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3123
3748
|
return createOperation2(
|
|
3124
3749
|
{
|
|
3125
3750
|
nodeId,
|
|
@@ -3127,10 +3752,11 @@ async function buildRouteOperation(store, mutation) {
|
|
|
3127
3752
|
collection: mutation.collection,
|
|
3128
3753
|
recordId: mutation.recordId,
|
|
3129
3754
|
data,
|
|
3130
|
-
previousData: current ? pickFields(current, Object.keys(
|
|
3755
|
+
previousData: current ? pickFields(current, Object.keys(rawData)) : {},
|
|
3131
3756
|
sequenceNumber,
|
|
3132
3757
|
causalDeps: [],
|
|
3133
|
-
schemaVersion
|
|
3758
|
+
schemaVersion,
|
|
3759
|
+
...Object.keys(atomicOps).length > 0 ? { atomicOps } : {}
|
|
3134
3760
|
},
|
|
3135
3761
|
clock
|
|
3136
3762
|
);
|
|
@@ -3168,45 +3794,200 @@ function createRouteContext(server, store) {
|
|
|
3168
3794
|
);
|
|
3169
3795
|
return run;
|
|
3170
3796
|
}
|
|
3797
|
+
async function prepareMutation(mutation, scope) {
|
|
3798
|
+
let op;
|
|
3799
|
+
try {
|
|
3800
|
+
op = await buildRouteOperation(store, mutation);
|
|
3801
|
+
} catch (error) {
|
|
3802
|
+
if (error instanceof RouteMutationError) {
|
|
3803
|
+
return {
|
|
3804
|
+
ok: false,
|
|
3805
|
+
rejection: {
|
|
3806
|
+
ok: false,
|
|
3807
|
+
code: error.code,
|
|
3808
|
+
message: error.message,
|
|
3809
|
+
retriable: isRetriableRejection(error.code)
|
|
3810
|
+
}
|
|
3811
|
+
};
|
|
3812
|
+
}
|
|
3813
|
+
throw error;
|
|
3814
|
+
}
|
|
3815
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
3816
|
+
return {
|
|
3817
|
+
ok: false,
|
|
3818
|
+
rejection: {
|
|
3819
|
+
ok: false,
|
|
3820
|
+
code: "SCOPE_VIOLATION",
|
|
3821
|
+
message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
|
|
3822
|
+
retriable: isRetriableRejection("SCOPE_VIOLATION")
|
|
3823
|
+
}
|
|
3824
|
+
};
|
|
3825
|
+
}
|
|
3826
|
+
return { ok: true, op };
|
|
3827
|
+
}
|
|
3828
|
+
async function commitOperation(op, collection) {
|
|
3829
|
+
const result = await server.applyLocalOperation(op);
|
|
3830
|
+
if (result.result !== "applied") {
|
|
3831
|
+
const code = result.rejection?.code ?? "NOT_APPLIED";
|
|
3832
|
+
return {
|
|
3833
|
+
ok: false,
|
|
3834
|
+
code,
|
|
3835
|
+
message: result.rejection?.message ?? `Operation on "${collection}" was not applied (${result.result}).`,
|
|
3836
|
+
// Prefer the pipeline's own classification when present; fall back
|
|
3837
|
+
// to classifying the code so this stays correct if new codes appear.
|
|
3838
|
+
retriable: result.rejection?.retriable ?? isRetriableRejection(code)
|
|
3839
|
+
};
|
|
3840
|
+
}
|
|
3841
|
+
const record = await store.findRecord(collection, op.recordId);
|
|
3842
|
+
return { ok: true, operation: op, record };
|
|
3843
|
+
}
|
|
3844
|
+
async function rawApply(mutation, scope) {
|
|
3845
|
+
const prepared = await prepareMutation(mutation, scope);
|
|
3846
|
+
if (!prepared.ok) {
|
|
3847
|
+
return prepared.rejection;
|
|
3848
|
+
}
|
|
3849
|
+
return commitOperation(prepared.op, mutation.collection);
|
|
3850
|
+
}
|
|
3851
|
+
async function runConditionalViaStore(storeApplyConditional, spec, options) {
|
|
3852
|
+
const scope = options?.scope;
|
|
3853
|
+
const mutations = [];
|
|
3854
|
+
const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
|
|
3855
|
+
if (hasTargetUpdate) {
|
|
3856
|
+
mutations.push({
|
|
3857
|
+
collection: spec.collection,
|
|
3858
|
+
type: "update",
|
|
3859
|
+
recordId: spec.id,
|
|
3860
|
+
data: spec.update
|
|
3861
|
+
});
|
|
3862
|
+
}
|
|
3863
|
+
if (spec.also) {
|
|
3864
|
+
mutations.push(...spec.also);
|
|
3865
|
+
}
|
|
3866
|
+
let result;
|
|
3867
|
+
try {
|
|
3868
|
+
result = await storeApplyConditional({
|
|
3869
|
+
target: { collection: spec.collection, id: spec.id },
|
|
3870
|
+
...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
|
|
3871
|
+
admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
|
|
3872
|
+
buildOperations: async (current, context) => {
|
|
3873
|
+
const ops = [];
|
|
3874
|
+
for (let i = 0; i < mutations.length; i++) {
|
|
3875
|
+
const mutation = mutations[i];
|
|
3876
|
+
const useLockedCurrent = i === 0 && hasTargetUpdate;
|
|
3877
|
+
const op = await buildRouteOperation(
|
|
3878
|
+
store,
|
|
3879
|
+
mutation,
|
|
3880
|
+
useLockedCurrent ? { record: current } : void 0,
|
|
3881
|
+
context.clock
|
|
3882
|
+
);
|
|
3883
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
3884
|
+
throw new RouteMutationError(
|
|
3885
|
+
"SCOPE_VIOLATION",
|
|
3886
|
+
`Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
3887
|
+
);
|
|
3888
|
+
}
|
|
3889
|
+
ops.push(op);
|
|
3890
|
+
}
|
|
3891
|
+
return ops;
|
|
3892
|
+
}
|
|
3893
|
+
});
|
|
3894
|
+
} catch (error) {
|
|
3895
|
+
if (error instanceof RouteMutationError) {
|
|
3896
|
+
return {
|
|
3897
|
+
ok: false,
|
|
3898
|
+
code: error.code,
|
|
3899
|
+
message: error.message,
|
|
3900
|
+
retriable: isRetriableRejection(error.code)
|
|
3901
|
+
};
|
|
3902
|
+
}
|
|
3903
|
+
throw error;
|
|
3904
|
+
}
|
|
3905
|
+
if (!result.admitted) {
|
|
3906
|
+
return {
|
|
3907
|
+
ok: false,
|
|
3908
|
+
code: spec.reject?.code ?? "CONDITION_NOT_MET",
|
|
3909
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
3910
|
+
retriable: false
|
|
3911
|
+
};
|
|
3912
|
+
}
|
|
3913
|
+
if (result.idempotent) {
|
|
3914
|
+
const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
|
|
3915
|
+
return { ok: true, idempotent: true, operations: [], records: records2 };
|
|
3916
|
+
}
|
|
3917
|
+
server.relayServerOperations(result.applied);
|
|
3918
|
+
const records = [];
|
|
3919
|
+
for (const op of result.applied) {
|
|
3920
|
+
records.push(await store.findRecord(op.collection, op.recordId));
|
|
3921
|
+
}
|
|
3922
|
+
return { ok: true, idempotent: false, operations: result.applied, records };
|
|
3923
|
+
}
|
|
3171
3924
|
return {
|
|
3172
3925
|
apply(mutation, options) {
|
|
3926
|
+
return serialize(() => rawApply(mutation, options?.scope));
|
|
3927
|
+
},
|
|
3928
|
+
applyConditional(spec, options) {
|
|
3929
|
+
const storeApplyConditional = store.applyConditional;
|
|
3930
|
+
if (storeApplyConditional) {
|
|
3931
|
+
return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
|
|
3932
|
+
}
|
|
3173
3933
|
return serialize(async () => {
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3934
|
+
if (spec.idempotencyKey) {
|
|
3935
|
+
const existing = await store.findRecord(
|
|
3936
|
+
spec.idempotencyKey.collection,
|
|
3937
|
+
spec.idempotencyKey.id
|
|
3938
|
+
);
|
|
3939
|
+
if (existing) {
|
|
3940
|
+
return { ok: true, idempotent: true, operations: [], records: [existing] };
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
if (spec.if && Object.keys(spec.if).length > 0) {
|
|
3944
|
+
const target = await store.findRecord(spec.collection, spec.id);
|
|
3945
|
+
if (!evaluateRoutePredicate(target, spec.if)) {
|
|
3946
|
+
const code = spec.reject?.code ?? "CONDITION_NOT_MET";
|
|
3179
3947
|
return {
|
|
3180
3948
|
ok: false,
|
|
3181
|
-
code
|
|
3182
|
-
message:
|
|
3183
|
-
retriable:
|
|
3949
|
+
code,
|
|
3950
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
3951
|
+
retriable: false
|
|
3184
3952
|
};
|
|
3185
3953
|
}
|
|
3186
|
-
throw error;
|
|
3187
3954
|
}
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3955
|
+
const mutations = [];
|
|
3956
|
+
if (spec.update !== void 0 && spec.update !== null) {
|
|
3957
|
+
mutations.push({
|
|
3958
|
+
collection: spec.collection,
|
|
3959
|
+
type: "update",
|
|
3960
|
+
recordId: spec.id,
|
|
3961
|
+
data: spec.update
|
|
3962
|
+
});
|
|
3195
3963
|
}
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3964
|
+
if (spec.also) {
|
|
3965
|
+
mutations.push(...spec.also);
|
|
3966
|
+
}
|
|
3967
|
+
const prepared = [];
|
|
3968
|
+
for (const mutation of mutations) {
|
|
3969
|
+
const p = await prepareMutation(mutation, options?.scope);
|
|
3970
|
+
if (!p.ok) {
|
|
3971
|
+
return p.rejection;
|
|
3972
|
+
}
|
|
3973
|
+
prepared.push({ op: p.op, collection: mutation.collection });
|
|
3974
|
+
}
|
|
3975
|
+
const operations2 = [];
|
|
3976
|
+
const records = [];
|
|
3977
|
+
for (const { op, collection } of prepared) {
|
|
3978
|
+
const result = await commitOperation(op, collection);
|
|
3979
|
+
if (!result.ok) {
|
|
3980
|
+
return {
|
|
3981
|
+
ok: false,
|
|
3982
|
+
code: result.code,
|
|
3983
|
+
message: result.message,
|
|
3984
|
+
retriable: result.retriable
|
|
3985
|
+
};
|
|
3986
|
+
}
|
|
3987
|
+
operations2.push(result.operation);
|
|
3988
|
+
records.push(result.record);
|
|
3207
3989
|
}
|
|
3208
|
-
|
|
3209
|
-
return { ok: true, operation: op, record };
|
|
3990
|
+
return { ok: true, idempotent: false, operations: operations2, records };
|
|
3210
3991
|
});
|
|
3211
3992
|
},
|
|
3212
3993
|
async query(collection, options) {
|
|
@@ -3236,7 +4017,8 @@ var DEFAULT_BATCH_SIZE2 = 100;
|
|
|
3236
4017
|
var DEFAULT_SCHEMA_VERSION2 = 1;
|
|
3237
4018
|
var DEFAULT_HOST = "0.0.0.0";
|
|
3238
4019
|
var DEFAULT_PATH = "/";
|
|
3239
|
-
var
|
|
4020
|
+
var RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
|
|
4021
|
+
var KoraSyncServer = class _KoraSyncServer {
|
|
3240
4022
|
store;
|
|
3241
4023
|
auth;
|
|
3242
4024
|
serializer;
|
|
@@ -3266,6 +4048,20 @@ var KoraSyncServer = class {
|
|
|
3266
4048
|
serverVersion = "1.0.0-beta.0";
|
|
3267
4049
|
wsServer = null;
|
|
3268
4050
|
running = false;
|
|
4051
|
+
/**
|
|
4052
|
+
* Periodic tick that retransmits relay batches connected clients have not
|
|
4053
|
+
* acknowledged, so a relay dropped by a lossy transport is redelivered instead of
|
|
4054
|
+
* leaving that client with a permanent version-vector gap (a lost operation).
|
|
4055
|
+
*/
|
|
4056
|
+
relayRetransmitTimer = null;
|
|
4057
|
+
/**
|
|
4058
|
+
* Relay operations a client never acknowledged before it disconnected, buffered by
|
|
4059
|
+
* client node id so they can be redelivered on its next connection. Bounded per
|
|
4060
|
+
* node and expired by age so a client that never returns cannot grow this forever.
|
|
4061
|
+
*/
|
|
4062
|
+
orphanedRelaysByNode = /* @__PURE__ */ new Map();
|
|
4063
|
+
static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
|
|
4064
|
+
static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
|
|
3269
4065
|
constructor(config) {
|
|
3270
4066
|
this.store = config.store;
|
|
3271
4067
|
this.auth = config.auth ?? null;
|
|
@@ -3293,6 +4089,57 @@ var KoraSyncServer = class {
|
|
|
3293
4089
|
if (!this.emitter) {
|
|
3294
4090
|
this.emitter = new SimpleEventEmitter();
|
|
3295
4091
|
}
|
|
4092
|
+
this.relayRetransmitTimer = setInterval(() => {
|
|
4093
|
+
this.retransmitPendingRelays(RELAY_RETRANSMIT_INTERVAL_MS);
|
|
4094
|
+
}, RELAY_RETRANSMIT_INTERVAL_MS);
|
|
4095
|
+
this.relayRetransmitTimer.unref?.();
|
|
4096
|
+
}
|
|
4097
|
+
/**
|
|
4098
|
+
* Retransmit relay batches that connected clients have not acknowledged within
|
|
4099
|
+
* `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
|
|
4100
|
+
* already-applied operation is harmless (clients dedup by content-addressed id).
|
|
4101
|
+
*/
|
|
4102
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
4103
|
+
for (const session of this.sessions.values()) {
|
|
4104
|
+
session.retransmitPendingRelays(staleMs);
|
|
4105
|
+
}
|
|
4106
|
+
this.expireOrphanedRelays();
|
|
4107
|
+
}
|
|
4108
|
+
/**
|
|
4109
|
+
* Buffer relay operations a disconnected client never acknowledged, keyed by its
|
|
4110
|
+
* node id, deduped by operation id and bounded by count. Redelivered when the
|
|
4111
|
+
* client reconnects (see {@link takeOrphanedRelays}).
|
|
4112
|
+
*/
|
|
4113
|
+
bufferOrphanedRelays(nodeId, ops) {
|
|
4114
|
+
if (ops.length === 0) return;
|
|
4115
|
+
const existing = this.orphanedRelaysByNode.get(nodeId);
|
|
4116
|
+
const merged = existing ? existing.ops : [];
|
|
4117
|
+
const seen = new Set(merged.map((op) => op.id));
|
|
4118
|
+
for (const op of ops) {
|
|
4119
|
+
if (!seen.has(op.id)) {
|
|
4120
|
+
merged.push(op);
|
|
4121
|
+
seen.add(op.id);
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4124
|
+
const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
|
|
4125
|
+
this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
|
|
4126
|
+
}
|
|
4127
|
+
/** Remove and return a node's buffered orphaned relay operations, if any. */
|
|
4128
|
+
takeOrphanedRelays(nodeId) {
|
|
4129
|
+
const entry = this.orphanedRelaysByNode.get(nodeId);
|
|
4130
|
+
if (!entry) return [];
|
|
4131
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4132
|
+
return entry.ops;
|
|
4133
|
+
}
|
|
4134
|
+
/** Drop buffered orphaned relays older than the TTL (client never returned). */
|
|
4135
|
+
expireOrphanedRelays() {
|
|
4136
|
+
if (this.orphanedRelaysByNode.size === 0) return;
|
|
4137
|
+
const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
|
|
4138
|
+
for (const [nodeId, entry] of this.orphanedRelaysByNode) {
|
|
4139
|
+
if (entry.bufferedAtMs <= cutoff) {
|
|
4140
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
3296
4143
|
}
|
|
3297
4144
|
/**
|
|
3298
4145
|
* The trusted, scoped data-plane context (`apply` / `query` / `findById`).
|
|
@@ -3419,6 +4266,11 @@ var KoraSyncServer = class {
|
|
|
3419
4266
|
event: "server.stopping",
|
|
3420
4267
|
details: { connectedClients: this.sessions.size }
|
|
3421
4268
|
});
|
|
4269
|
+
if (this.relayRetransmitTimer) {
|
|
4270
|
+
clearInterval(this.relayRetransmitTimer);
|
|
4271
|
+
this.relayRetransmitTimer = null;
|
|
4272
|
+
}
|
|
4273
|
+
this.orphanedRelaysByNode.clear();
|
|
3422
4274
|
this.awarenessRelay.clear();
|
|
3423
4275
|
this.yjsDocRelay.clear();
|
|
3424
4276
|
this.blobChunkRelay.clear();
|
|
@@ -3580,7 +4432,11 @@ var KoraSyncServer = class {
|
|
|
3580
4432
|
...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
|
|
3581
4433
|
onClose: (sid) => {
|
|
3582
4434
|
this.handleSessionClose(sid);
|
|
3583
|
-
}
|
|
4435
|
+
},
|
|
4436
|
+
onOrphanedRelays: (nodeId, ops) => {
|
|
4437
|
+
this.bufferOrphanedRelays(nodeId, ops);
|
|
4438
|
+
},
|
|
4439
|
+
takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
|
|
3584
4440
|
});
|
|
3585
4441
|
this.sessions.set(sessionId, session);
|
|
3586
4442
|
this.yjsDocRelay.addClient(sessionId, transport);
|
|
@@ -3640,6 +4496,22 @@ var KoraSyncServer = class {
|
|
|
3640
4496
|
}
|
|
3641
4497
|
return result;
|
|
3642
4498
|
}
|
|
4499
|
+
/**
|
|
4500
|
+
* Relay already-applied, server-originated operations to every connected client.
|
|
4501
|
+
* Used by the conditional route apply, which commits its operations atomically
|
|
4502
|
+
* through the store (bypassing {@link applyLocalOperation}) and then fans them
|
|
4503
|
+
* out. Each session still enforces its own per-scope visibility filter.
|
|
4504
|
+
*
|
|
4505
|
+
* @param operations - Operations that have already been committed to the store
|
|
4506
|
+
*/
|
|
4507
|
+
relayServerOperations(operations2) {
|
|
4508
|
+
if (operations2.length === 0) {
|
|
4509
|
+
return;
|
|
4510
|
+
}
|
|
4511
|
+
for (const session of this.sessions.values()) {
|
|
4512
|
+
session.relayOperations(operations2);
|
|
4513
|
+
}
|
|
4514
|
+
}
|
|
3643
4515
|
/**
|
|
3644
4516
|
* Collect every blob reference still reachable from live records on the server.
|
|
3645
4517
|
*
|