@korajs/server 1.0.0-beta.4 → 1.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +873 -129
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +310 -2
- package/dist/index.d.ts +310 -2
- package/dist/index.js +873 -129
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.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])
|
|
@@ -220,6 +196,9 @@ function serializeFieldValue(value, descriptor) {
|
|
|
220
196
|
if (value === null || value === void 0) return null;
|
|
221
197
|
switch (descriptor.kind) {
|
|
222
198
|
case "array":
|
|
199
|
+
case "object":
|
|
200
|
+
case "json":
|
|
201
|
+
case "blob":
|
|
223
202
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
224
203
|
case "boolean":
|
|
225
204
|
return value ? 1 : 0;
|
|
@@ -233,6 +212,9 @@ function deserializeFieldValue(value, descriptor) {
|
|
|
233
212
|
if (value === null || value === void 0) return null;
|
|
234
213
|
switch (descriptor.kind) {
|
|
235
214
|
case "array":
|
|
215
|
+
case "object":
|
|
216
|
+
case "json":
|
|
217
|
+
case "blob":
|
|
236
218
|
return typeof value === "string" ? JSON.parse(value) : value;
|
|
237
219
|
case "boolean":
|
|
238
220
|
return value === 1 || value === true;
|
|
@@ -265,6 +247,14 @@ var MemoryServerStore = class {
|
|
|
265
247
|
operations = [];
|
|
266
248
|
operationIndex = /* @__PURE__ */ new Map();
|
|
267
249
|
versionVector = /* @__PURE__ */ new Map();
|
|
250
|
+
/**
|
|
251
|
+
* Server-assigned delivery sequence per operation id. Single-process, so a plain
|
|
252
|
+
* counter incremented at insert time gives commit-order delivery sequence with no
|
|
253
|
+
* gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
|
|
254
|
+
* is a forward walk.
|
|
255
|
+
*/
|
|
256
|
+
deliverySeqByOpId = /* @__PURE__ */ new Map();
|
|
257
|
+
deliverySeqCounter = 0;
|
|
268
258
|
schema = null;
|
|
269
259
|
/** Materialized records: collection -> recordId -> record data */
|
|
270
260
|
materializedRecords = /* @__PURE__ */ new Map();
|
|
@@ -298,6 +288,8 @@ var MemoryServerStore = class {
|
|
|
298
288
|
}
|
|
299
289
|
this.operations.push(op);
|
|
300
290
|
this.operationIndex.set(op.id, op);
|
|
291
|
+
this.deliverySeqCounter += 1;
|
|
292
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
301
293
|
const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
|
|
302
294
|
if (op.sequenceNumber > currentSeq) {
|
|
303
295
|
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
@@ -317,6 +309,23 @@ var MemoryServerStore = class {
|
|
|
317
309
|
this.assertOpen();
|
|
318
310
|
return this.operations.length;
|
|
319
311
|
}
|
|
312
|
+
async getMaxDeliverySequence() {
|
|
313
|
+
this.assertOpen();
|
|
314
|
+
return this.deliverySeqCounter;
|
|
315
|
+
}
|
|
316
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
317
|
+
this.assertOpen();
|
|
318
|
+
const result = [];
|
|
319
|
+
if (limit <= 0) return result;
|
|
320
|
+
for (const op of this.operations) {
|
|
321
|
+
const deliverySequence = this.deliverySeqByOpId.get(op.id) ?? 0;
|
|
322
|
+
if (deliverySequence > afterDeliverySequence) {
|
|
323
|
+
result.push({ operation: op, deliverySequence });
|
|
324
|
+
if (result.length >= limit) break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
320
329
|
async materializeCollection(collection) {
|
|
321
330
|
this.assertOpen();
|
|
322
331
|
if (this.schema?.collections[collection]) {
|
|
@@ -461,12 +470,16 @@ var MemoryServerStore = class {
|
|
|
461
470
|
this.operations.length = 0;
|
|
462
471
|
this.operationIndex.clear();
|
|
463
472
|
this.versionVector.clear();
|
|
473
|
+
this.deliverySeqByOpId.clear();
|
|
474
|
+
this.deliverySeqCounter = 0;
|
|
464
475
|
for (const [nid, seq] of versionVector) {
|
|
465
476
|
this.versionVector.set(nid, seq);
|
|
466
477
|
}
|
|
467
478
|
for (const op of operations2) {
|
|
468
479
|
this.operations.push(op);
|
|
469
480
|
this.operationIndex.set(op.id, op);
|
|
481
|
+
this.deliverySeqCounter += 1;
|
|
482
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
470
483
|
if (this.schema?.collections[op.collection]) {
|
|
471
484
|
this.rebuildMaterializedRecord(op.collection, op.recordId);
|
|
472
485
|
}
|
|
@@ -496,11 +509,12 @@ var MemoryServerStore = class {
|
|
|
496
509
|
return a.timestamp.wallTime - b.timestamp.wallTime;
|
|
497
510
|
if (a.timestamp.logical !== b.timestamp.logical)
|
|
498
511
|
return a.timestamp.logical - b.timestamp.logical;
|
|
499
|
-
return a.
|
|
512
|
+
return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
|
|
500
513
|
});
|
|
501
514
|
const parsedOps = recordOps.map((op) => ({
|
|
502
515
|
type: op.type,
|
|
503
|
-
data: op.data
|
|
516
|
+
data: op.data,
|
|
517
|
+
atomicOps: op.atomicOps ?? null
|
|
504
518
|
}));
|
|
505
519
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
506
520
|
if (recordData) {
|
|
@@ -606,8 +620,8 @@ var MemoryServerStore = class {
|
|
|
606
620
|
};
|
|
607
621
|
|
|
608
622
|
// 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";
|
|
623
|
+
import { HybridLogicalClock, generateUUIDv7 as generateUUIDv72, quoteIdent as quoteIdent2 } from "@korajs/core";
|
|
624
|
+
import { and, asc, between, count, desc, eq, gt, sql } from "drizzle-orm";
|
|
611
625
|
|
|
612
626
|
// src/store/drizzle-pg-schema.ts
|
|
613
627
|
import { bigint, index, integer, pgTable, text } from "drizzle-orm/pg-core";
|
|
@@ -623,6 +637,8 @@ var pgOperations = pgTable(
|
|
|
623
637
|
// JSON-serialized, null for deletes
|
|
624
638
|
previousData: text("previous_data"),
|
|
625
639
|
// JSON-serialized, null for insert/delete
|
|
640
|
+
atomicOps: text("atomic_ops"),
|
|
641
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
626
642
|
wallTime: bigint("wall_time", { mode: "number" }).notNull(),
|
|
627
643
|
logical: integer("logical").notNull(),
|
|
628
644
|
timestampNodeId: text("timestamp_node_id").notNull(),
|
|
@@ -630,12 +646,18 @@ var pgOperations = pgTable(
|
|
|
630
646
|
causalDeps: text("causal_deps").notNull().default("[]"),
|
|
631
647
|
// JSON array of op IDs
|
|
632
648
|
schemaVersion: integer("schema_version").notNull(),
|
|
633
|
-
receivedAt: bigint("received_at", { mode: "number" }).notNull()
|
|
649
|
+
receivedAt: bigint("received_at", { mode: "number" }).notNull(),
|
|
650
|
+
// Server-assigned monotonic delivery sequence, ordered by commit (assigned from
|
|
651
|
+
// a counter row locked inside the append transaction, so delivery order equals
|
|
652
|
+
// visibility order even across instances). Drives the gap-free server->client
|
|
653
|
+
// delivery watermark. Nullable only for pre-column rows, backfilled on startup.
|
|
654
|
+
deliverySeq: bigint("delivery_seq", { mode: "number" })
|
|
634
655
|
},
|
|
635
656
|
(table) => ({
|
|
636
657
|
nodeSeqIdx: index("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
637
658
|
collectionIdx: index("idx_pg_collection").on(table.collection),
|
|
638
|
-
receivedIdx: index("idx_pg_received").on(table.receivedAt)
|
|
659
|
+
receivedIdx: index("idx_pg_received").on(table.receivedAt),
|
|
660
|
+
deliveryIdx: index("idx_pg_delivery_seq").on(table.deliverySeq)
|
|
639
661
|
})
|
|
640
662
|
);
|
|
641
663
|
var pgSyncState = pgTable("sync_state", {
|
|
@@ -652,15 +674,43 @@ var PostgresServerStore = class {
|
|
|
652
674
|
ready;
|
|
653
675
|
schema = null;
|
|
654
676
|
closed = false;
|
|
655
|
-
|
|
677
|
+
/**
|
|
678
|
+
* Monotonic counter for this node's server-originated sequence numbers. Lazily
|
|
679
|
+
* seeded from the persisted version vector on first use, then advanced in memory
|
|
680
|
+
* (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
|
|
681
|
+
* applies never receive the same number. Null means "not yet seeded"; reset to
|
|
682
|
+
* null after a backup import so it re-seeds from the restored version vector.
|
|
683
|
+
*/
|
|
684
|
+
sequenceCounter = null;
|
|
685
|
+
/**
|
|
686
|
+
* Time source for HLC timestamps on server-originated operations. Injectable so a
|
|
687
|
+
* test can freeze wall-clock time and prove the conditional-apply ordering holds
|
|
688
|
+
* under same-millisecond commits (the case the advisory lock and clock advance
|
|
689
|
+
* exist to make correct). Defaults to the system clock in production.
|
|
690
|
+
*/
|
|
691
|
+
timeSource;
|
|
692
|
+
constructor(db, nodeId, timeSource) {
|
|
656
693
|
this.db = db;
|
|
657
694
|
this.nodeId = nodeId ?? generateUUIDv72();
|
|
695
|
+
this.timeSource = timeSource ?? { now: () => Date.now() };
|
|
658
696
|
this.ready = this.initialize();
|
|
659
697
|
}
|
|
660
698
|
getVersionVector() {
|
|
661
699
|
this.assertOpen();
|
|
662
700
|
return new Map(this.versionVector);
|
|
663
701
|
}
|
|
702
|
+
/**
|
|
703
|
+
* Atomically reserve this node's next server-operation sequence number. The
|
|
704
|
+
* synchronous increment cannot interleave on the single JS thread, so two
|
|
705
|
+
* concurrent conditional applies are always handed distinct numbers.
|
|
706
|
+
*/
|
|
707
|
+
reserveSequenceNumber() {
|
|
708
|
+
if (this.sequenceCounter === null) {
|
|
709
|
+
this.sequenceCounter = this.versionVector.get(this.nodeId) ?? 0;
|
|
710
|
+
}
|
|
711
|
+
this.sequenceCounter += 1;
|
|
712
|
+
return this.sequenceCounter;
|
|
713
|
+
}
|
|
664
714
|
getNodeId() {
|
|
665
715
|
return this.nodeId;
|
|
666
716
|
}
|
|
@@ -698,8 +748,9 @@ var PostgresServerStore = class {
|
|
|
698
748
|
return "duplicate";
|
|
699
749
|
}
|
|
700
750
|
const now = Date.now();
|
|
701
|
-
const row = this.serializeOperation(op, now);
|
|
702
751
|
await this.db.transaction(async (tx) => {
|
|
752
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
753
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
703
754
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
704
755
|
await tx.insert(pgSyncState).values({
|
|
705
756
|
nodeId: op.nodeId,
|
|
@@ -722,6 +773,73 @@ var PostgresServerStore = class {
|
|
|
722
773
|
}
|
|
723
774
|
return "applied";
|
|
724
775
|
}
|
|
776
|
+
/**
|
|
777
|
+
* Conditionally apply operations atomically, serialized across server instances
|
|
778
|
+
* on the target record via a transaction-scoped advisory lock. The lock makes
|
|
779
|
+
* the read-decide-write cycle atomic against concurrent instances, so a cap
|
|
780
|
+
* check like `responseCount < max` cannot be passed by two admissions at once.
|
|
781
|
+
*/
|
|
782
|
+
async applyConditional(input) {
|
|
783
|
+
this.assertOpen();
|
|
784
|
+
await this.ready;
|
|
785
|
+
this.assertSchema();
|
|
786
|
+
this.assertCollection(input.target.collection);
|
|
787
|
+
const schema = this.schema;
|
|
788
|
+
const collectionDef = schema.collections[input.target.collection];
|
|
789
|
+
const lockKey = `kora:${input.target.collection}:${input.target.id}`;
|
|
790
|
+
const result = await this.db.transaction(async (tx) => {
|
|
791
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
|
792
|
+
if (input.idempotencyKey) {
|
|
793
|
+
const existing = await tx.execute(
|
|
794
|
+
sql`SELECT 1 FROM ${sql.raw(quoteIdent2(input.idempotencyKey.collection))} WHERE id = ${input.idempotencyKey.id} AND _deleted = 0 LIMIT 1`
|
|
795
|
+
);
|
|
796
|
+
if (existing.length > 0) {
|
|
797
|
+
return { admitted: true, idempotent: true, applied: [] };
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const rows = await tx.execute(
|
|
801
|
+
sql`SELECT * FROM ${sql.raw(quoteIdent2(input.target.collection))} WHERE id = ${input.target.id} AND _deleted = 0`
|
|
802
|
+
);
|
|
803
|
+
const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
|
|
804
|
+
if (!input.admit(current)) {
|
|
805
|
+
return { admitted: false, idempotent: false, applied: [] };
|
|
806
|
+
}
|
|
807
|
+
const clock = new HybridLogicalClock(this.nodeId, this.timeSource);
|
|
808
|
+
const latest = await this.latestOperationTimestamp(
|
|
809
|
+
tx,
|
|
810
|
+
input.target.collection,
|
|
811
|
+
input.target.id
|
|
812
|
+
);
|
|
813
|
+
if (latest) {
|
|
814
|
+
clock.advanceTo(latest);
|
|
815
|
+
}
|
|
816
|
+
const ops = await input.buildOperations(current, { clock });
|
|
817
|
+
const now = Date.now();
|
|
818
|
+
for (const op of ops) {
|
|
819
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
820
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
821
|
+
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
822
|
+
await tx.insert(pgSyncState).values({ nodeId: op.nodeId, maxSequenceNumber: op.sequenceNumber, lastSeenAt: now }).onConflictDoUpdate({
|
|
823
|
+
target: pgSyncState.nodeId,
|
|
824
|
+
set: {
|
|
825
|
+
maxSequenceNumber: sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
|
|
826
|
+
lastSeenAt: sql`${now}`
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
if (this.schema?.collections[op.collection]) {
|
|
830
|
+
await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return { admitted: true, idempotent: false, applied: ops };
|
|
834
|
+
});
|
|
835
|
+
for (const op of result.applied) {
|
|
836
|
+
const currentMax = this.versionVector.get(op.nodeId) ?? 0;
|
|
837
|
+
if (op.sequenceNumber > currentMax) {
|
|
838
|
+
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return result;
|
|
842
|
+
}
|
|
725
843
|
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
726
844
|
this.assertOpen();
|
|
727
845
|
await this.ready;
|
|
@@ -736,6 +854,23 @@ var PostgresServerStore = class {
|
|
|
736
854
|
const result = await this.db.select({ value: count() }).from(pgOperations);
|
|
737
855
|
return result[0]?.value ?? 0;
|
|
738
856
|
}
|
|
857
|
+
async getMaxDeliverySequence() {
|
|
858
|
+
this.assertOpen();
|
|
859
|
+
await this.ready;
|
|
860
|
+
const rows = await this.db.execute(
|
|
861
|
+
sql`SELECT COALESCE(MAX(delivery_seq), 0) AS m FROM operations`
|
|
862
|
+
);
|
|
863
|
+
return Number(rows[0]?.m ?? 0);
|
|
864
|
+
}
|
|
865
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
866
|
+
this.assertOpen();
|
|
867
|
+
await this.ready;
|
|
868
|
+
const rows = await this.db.select().from(pgOperations).where(gt(pgOperations.deliverySeq, afterDeliverySequence)).orderBy(asc(pgOperations.deliverySeq)).limit(limit);
|
|
869
|
+
return rows.map((row) => ({
|
|
870
|
+
operation: this.deserializeOperation(row),
|
|
871
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
872
|
+
}));
|
|
873
|
+
}
|
|
739
874
|
async materializeCollection(collection) {
|
|
740
875
|
this.assertOpen();
|
|
741
876
|
await this.ready;
|
|
@@ -799,7 +934,7 @@ var PostgresServerStore = class {
|
|
|
799
934
|
this.assertOpen();
|
|
800
935
|
await this.ready;
|
|
801
936
|
const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
|
|
802
|
-
const rows = await this.db.select().from(pgOperations).orderBy(asc(pgOperations.
|
|
937
|
+
const rows = await this.db.select().from(pgOperations).orderBy(asc(pgOperations.deliverySeq));
|
|
803
938
|
const operations2 = rows.map((row) => this.deserializeOperation(row));
|
|
804
939
|
return buildServerBackup(this.nodeId, operations2, this.versionVector);
|
|
805
940
|
}
|
|
@@ -814,6 +949,7 @@ var PostgresServerStore = class {
|
|
|
814
949
|
const result = await this.applyRemoteOperation(op);
|
|
815
950
|
if (result === "applied") restored++;
|
|
816
951
|
}
|
|
952
|
+
this.sequenceCounter = null;
|
|
817
953
|
return { operationsRestored: restored, success: true };
|
|
818
954
|
}
|
|
819
955
|
const now = Date.now();
|
|
@@ -823,20 +959,49 @@ var PostgresServerStore = class {
|
|
|
823
959
|
for (const [nid, seq] of versionVector) {
|
|
824
960
|
await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
|
|
825
961
|
}
|
|
962
|
+
let deliverySeq = 0;
|
|
826
963
|
for (const op of operations2) {
|
|
827
|
-
|
|
964
|
+
deliverySeq += 1;
|
|
965
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
828
966
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
829
967
|
}
|
|
968
|
+
await tx.execute(
|
|
969
|
+
sql`INSERT INTO delivery_counter (id, value) VALUES (1, ${deliverySeq})
|
|
970
|
+
ON CONFLICT (id) DO UPDATE SET value = ${deliverySeq}`
|
|
971
|
+
);
|
|
830
972
|
});
|
|
831
973
|
this.versionVector.clear();
|
|
832
974
|
for (const [nid, seq] of versionVector) {
|
|
833
975
|
this.versionVector.set(nid, seq);
|
|
834
976
|
}
|
|
977
|
+
this.sequenceCounter = null;
|
|
835
978
|
return { operationsRestored: operations2.length, success: true };
|
|
836
979
|
}
|
|
837
980
|
// ---------------------------------------------------------------------------
|
|
838
981
|
// Materialization internals
|
|
839
982
|
// ---------------------------------------------------------------------------
|
|
983
|
+
/**
|
|
984
|
+
* The HLC timestamp of the most recent operation on a record, by total order
|
|
985
|
+
* (wallTime, logical, nodeId), or null when the record has no operations. Used
|
|
986
|
+
* by {@link applyConditional} to advance its clock past the record's latest
|
|
987
|
+
* write so newly built operations sort strictly after it.
|
|
988
|
+
*/
|
|
989
|
+
async latestOperationTimestamp(txOrDb, collection, recordId) {
|
|
990
|
+
const rows = await txOrDb.select({
|
|
991
|
+
wallTime: pgOperations.wallTime,
|
|
992
|
+
logical: pgOperations.logical,
|
|
993
|
+
timestampNodeId: pgOperations.timestampNodeId
|
|
994
|
+
}).from(pgOperations).where(and(eq(pgOperations.collection, collection), eq(pgOperations.recordId, recordId))).orderBy(
|
|
995
|
+
desc(pgOperations.wallTime),
|
|
996
|
+
desc(pgOperations.logical),
|
|
997
|
+
desc(pgOperations.timestampNodeId)
|
|
998
|
+
).limit(1);
|
|
999
|
+
const row = rows[0];
|
|
1000
|
+
if (!row) {
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
return { wallTime: row.wallTime, logical: row.logical, nodeId: row.timestampNodeId };
|
|
1004
|
+
}
|
|
840
1005
|
/**
|
|
841
1006
|
* Rebuild a single record in the materialized collection table by replaying
|
|
842
1007
|
* all operations for that record.
|
|
@@ -847,15 +1012,17 @@ var PostgresServerStore = class {
|
|
|
847
1012
|
const ops = await txOrDb.select({
|
|
848
1013
|
type: pgOperations.type,
|
|
849
1014
|
data: pgOperations.data,
|
|
1015
|
+
atomicOps: pgOperations.atomicOps,
|
|
850
1016
|
wallTime: pgOperations.wallTime
|
|
851
1017
|
}).from(pgOperations).where(and(eq(pgOperations.collection, collection), eq(pgOperations.recordId, recordId))).orderBy(
|
|
852
1018
|
asc(pgOperations.wallTime),
|
|
853
1019
|
asc(pgOperations.logical),
|
|
854
|
-
asc(pgOperations.
|
|
1020
|
+
asc(pgOperations.timestampNodeId)
|
|
855
1021
|
);
|
|
856
1022
|
const parsedOps = ops.map((op) => ({
|
|
857
1023
|
type: op.type,
|
|
858
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1024
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1025
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
859
1026
|
}));
|
|
860
1027
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
861
1028
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
@@ -924,11 +1091,12 @@ var PostgresServerStore = class {
|
|
|
924
1091
|
recordId: pgOperations.recordId,
|
|
925
1092
|
type: pgOperations.type,
|
|
926
1093
|
data: pgOperations.data,
|
|
1094
|
+
atomicOps: pgOperations.atomicOps,
|
|
927
1095
|
wallTime: pgOperations.wallTime
|
|
928
1096
|
}).from(pgOperations).where(eq(pgOperations.collection, collectionName)).orderBy(
|
|
929
1097
|
asc(pgOperations.wallTime),
|
|
930
1098
|
asc(pgOperations.logical),
|
|
931
|
-
asc(pgOperations.
|
|
1099
|
+
asc(pgOperations.timestampNodeId)
|
|
932
1100
|
);
|
|
933
1101
|
if (allOps.length === 0) return;
|
|
934
1102
|
const grouped = /* @__PURE__ */ new Map();
|
|
@@ -944,7 +1112,8 @@ var PostgresServerStore = class {
|
|
|
944
1112
|
for (const [recordId, recordOps] of grouped) {
|
|
945
1113
|
const parsedOps = recordOps.map((op) => ({
|
|
946
1114
|
type: op.type,
|
|
947
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1115
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1116
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
948
1117
|
}));
|
|
949
1118
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
950
1119
|
if (recordData) {
|
|
@@ -1069,44 +1238,92 @@ var PostgresServerStore = class {
|
|
|
1069
1238
|
}
|
|
1070
1239
|
}
|
|
1071
1240
|
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
|
-
|
|
1241
|
+
await this.db.transaction(async (tx) => {
|
|
1242
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended('kora:ensure-tables', 0))`);
|
|
1243
|
+
await tx.execute(sql`
|
|
1244
|
+
CREATE TABLE IF NOT EXISTS operations (
|
|
1245
|
+
id TEXT PRIMARY KEY,
|
|
1246
|
+
node_id TEXT NOT NULL,
|
|
1247
|
+
type TEXT NOT NULL,
|
|
1248
|
+
collection TEXT NOT NULL,
|
|
1249
|
+
record_id TEXT NOT NULL,
|
|
1250
|
+
data TEXT,
|
|
1251
|
+
previous_data TEXT,
|
|
1252
|
+
atomic_ops TEXT,
|
|
1253
|
+
wall_time BIGINT NOT NULL,
|
|
1254
|
+
logical INTEGER NOT NULL,
|
|
1255
|
+
timestamp_node_id TEXT NOT NULL,
|
|
1256
|
+
sequence_number INTEGER NOT NULL,
|
|
1257
|
+
causal_deps TEXT NOT NULL DEFAULT '[]',
|
|
1258
|
+
schema_version INTEGER NOT NULL,
|
|
1259
|
+
received_at BIGINT NOT NULL
|
|
1260
|
+
)
|
|
1261
|
+
`);
|
|
1262
|
+
await tx.execute(sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS atomic_ops TEXT`);
|
|
1263
|
+
await tx.execute(sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS delivery_seq BIGINT`);
|
|
1264
|
+
await tx.execute(
|
|
1265
|
+
sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
|
|
1266
|
+
);
|
|
1267
|
+
await tx.execute(sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
|
|
1268
|
+
await tx.execute(sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
|
|
1269
|
+
await tx.execute(
|
|
1270
|
+
sql`CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)`
|
|
1271
|
+
);
|
|
1272
|
+
await tx.execute(
|
|
1273
|
+
sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
|
|
1274
|
+
);
|
|
1275
|
+
await tx.execute(sql`
|
|
1276
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
1277
|
+
node_id TEXT PRIMARY KEY,
|
|
1278
|
+
max_sequence_number INTEGER NOT NULL,
|
|
1279
|
+
last_seen_at BIGINT NOT NULL
|
|
1280
|
+
)
|
|
1281
|
+
`);
|
|
1282
|
+
await tx.execute(sql`
|
|
1283
|
+
CREATE TABLE IF NOT EXISTS delivery_counter (
|
|
1284
|
+
id INTEGER PRIMARY KEY,
|
|
1285
|
+
value BIGINT NOT NULL
|
|
1286
|
+
)
|
|
1287
|
+
`);
|
|
1288
|
+
await tx.execute(sql`
|
|
1289
|
+
WITH ordered AS (
|
|
1290
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
1291
|
+
ORDER BY received_at ASC, sequence_number ASC, id ASC
|
|
1292
|
+
) + COALESCE((SELECT MAX(delivery_seq) FROM operations), 0) AS rn
|
|
1293
|
+
FROM operations WHERE delivery_seq IS NULL
|
|
1294
|
+
)
|
|
1295
|
+
UPDATE operations o SET delivery_seq = ordered.rn
|
|
1296
|
+
FROM ordered WHERE o.id = ordered.id
|
|
1297
|
+
`);
|
|
1298
|
+
await tx.execute(sql`
|
|
1299
|
+
INSERT INTO delivery_counter (id, value)
|
|
1300
|
+
VALUES (1, COALESCE((SELECT MAX(delivery_seq) FROM operations), 0))
|
|
1301
|
+
ON CONFLICT (id) DO UPDATE
|
|
1302
|
+
SET value = GREATEST(delivery_counter.value, EXCLUDED.value)
|
|
1303
|
+
`);
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Reserve the next delivery sequence inside an open append transaction. The
|
|
1308
|
+
* `UPDATE ... RETURNING` locks the counter row until this transaction commits,
|
|
1309
|
+
* serializing assignment into commit order (see the counter table comment).
|
|
1310
|
+
*/
|
|
1311
|
+
async nextDeliverySeq(tx) {
|
|
1312
|
+
const rows = await tx.execute(
|
|
1313
|
+
sql`UPDATE delivery_counter SET value = value + 1 WHERE id = 1 RETURNING value`
|
|
1097
1314
|
);
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1315
|
+
const value = rows[0]?.value;
|
|
1316
|
+
if (value === void 0 || value === null) {
|
|
1317
|
+
throw new Error(
|
|
1318
|
+
"delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
return Number(value);
|
|
1105
1322
|
}
|
|
1106
1323
|
// ---------------------------------------------------------------------------
|
|
1107
1324
|
// Operation serialization
|
|
1108
1325
|
// ---------------------------------------------------------------------------
|
|
1109
|
-
serializeOperation(op, receivedAt) {
|
|
1326
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1110
1327
|
return {
|
|
1111
1328
|
id: op.id,
|
|
1112
1329
|
nodeId: op.nodeId,
|
|
@@ -1115,16 +1332,19 @@ var PostgresServerStore = class {
|
|
|
1115
1332
|
recordId: op.recordId,
|
|
1116
1333
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1117
1334
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
1335
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1118
1336
|
wallTime: op.timestamp.wallTime,
|
|
1119
1337
|
logical: op.timestamp.logical,
|
|
1120
1338
|
timestampNodeId: op.timestamp.nodeId,
|
|
1121
1339
|
sequenceNumber: op.sequenceNumber,
|
|
1122
1340
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1123
1341
|
schemaVersion: op.schemaVersion,
|
|
1124
|
-
receivedAt
|
|
1342
|
+
receivedAt,
|
|
1343
|
+
deliverySeq
|
|
1125
1344
|
};
|
|
1126
1345
|
}
|
|
1127
1346
|
deserializeOperation(row) {
|
|
1347
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1128
1348
|
return {
|
|
1129
1349
|
id: row.id,
|
|
1130
1350
|
nodeId: row.nodeId,
|
|
@@ -1140,7 +1360,8 @@ var PostgresServerStore = class {
|
|
|
1140
1360
|
},
|
|
1141
1361
|
sequenceNumber: row.sequenceNumber,
|
|
1142
1362
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1143
|
-
schemaVersion: row.schemaVersion
|
|
1363
|
+
schemaVersion: row.schemaVersion,
|
|
1364
|
+
...atomicOps ? { atomicOps } : {}
|
|
1144
1365
|
};
|
|
1145
1366
|
}
|
|
1146
1367
|
// ---------------------------------------------------------------------------
|
|
@@ -1192,7 +1413,7 @@ async function loadPostgresDeps() {
|
|
|
1192
1413
|
// src/store/sqlite-server-store.ts
|
|
1193
1414
|
import { createRequire } from "module";
|
|
1194
1415
|
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";
|
|
1416
|
+
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
1417
|
|
|
1197
1418
|
// src/store/drizzle-schema.ts
|
|
1198
1419
|
import { index as index2, integer as integer2, sqliteTable, text as text2 } from "drizzle-orm/sqlite-core";
|
|
@@ -1208,6 +1429,8 @@ var operations = sqliteTable(
|
|
|
1208
1429
|
// JSON-serialized, null for deletes
|
|
1209
1430
|
previousData: text2("previous_data"),
|
|
1210
1431
|
// JSON-serialized, null for insert/delete
|
|
1432
|
+
atomicOps: text2("atomic_ops"),
|
|
1433
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
1211
1434
|
wallTime: integer2("wall_time").notNull(),
|
|
1212
1435
|
logical: integer2("logical").notNull(),
|
|
1213
1436
|
timestampNodeId: text2("timestamp_node_id").notNull(),
|
|
@@ -1215,12 +1438,17 @@ var operations = sqliteTable(
|
|
|
1215
1438
|
causalDeps: text2("causal_deps").notNull().default("[]"),
|
|
1216
1439
|
// JSON array of op IDs
|
|
1217
1440
|
schemaVersion: integer2("schema_version").notNull(),
|
|
1218
|
-
receivedAt: integer2("received_at").notNull()
|
|
1441
|
+
receivedAt: integer2("received_at").notNull(),
|
|
1442
|
+
// Server-assigned monotonic delivery sequence, ordered by commit. Drives the
|
|
1443
|
+
// gap-free server->client delivery watermark. Nullable only for rows written
|
|
1444
|
+
// before the column existed; those are backfilled on startup.
|
|
1445
|
+
deliverySeq: integer2("delivery_seq")
|
|
1219
1446
|
},
|
|
1220
1447
|
(table) => ({
|
|
1221
1448
|
nodeSeqIdx: index2("idx_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
1222
1449
|
collectionIdx: index2("idx_collection").on(table.collection),
|
|
1223
|
-
receivedIdx: index2("idx_received").on(table.receivedAt)
|
|
1450
|
+
receivedIdx: index2("idx_received").on(table.receivedAt),
|
|
1451
|
+
deliveryIdx: index2("idx_delivery_seq").on(table.deliverySeq)
|
|
1224
1452
|
})
|
|
1225
1453
|
);
|
|
1226
1454
|
var syncState = sqliteTable("sync_state", {
|
|
@@ -1236,6 +1464,12 @@ var SqliteServerStore = class {
|
|
|
1236
1464
|
db;
|
|
1237
1465
|
schema = null;
|
|
1238
1466
|
closed = false;
|
|
1467
|
+
/**
|
|
1468
|
+
* Next delivery sequence to assign. Single-writer (better-sqlite3 is synchronous),
|
|
1469
|
+
* so a plain in-memory counter, seeded from MAX(delivery_seq) at startup, gives
|
|
1470
|
+
* commit-order delivery sequence. Assigned inside the append transaction.
|
|
1471
|
+
*/
|
|
1472
|
+
deliverySeqCounter = 0;
|
|
1239
1473
|
constructor(db, nodeId) {
|
|
1240
1474
|
this.db = db;
|
|
1241
1475
|
this.nodeId = nodeId ?? generateUUIDv73();
|
|
@@ -1281,12 +1515,16 @@ var SqliteServerStore = class {
|
|
|
1281
1515
|
async applyRemoteOperation(op) {
|
|
1282
1516
|
this.assertOpen();
|
|
1283
1517
|
const now = Date.now();
|
|
1284
|
-
const row = this.serializeOperation(op, now);
|
|
1285
1518
|
const result = this.db.transaction((tx) => {
|
|
1286
|
-
const
|
|
1287
|
-
|
|
1519
|
+
const existing = tx.all(
|
|
1520
|
+
sql2`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
|
|
1521
|
+
);
|
|
1522
|
+
if (existing.length > 0) {
|
|
1288
1523
|
return "duplicate";
|
|
1289
1524
|
}
|
|
1525
|
+
this.deliverySeqCounter += 1;
|
|
1526
|
+
const row = this.serializeOperation(op, now, this.deliverySeqCounter);
|
|
1527
|
+
tx.insert(operations).values(row).run();
|
|
1290
1528
|
tx.insert(syncState).values({
|
|
1291
1529
|
nodeId: op.nodeId,
|
|
1292
1530
|
maxSequenceNumber: op.sequenceNumber,
|
|
@@ -1315,6 +1553,21 @@ var SqliteServerStore = class {
|
|
|
1315
1553
|
const result = this.db.select({ value: count2() }).from(operations).all();
|
|
1316
1554
|
return result[0]?.value ?? 0;
|
|
1317
1555
|
}
|
|
1556
|
+
async getMaxDeliverySequence() {
|
|
1557
|
+
this.assertOpen();
|
|
1558
|
+
const rows = this.db.all(
|
|
1559
|
+
sql2`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
1560
|
+
);
|
|
1561
|
+
return rows[0]?.m ?? 0;
|
|
1562
|
+
}
|
|
1563
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
1564
|
+
this.assertOpen();
|
|
1565
|
+
const rows = this.db.select().from(operations).where(gt2(operations.deliverySeq, afterDeliverySequence)).orderBy(asc2(operations.deliverySeq)).limit(limit).all();
|
|
1566
|
+
return rows.map((row) => ({
|
|
1567
|
+
operation: this.deserializeOperation(row),
|
|
1568
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
1569
|
+
}));
|
|
1570
|
+
}
|
|
1318
1571
|
async materializeCollection(collection) {
|
|
1319
1572
|
this.assertOpen();
|
|
1320
1573
|
if (this.schema?.collections[collection]) {
|
|
@@ -1372,7 +1625,7 @@ var SqliteServerStore = class {
|
|
|
1372
1625
|
async exportBackup() {
|
|
1373
1626
|
this.assertOpen();
|
|
1374
1627
|
const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
|
|
1375
|
-
const rows = this.db.select().from(operations).all();
|
|
1628
|
+
const rows = this.db.select().from(operations).orderBy(asc2(operations.deliverySeq)).all();
|
|
1376
1629
|
const deserialized = rows.map((row) => this.deserializeOperation(row));
|
|
1377
1630
|
const vv = this.getVersionVector();
|
|
1378
1631
|
return buildServerBackup(this.nodeId, deserialized, vv);
|
|
@@ -1395,10 +1648,13 @@ var SqliteServerStore = class {
|
|
|
1395
1648
|
for (const [nid, seq] of versionVector) {
|
|
1396
1649
|
tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
|
|
1397
1650
|
}
|
|
1651
|
+
let deliverySeq = 0;
|
|
1398
1652
|
for (const op of ops) {
|
|
1399
|
-
|
|
1653
|
+
deliverySeq += 1;
|
|
1654
|
+
const row = this.serializeOperation(op, Date.now(), deliverySeq);
|
|
1400
1655
|
tx.insert(operations).values(row).run();
|
|
1401
1656
|
}
|
|
1657
|
+
this.deliverySeqCounter = deliverySeq;
|
|
1402
1658
|
});
|
|
1403
1659
|
return { operationsRestored: ops.length, success: true };
|
|
1404
1660
|
}
|
|
@@ -1416,11 +1672,13 @@ var SqliteServerStore = class {
|
|
|
1416
1672
|
const ops = txOrDb.select({
|
|
1417
1673
|
type: operations.type,
|
|
1418
1674
|
data: operations.data,
|
|
1675
|
+
atomicOps: operations.atomicOps,
|
|
1419
1676
|
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.
|
|
1677
|
+
}).from(operations).where(and2(eq2(operations.collection, collection), eq2(operations.recordId, recordId))).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.timestampNodeId)).all();
|
|
1421
1678
|
const parsedOps = ops.map((op) => ({
|
|
1422
1679
|
type: op.type,
|
|
1423
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1680
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1681
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1424
1682
|
}));
|
|
1425
1683
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
1426
1684
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
@@ -1492,8 +1750,9 @@ var SqliteServerStore = class {
|
|
|
1492
1750
|
recordId: operations.recordId,
|
|
1493
1751
|
type: operations.type,
|
|
1494
1752
|
data: operations.data,
|
|
1753
|
+
atomicOps: operations.atomicOps,
|
|
1495
1754
|
wallTime: operations.wallTime
|
|
1496
|
-
}).from(operations).where(eq2(operations.collection, collectionName)).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.
|
|
1755
|
+
}).from(operations).where(eq2(operations.collection, collectionName)).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.timestampNodeId)).all();
|
|
1497
1756
|
if (allOps.length === 0) return;
|
|
1498
1757
|
const grouped = /* @__PURE__ */ new Map();
|
|
1499
1758
|
for (const op of allOps) {
|
|
@@ -1509,7 +1768,8 @@ var SqliteServerStore = class {
|
|
|
1509
1768
|
for (const [recordId, recordOps] of grouped) {
|
|
1510
1769
|
const parsedOps = recordOps.map((op) => ({
|
|
1511
1770
|
type: op.type,
|
|
1512
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1771
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1772
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1513
1773
|
}));
|
|
1514
1774
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
1515
1775
|
if (recordData) {
|
|
@@ -1633,6 +1893,7 @@ var SqliteServerStore = class {
|
|
|
1633
1893
|
record_id TEXT NOT NULL,
|
|
1634
1894
|
data TEXT,
|
|
1635
1895
|
previous_data TEXT,
|
|
1896
|
+
atomic_ops TEXT,
|
|
1636
1897
|
wall_time INTEGER NOT NULL,
|
|
1637
1898
|
logical INTEGER NOT NULL,
|
|
1638
1899
|
timestamp_node_id TEXT NOT NULL,
|
|
@@ -1642,9 +1903,30 @@ var SqliteServerStore = class {
|
|
|
1642
1903
|
received_at INTEGER NOT NULL
|
|
1643
1904
|
)
|
|
1644
1905
|
`);
|
|
1906
|
+
try {
|
|
1907
|
+
this.db.run(sql2`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
|
|
1908
|
+
} catch (e) {
|
|
1909
|
+
const msg = e instanceof Error ? e.message : "";
|
|
1910
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
1911
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
1912
|
+
throw e;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
try {
|
|
1916
|
+
this.db.run(sql2`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
|
|
1917
|
+
} catch (e) {
|
|
1918
|
+
const msg = e instanceof Error ? e.message : "";
|
|
1919
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
1920
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
1921
|
+
throw e;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1645
1924
|
this.db.run(sql2`
|
|
1646
1925
|
CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
|
|
1647
1926
|
`);
|
|
1927
|
+
this.db.run(sql2`
|
|
1928
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
|
|
1929
|
+
`);
|
|
1648
1930
|
this.db.run(sql2`
|
|
1649
1931
|
CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
|
|
1650
1932
|
`);
|
|
@@ -1661,11 +1943,34 @@ var SqliteServerStore = class {
|
|
|
1661
1943
|
last_seen_at INTEGER NOT NULL
|
|
1662
1944
|
)
|
|
1663
1945
|
`);
|
|
1946
|
+
this.backfillDeliverySequence();
|
|
1947
|
+
}
|
|
1948
|
+
/**
|
|
1949
|
+
* Assign delivery sequences to any operations written before the column existed,
|
|
1950
|
+
* then seed the in-memory counter from the current max. Ordered by
|
|
1951
|
+
* (received_at, sequence_number, id) so the backfill is deterministic and stable
|
|
1952
|
+
* across restarts. A one-time migration: after it runs, all rows have a value.
|
|
1953
|
+
*/
|
|
1954
|
+
backfillDeliverySequence() {
|
|
1955
|
+
this.db.transaction((tx) => {
|
|
1956
|
+
const maxRow = tx.all(
|
|
1957
|
+
sql2`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
1958
|
+
);
|
|
1959
|
+
let next = maxRow[0]?.m ?? 0;
|
|
1960
|
+
const nullRows = tx.all(
|
|
1961
|
+
sql2`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
|
|
1962
|
+
);
|
|
1963
|
+
for (const row of nullRows) {
|
|
1964
|
+
next += 1;
|
|
1965
|
+
tx.run(sql2`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
|
|
1966
|
+
}
|
|
1967
|
+
this.deliverySeqCounter = next;
|
|
1968
|
+
});
|
|
1664
1969
|
}
|
|
1665
1970
|
// ---------------------------------------------------------------------------
|
|
1666
1971
|
// Operation serialization
|
|
1667
1972
|
// ---------------------------------------------------------------------------
|
|
1668
|
-
serializeOperation(op, receivedAt) {
|
|
1973
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1669
1974
|
return {
|
|
1670
1975
|
id: op.id,
|
|
1671
1976
|
nodeId: op.nodeId,
|
|
@@ -1674,16 +1979,19 @@ var SqliteServerStore = class {
|
|
|
1674
1979
|
recordId: op.recordId,
|
|
1675
1980
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1676
1981
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
1982
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1677
1983
|
wallTime: op.timestamp.wallTime,
|
|
1678
1984
|
logical: op.timestamp.logical,
|
|
1679
1985
|
timestampNodeId: op.timestamp.nodeId,
|
|
1680
1986
|
sequenceNumber: op.sequenceNumber,
|
|
1681
1987
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1682
1988
|
schemaVersion: op.schemaVersion,
|
|
1683
|
-
receivedAt
|
|
1989
|
+
receivedAt,
|
|
1990
|
+
deliverySeq
|
|
1684
1991
|
};
|
|
1685
1992
|
}
|
|
1686
1993
|
deserializeOperation(row) {
|
|
1994
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1687
1995
|
return {
|
|
1688
1996
|
id: row.id,
|
|
1689
1997
|
nodeId: row.nodeId,
|
|
@@ -1699,7 +2007,8 @@ var SqliteServerStore = class {
|
|
|
1699
2007
|
},
|
|
1700
2008
|
sequenceNumber: row.sequenceNumber,
|
|
1701
2009
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1702
|
-
schemaVersion: row.schemaVersion
|
|
2010
|
+
schemaVersion: row.schemaVersion,
|
|
2011
|
+
...atomicOps ? { atomicOps } : {}
|
|
1703
2012
|
};
|
|
1704
2013
|
}
|
|
1705
2014
|
// ---------------------------------------------------------------------------
|
|
@@ -1981,10 +2290,10 @@ function createServerReferentialContext(store) {
|
|
|
1981
2290
|
}
|
|
1982
2291
|
|
|
1983
2292
|
// src/apply/server-side-effect-operation.ts
|
|
1984
|
-
import { HybridLogicalClock, createOperation } from "@korajs/core";
|
|
2293
|
+
import { HybridLogicalClock as HybridLogicalClock2, createOperation } from "@korajs/core";
|
|
1985
2294
|
async function createServerSideEffectOperation(store, parentOp, effect, schemaVersion, sequenceNumber) {
|
|
1986
2295
|
const nodeId = store.getNodeId();
|
|
1987
|
-
const clock = new
|
|
2296
|
+
const clock = new HybridLogicalClock2(nodeId);
|
|
1988
2297
|
clock.receive(parentOp.timestamp);
|
|
1989
2298
|
return createOperation(
|
|
1990
2299
|
{
|
|
@@ -2002,6 +2311,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
|
|
|
2002
2311
|
);
|
|
2003
2312
|
}
|
|
2004
2313
|
function nextServerSequenceNumber(store) {
|
|
2314
|
+
if (store.reserveSequenceNumber) {
|
|
2315
|
+
return store.reserveSequenceNumber();
|
|
2316
|
+
}
|
|
2005
2317
|
const nodeId = store.getNodeId();
|
|
2006
2318
|
const current = store.getVersionVector().get(nodeId) ?? 0;
|
|
2007
2319
|
return current + 1;
|
|
@@ -2043,16 +2355,14 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2043
2355
|
return { result: primaryResult, appliedOperations: [] };
|
|
2044
2356
|
}
|
|
2045
2357
|
const appliedOperations = [op];
|
|
2046
|
-
let serverSeq = nextServerSequenceNumber(store);
|
|
2047
2358
|
for (const effect of referential.sideEffectOps) {
|
|
2048
2359
|
const sideOp = await createServerSideEffectOperation(
|
|
2049
2360
|
store,
|
|
2050
2361
|
op,
|
|
2051
2362
|
effect,
|
|
2052
2363
|
op.schemaVersion,
|
|
2053
|
-
|
|
2364
|
+
nextServerSequenceNumber(store)
|
|
2054
2365
|
);
|
|
2055
|
-
serverSeq += 1;
|
|
2056
2366
|
const sideResult = await store.applyRemoteOperation(sideOp);
|
|
2057
2367
|
if (sideResult === "applied") {
|
|
2058
2368
|
appliedOperations.push(sideOp);
|
|
@@ -2110,11 +2420,11 @@ function mergeScopeMaps(base, overlay, source) {
|
|
|
2110
2420
|
}
|
|
2111
2421
|
|
|
2112
2422
|
// src/scopes/server-scope-filter.ts
|
|
2113
|
-
function operationMatchesScopes(op, scopes) {
|
|
2423
|
+
function operationMatchesScopes(op, scopes, fullRecord) {
|
|
2114
2424
|
if (!scopes) return true;
|
|
2115
2425
|
const collectionScope = scopes[op.collection];
|
|
2116
2426
|
if (!collectionScope) return false;
|
|
2117
|
-
const snapshot = buildSnapshot(op);
|
|
2427
|
+
const snapshot = buildSnapshot(op, fullRecord);
|
|
2118
2428
|
if (!snapshot) return false;
|
|
2119
2429
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
2120
2430
|
if (snapshot[field] !== expected) {
|
|
@@ -2123,11 +2433,19 @@ function operationMatchesScopes(op, scopes) {
|
|
|
2123
2433
|
}
|
|
2124
2434
|
return true;
|
|
2125
2435
|
}
|
|
2126
|
-
function
|
|
2436
|
+
function missingScopeFields(op, scopes) {
|
|
2437
|
+
if (!scopes) return [];
|
|
2438
|
+
const collectionScope = scopes[op.collection];
|
|
2439
|
+
if (!collectionScope) return [];
|
|
2440
|
+
const snapshot = buildSnapshot(op);
|
|
2441
|
+
return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
|
|
2442
|
+
}
|
|
2443
|
+
function buildSnapshot(op, fullRecord) {
|
|
2127
2444
|
const previous = asRecord(op.previousData);
|
|
2128
2445
|
const next = asRecord(op.data);
|
|
2129
|
-
if (!previous && !next) return null;
|
|
2446
|
+
if (!previous && !next && !fullRecord) return null;
|
|
2130
2447
|
return {
|
|
2448
|
+
...fullRecord ?? {},
|
|
2131
2449
|
...previous ?? {},
|
|
2132
2450
|
...next ?? {}
|
|
2133
2451
|
};
|
|
@@ -2218,18 +2536,48 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
|
|
|
2218
2536
|
"[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
2537
|
);
|
|
2220
2538
|
}
|
|
2221
|
-
var ClientSession = class {
|
|
2539
|
+
var ClientSession = class _ClientSession {
|
|
2222
2540
|
state = "connected";
|
|
2223
2541
|
clientNodeId = null;
|
|
2224
2542
|
authContext = null;
|
|
2225
2543
|
syncQuerySubsets = [];
|
|
2226
2544
|
resumeDeltaCursor = null;
|
|
2545
|
+
/**
|
|
2546
|
+
* The delivery watermark the client reported at handshake, or null when the client
|
|
2547
|
+
* does not use the delivery watermark (old client, or no watermark yet). When set,
|
|
2548
|
+
* the server drives the gap-free server->client stream from delivery sequences
|
|
2549
|
+
* instead of the version-vector delta.
|
|
2550
|
+
*/
|
|
2551
|
+
clientDeliveryWatermark = null;
|
|
2552
|
+
/**
|
|
2553
|
+
* The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
|
|
2554
|
+
* watermark, as reported in acks). Incremental streaming pushes resume from here, not
|
|
2555
|
+
* from the highest sequence sent, so a dropped or unapplied batch is always
|
|
2556
|
+
* re-included by the next push (which re-scans from the acknowledged position). This
|
|
2557
|
+
* is what makes streaming recovery independent of any bounded retransmit buffer: there
|
|
2558
|
+
* is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
|
|
2559
|
+
* client's reported watermark at handshake.
|
|
2560
|
+
*/
|
|
2561
|
+
lastAckedDeliverySeq = 0;
|
|
2562
|
+
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
2563
|
+
deliveryPushChain = Promise.resolve();
|
|
2564
|
+
/**
|
|
2565
|
+
* Relay batches sent to this client but not yet acknowledged, keyed by messageId.
|
|
2566
|
+
* The client acks every operation-batch it applies; on ack the entry is cleared.
|
|
2567
|
+
* {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
|
|
2568
|
+
* dropped by a lossy transport is redelivered instead of leaving the client with a
|
|
2569
|
+
* permanent version-vector gap (a lost operation) that delta sync cannot recover.
|
|
2570
|
+
* Bounded so a silent client cannot grow it without limit.
|
|
2571
|
+
*/
|
|
2572
|
+
pendingRelays = /* @__PURE__ */ new Map();
|
|
2573
|
+
static MAX_PENDING_RELAYS = 1e3;
|
|
2227
2574
|
sessionId;
|
|
2228
2575
|
transport;
|
|
2229
2576
|
store;
|
|
2230
2577
|
auth;
|
|
2231
2578
|
serializer;
|
|
2232
2579
|
emitter;
|
|
2580
|
+
logger;
|
|
2233
2581
|
batchSize;
|
|
2234
2582
|
schemaVersion;
|
|
2235
2583
|
supportedSchemaVersions;
|
|
@@ -2240,6 +2588,8 @@ var ClientSession = class {
|
|
|
2240
2588
|
onBlobChunkResponse;
|
|
2241
2589
|
persistBlobChunk;
|
|
2242
2590
|
onClose;
|
|
2591
|
+
onOrphanedRelays;
|
|
2592
|
+
takeOrphanedRelays;
|
|
2243
2593
|
maxOperationBytes;
|
|
2244
2594
|
maxOpsPerMinute;
|
|
2245
2595
|
rateLimiter;
|
|
@@ -2252,6 +2602,7 @@ var ClientSession = class {
|
|
|
2252
2602
|
this.auth = options.auth ?? null;
|
|
2253
2603
|
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
2254
2604
|
this.emitter = options.emitter ?? null;
|
|
2605
|
+
this.logger = options.logger ?? null;
|
|
2255
2606
|
this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2256
2607
|
this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
2257
2608
|
const supported = options.supportedSchemaVersions;
|
|
@@ -2266,6 +2617,8 @@ var ClientSession = class {
|
|
|
2266
2617
|
this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
|
|
2267
2618
|
this.persistBlobChunk = options.persistBlobChunk ?? null;
|
|
2268
2619
|
this.onClose = options.onClose ?? null;
|
|
2620
|
+
this.onOrphanedRelays = options.onOrphanedRelays ?? null;
|
|
2621
|
+
this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
|
|
2269
2622
|
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2270
2623
|
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
2271
2624
|
this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
|
|
@@ -2290,9 +2643,40 @@ var ClientSession = class {
|
|
|
2290
2643
|
*/
|
|
2291
2644
|
relayOperations(operations2) {
|
|
2292
2645
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2646
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2647
|
+
this.pushDeliveryStream();
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
2293
2650
|
if (operations2.length === 0) return;
|
|
2294
|
-
|
|
2651
|
+
void this.relayVisibleOperations(operations2);
|
|
2652
|
+
}
|
|
2653
|
+
/**
|
|
2654
|
+
* Push newly-available in-scope operations to a delivery-watermark client, resuming
|
|
2655
|
+
* from the highest delivery sequence already sent. Pushes are serialized so their
|
|
2656
|
+
* batches never interleave (which would break the base/max chain).
|
|
2657
|
+
*/
|
|
2658
|
+
pushDeliveryStream() {
|
|
2659
|
+
this.deliveryPushChain = this.deliveryPushChain.then(async () => {
|
|
2660
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2661
|
+
try {
|
|
2662
|
+
await this.sendDeliveryStream(
|
|
2663
|
+
this.lastAckedDeliverySeq,
|
|
2664
|
+
false,
|
|
2665
|
+
this.clientNodeId ?? void 0
|
|
2666
|
+
);
|
|
2667
|
+
} catch {
|
|
2668
|
+
}
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
async relayVisibleOperations(operations2) {
|
|
2672
|
+
const visibleOperations = [];
|
|
2673
|
+
for (const op of operations2) {
|
|
2674
|
+
if (await this.operationVisibleToClient(op)) {
|
|
2675
|
+
visibleOperations.push(op);
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2295
2678
|
if (visibleOperations.length === 0) return;
|
|
2679
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2296
2680
|
const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
|
|
2297
2681
|
const msg = {
|
|
2298
2682
|
type: "operation-batch",
|
|
@@ -2301,14 +2685,62 @@ var ClientSession = class {
|
|
|
2301
2685
|
isFinal: true,
|
|
2302
2686
|
batchIndex: 0
|
|
2303
2687
|
};
|
|
2688
|
+
this.trackPendingRelay(msg, visibleOperations);
|
|
2304
2689
|
this.sendToClient(msg);
|
|
2305
2690
|
}
|
|
2691
|
+
/** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
|
|
2692
|
+
trackPendingRelay(msg, ops) {
|
|
2693
|
+
if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
|
|
2694
|
+
const oldest = this.pendingRelays.keys().next().value;
|
|
2695
|
+
if (oldest !== void 0) {
|
|
2696
|
+
this.pendingRelays.delete(oldest);
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
|
|
2700
|
+
}
|
|
2701
|
+
/**
|
|
2702
|
+
* Retransmit relay batches this client has not acknowledged within `staleMs`.
|
|
2703
|
+
* Called on a periodic tick by the server (and directly by tests). Redelivering an
|
|
2704
|
+
* already-applied op is harmless: the client dedups by content-addressed id.
|
|
2705
|
+
*/
|
|
2706
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
2707
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2708
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2709
|
+
this.pushDeliveryStream();
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
if (this.pendingRelays.size === 0) return;
|
|
2713
|
+
const cutoff = Date.now() - staleMs;
|
|
2714
|
+
for (const { message, sentAtMs } of this.pendingRelays.values()) {
|
|
2715
|
+
if (sentAtMs <= cutoff) {
|
|
2716
|
+
this.sendToClient(message);
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2306
2720
|
/**
|
|
2307
2721
|
* Close this session.
|
|
2308
2722
|
*/
|
|
2723
|
+
/**
|
|
2724
|
+
* Hand any unacknowledged relays to the server so they survive this session and are
|
|
2725
|
+
* redelivered when this client reconnects (per-node buffer), then clear them. Called
|
|
2726
|
+
* from every session-teardown path (explicit close and transport close).
|
|
2727
|
+
*/
|
|
2728
|
+
flushOrphanedRelays() {
|
|
2729
|
+
if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
|
|
2730
|
+
const ops = [];
|
|
2731
|
+
for (const entry of this.pendingRelays.values()) {
|
|
2732
|
+
ops.push(...entry.ops);
|
|
2733
|
+
}
|
|
2734
|
+
if (ops.length > 0) {
|
|
2735
|
+
this.onOrphanedRelays(this.clientNodeId, ops);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
this.pendingRelays.clear();
|
|
2739
|
+
}
|
|
2309
2740
|
close(reason) {
|
|
2310
2741
|
if (this.state === "closed") return;
|
|
2311
2742
|
this.state = "closed";
|
|
2743
|
+
this.flushOrphanedRelays();
|
|
2312
2744
|
if (this.transport.isConnected()) {
|
|
2313
2745
|
this.transport.close(1e3, reason ?? "session closed");
|
|
2314
2746
|
}
|
|
@@ -2363,6 +2795,10 @@ var ClientSession = class {
|
|
|
2363
2795
|
await this.handleOperationBatch(message);
|
|
2364
2796
|
break;
|
|
2365
2797
|
case "acknowledgment":
|
|
2798
|
+
this.pendingRelays.delete(message.acknowledgedMessageId);
|
|
2799
|
+
if (message.deliverySequence !== void 0) {
|
|
2800
|
+
this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
|
|
2801
|
+
}
|
|
2366
2802
|
break;
|
|
2367
2803
|
case "error":
|
|
2368
2804
|
break;
|
|
@@ -2401,6 +2837,18 @@ var ClientSession = class {
|
|
|
2401
2837
|
}
|
|
2402
2838
|
handleMessageFailure(error) {
|
|
2403
2839
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
2840
|
+
this.logger?.log({
|
|
2841
|
+
timestamp: Date.now(),
|
|
2842
|
+
level: "error",
|
|
2843
|
+
event: "session.message_failed",
|
|
2844
|
+
sessionId: this.sessionId,
|
|
2845
|
+
nodeId: this.clientNodeId ?? void 0,
|
|
2846
|
+
error: reason,
|
|
2847
|
+
details: {
|
|
2848
|
+
state: this.state,
|
|
2849
|
+
errorName: error instanceof Error ? error.name : typeof error
|
|
2850
|
+
}
|
|
2851
|
+
});
|
|
2404
2852
|
this.sendError("SYNC_ERROR", reason, true);
|
|
2405
2853
|
this.close(reason);
|
|
2406
2854
|
}
|
|
@@ -2439,6 +2887,15 @@ var ClientSession = class {
|
|
|
2439
2887
|
this.syncQuerySubsets = [];
|
|
2440
2888
|
}
|
|
2441
2889
|
this.resumeDeltaCursor = msg.deltaCursor ? decodeDeltaCursor(msg.deltaCursor) : null;
|
|
2890
|
+
this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
|
|
2891
|
+
const clientUsesWatermark = this.clientDeliveryWatermark !== null;
|
|
2892
|
+
let serverMaxDelivery = 0;
|
|
2893
|
+
if (clientUsesWatermark) {
|
|
2894
|
+
serverMaxDelivery = await this.store.getMaxDeliverySequence();
|
|
2895
|
+
if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
|
|
2896
|
+
this.clientDeliveryWatermark = 0;
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2442
2899
|
const serverVector = this.store.getVersionVector();
|
|
2443
2900
|
const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
|
|
2444
2901
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
@@ -2469,6 +2926,10 @@ var ClientSession = class {
|
|
|
2469
2926
|
accepted: true,
|
|
2470
2927
|
selectedWireFormat,
|
|
2471
2928
|
serverTime: Date.now(),
|
|
2929
|
+
// The server's highest delivery sequence, so a client whose persisted watermark
|
|
2930
|
+
// is ahead of it (server rolled back) can reset to a full resync. Sent only to
|
|
2931
|
+
// watermark-using clients.
|
|
2932
|
+
...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
|
|
2472
2933
|
// Advertise central blob storage so the client uploads the bytes behind
|
|
2473
2934
|
// its blob fields, keeping them available after the author goes offline.
|
|
2474
2935
|
...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
|
|
@@ -2479,16 +2940,28 @@ var ClientSession = class {
|
|
|
2479
2940
|
this.sendToClient(response);
|
|
2480
2941
|
this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
|
|
2481
2942
|
this.state = "syncing";
|
|
2482
|
-
|
|
2483
|
-
|
|
2943
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2944
|
+
this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
|
|
2945
|
+
const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
|
|
2946
|
+
await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
|
|
2947
|
+
} else {
|
|
2948
|
+
const clientVector = wireToVersionVector(msg.versionVector);
|
|
2949
|
+
await this.sendDelta(clientVector);
|
|
2950
|
+
}
|
|
2484
2951
|
this.state = "streaming";
|
|
2952
|
+
if (this.takeOrphanedRelays && this.clientNodeId) {
|
|
2953
|
+
const buffered = this.takeOrphanedRelays(this.clientNodeId);
|
|
2954
|
+
if (buffered.length > 0) {
|
|
2955
|
+
this.relayOperations(buffered);
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2485
2958
|
}
|
|
2486
2959
|
async handleOperationBatch(msg) {
|
|
2487
2960
|
const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2488
2961
|
const applied = [];
|
|
2489
2962
|
const rejected = [];
|
|
2490
2963
|
for (const op of operations2) {
|
|
2491
|
-
if (!this.operationVisibleToClient(op)) {
|
|
2964
|
+
if (!await this.operationVisibleToClient(op)) {
|
|
2492
2965
|
rejected.push(op);
|
|
2493
2966
|
continue;
|
|
2494
2967
|
}
|
|
@@ -2542,20 +3015,17 @@ var ClientSession = class {
|
|
|
2542
3015
|
continue;
|
|
2543
3016
|
}
|
|
2544
3017
|
}
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
applied.push(...applyResult.appliedOperations);
|
|
2557
|
-
}
|
|
2558
|
-
} catch {
|
|
3018
|
+
const applyResult = await applyServerOperation(this.store, op);
|
|
3019
|
+
if (applyResult.rejection) {
|
|
3020
|
+
this.sendError(
|
|
3021
|
+
applyResult.rejection.code,
|
|
3022
|
+
applyResult.rejection.message,
|
|
3023
|
+
applyResult.rejection.retriable
|
|
3024
|
+
);
|
|
3025
|
+
continue;
|
|
3026
|
+
}
|
|
3027
|
+
if (applyResult.result === "applied") {
|
|
3028
|
+
applied.push(...applyResult.appliedOperations);
|
|
2559
3029
|
}
|
|
2560
3030
|
}
|
|
2561
3031
|
if (rejected.length > 0) {
|
|
@@ -2593,8 +3063,11 @@ var ClientSession = class {
|
|
|
2593
3063
|
const clientSeq = clientVector.get(nodeId) ?? 0;
|
|
2594
3064
|
if (serverSeq > clientSeq) {
|
|
2595
3065
|
const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
|
|
2596
|
-
const
|
|
2597
|
-
|
|
3066
|
+
for (const op of ops) {
|
|
3067
|
+
if (await this.operationVisibleToClient(op)) {
|
|
3068
|
+
missing.push(op);
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
2598
3071
|
}
|
|
2599
3072
|
}
|
|
2600
3073
|
if (missing.length === 0) {
|
|
@@ -2646,11 +3119,111 @@ var ClientSession = class {
|
|
|
2646
3119
|
});
|
|
2647
3120
|
}
|
|
2648
3121
|
}
|
|
2649
|
-
|
|
2650
|
-
|
|
3122
|
+
/**
|
|
3123
|
+
* Send the gap-free server->client delivery stream, resuming just after
|
|
3124
|
+
* `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
|
|
3125
|
+
* (commit order), scope-filtered for this session, and sent in batches that chain:
|
|
3126
|
+
* each batch's `baseDeliverySequence` equals the previous batch's
|
|
3127
|
+
* `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
|
|
3128
|
+
* applies a batch only when its watermark equals the base and advances the
|
|
3129
|
+
* watermark to the max, so a dropped batch stalls the watermark and is recovered by
|
|
3130
|
+
* the next handshake resend. Because delivery-sequence order respects causal order
|
|
3131
|
+
* (a dependency is always committed, and thus sequenced, before its dependent), no
|
|
3132
|
+
* topological sort is needed and the client never defers on a missing dependency
|
|
3133
|
+
* that is itself in this stream.
|
|
3134
|
+
*
|
|
3135
|
+
* The final batch advances the watermark to the highest delivery sequence scanned,
|
|
3136
|
+
* not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
|
|
3137
|
+
* next reconnect. Returns the number of operations actually sent.
|
|
3138
|
+
*/
|
|
3139
|
+
async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
|
|
3140
|
+
const scanChunk = Math.max(this.batchSize, 1) * 5;
|
|
3141
|
+
let scanCursor = fromDeliverySeq;
|
|
3142
|
+
let maxScanned = fromDeliverySeq;
|
|
3143
|
+
const visible = [];
|
|
3144
|
+
while (true) {
|
|
3145
|
+
const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
|
|
3146
|
+
const last = chunk[chunk.length - 1];
|
|
3147
|
+
if (last === void 0) break;
|
|
3148
|
+
scanCursor = last.deliverySequence;
|
|
3149
|
+
maxScanned = scanCursor;
|
|
3150
|
+
for (const delivered of chunk) {
|
|
3151
|
+
if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
|
|
3152
|
+
continue;
|
|
3153
|
+
}
|
|
3154
|
+
if (await this.operationVisibleToClient(delivered.operation)) {
|
|
3155
|
+
visible.push(delivered);
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
if (chunk.length < scanChunk) break;
|
|
3159
|
+
}
|
|
3160
|
+
if (visible.length === 0) {
|
|
3161
|
+
if (finalizeWhenEmpty) {
|
|
3162
|
+
this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
|
|
3163
|
+
}
|
|
3164
|
+
return 0;
|
|
3165
|
+
}
|
|
3166
|
+
const totalBatches = Math.ceil(visible.length / this.batchSize);
|
|
3167
|
+
let base = fromDeliverySeq;
|
|
3168
|
+
for (let i = 0; i < totalBatches; i++) {
|
|
3169
|
+
const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
|
|
3170
|
+
const lastInSlice = slice[slice.length - 1];
|
|
3171
|
+
if (lastInSlice === void 0) continue;
|
|
3172
|
+
const isFinal = i === totalBatches - 1;
|
|
3173
|
+
const lastSeq = lastInSlice.deliverySequence;
|
|
3174
|
+
const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
|
|
3175
|
+
this.sendDeliveryBatch(slice, base, max, i, isFinal);
|
|
3176
|
+
base = max;
|
|
3177
|
+
}
|
|
3178
|
+
return visible.length;
|
|
3179
|
+
}
|
|
3180
|
+
/** Build, track, and send one chained delivery-stream batch. */
|
|
3181
|
+
sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
|
|
3182
|
+
const batchMsg = {
|
|
3183
|
+
type: "operation-batch",
|
|
3184
|
+
messageId: generateUUIDv74(),
|
|
3185
|
+
operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
|
|
3186
|
+
isFinal,
|
|
3187
|
+
batchIndex,
|
|
3188
|
+
baseDeliverySequence: base,
|
|
3189
|
+
maxDeliverySequence: max
|
|
3190
|
+
};
|
|
3191
|
+
this.sendToClient(batchMsg);
|
|
3192
|
+
if (slice.length > 0) {
|
|
3193
|
+
this.emitter?.emit({
|
|
3194
|
+
type: "sync:sent",
|
|
3195
|
+
operations: slice.map((delivered) => delivered.operation),
|
|
3196
|
+
batchSize: slice.length
|
|
3197
|
+
});
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
async operationVisibleToClient(op) {
|
|
3201
|
+
const scopes = this.authContext?.scopes;
|
|
3202
|
+
const subsets = this.syncQuerySubsets;
|
|
3203
|
+
const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
|
|
3204
|
+
const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
|
|
3205
|
+
if (!operationMatchesScopes(op, scopes, fullRecord)) {
|
|
2651
3206
|
return false;
|
|
2652
3207
|
}
|
|
2653
|
-
return operationMatchesQuerySubsets(op,
|
|
3208
|
+
return operationMatchesQuerySubsets(op, subsets, fullRecord);
|
|
3209
|
+
}
|
|
3210
|
+
/**
|
|
3211
|
+
* Read a record's current field values from the materialized store for scope /
|
|
3212
|
+
* query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
|
|
3213
|
+
* carries no fields) is still judged against the record's actual scope. Returns
|
|
3214
|
+
* undefined when the record cannot be read (never materialized, or no schema).
|
|
3215
|
+
*/
|
|
3216
|
+
async lookupRecordFields(collection, recordId) {
|
|
3217
|
+
try {
|
|
3218
|
+
const rows = await this.store.queryCollection(collection, {
|
|
3219
|
+
where: { id: recordId },
|
|
3220
|
+
includeDeleted: true,
|
|
3221
|
+
limit: 1
|
|
3222
|
+
});
|
|
3223
|
+
return rows[0];
|
|
3224
|
+
} catch {
|
|
3225
|
+
return void 0;
|
|
3226
|
+
}
|
|
2654
3227
|
}
|
|
2655
3228
|
handleAwarenessUpdate(msg) {
|
|
2656
3229
|
this.onAwarenessUpdate?.(this.sessionId, msg);
|
|
@@ -2695,6 +3268,7 @@ var ClientSession = class {
|
|
|
2695
3268
|
handleTransportClose() {
|
|
2696
3269
|
if (this.state === "closed") return;
|
|
2697
3270
|
this.state = "closed";
|
|
3271
|
+
this.flushOrphanedRelays();
|
|
2698
3272
|
this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
|
|
2699
3273
|
this.onClose?.(this.sessionId);
|
|
2700
3274
|
}
|
|
@@ -3069,7 +3643,7 @@ var YjsDocRelay = class {
|
|
|
3069
3643
|
|
|
3070
3644
|
// src/server/route-context.ts
|
|
3071
3645
|
import {
|
|
3072
|
-
HybridLogicalClock as
|
|
3646
|
+
HybridLogicalClock as HybridLogicalClock3,
|
|
3073
3647
|
createOperation as createOperation2,
|
|
3074
3648
|
generateUUIDv7 as generateUUIDv76,
|
|
3075
3649
|
isAtomicOp,
|
|
@@ -3147,9 +3721,9 @@ function pickFields(record, keys) {
|
|
|
3147
3721
|
}
|
|
3148
3722
|
return picked;
|
|
3149
3723
|
}
|
|
3150
|
-
async function buildRouteOperation(store, mutation) {
|
|
3724
|
+
async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
|
|
3151
3725
|
const nodeId = store.getNodeId();
|
|
3152
|
-
const clock = new
|
|
3726
|
+
const clock = clockOverride ?? new HybridLogicalClock3(nodeId);
|
|
3153
3727
|
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
3154
3728
|
const sequenceNumber = nextServerSequenceNumber(store);
|
|
3155
3729
|
if (mutation.type === "insert") {
|
|
@@ -3174,7 +3748,7 @@ async function buildRouteOperation(store, mutation) {
|
|
|
3174
3748
|
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
3175
3749
|
);
|
|
3176
3750
|
}
|
|
3177
|
-
const current = await store.findRecord(mutation.collection, mutation.recordId);
|
|
3751
|
+
const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
|
|
3178
3752
|
if (mutation.type === "update") {
|
|
3179
3753
|
const rawData = mutation.data ?? {};
|
|
3180
3754
|
const data = {};
|
|
@@ -3291,11 +3865,88 @@ function createRouteContext(server, store) {
|
|
|
3291
3865
|
}
|
|
3292
3866
|
return commitOperation(prepared.op, mutation.collection);
|
|
3293
3867
|
}
|
|
3868
|
+
async function runConditionalViaStore(storeApplyConditional, spec, options) {
|
|
3869
|
+
const scope = options?.scope;
|
|
3870
|
+
const mutations = [];
|
|
3871
|
+
const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
|
|
3872
|
+
if (hasTargetUpdate) {
|
|
3873
|
+
mutations.push({
|
|
3874
|
+
collection: spec.collection,
|
|
3875
|
+
type: "update",
|
|
3876
|
+
recordId: spec.id,
|
|
3877
|
+
data: spec.update
|
|
3878
|
+
});
|
|
3879
|
+
}
|
|
3880
|
+
if (spec.also) {
|
|
3881
|
+
mutations.push(...spec.also);
|
|
3882
|
+
}
|
|
3883
|
+
let result;
|
|
3884
|
+
try {
|
|
3885
|
+
result = await storeApplyConditional({
|
|
3886
|
+
target: { collection: spec.collection, id: spec.id },
|
|
3887
|
+
...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
|
|
3888
|
+
admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
|
|
3889
|
+
buildOperations: async (current, context) => {
|
|
3890
|
+
const ops = [];
|
|
3891
|
+
for (let i = 0; i < mutations.length; i++) {
|
|
3892
|
+
const mutation = mutations[i];
|
|
3893
|
+
const useLockedCurrent = i === 0 && hasTargetUpdate;
|
|
3894
|
+
const op = await buildRouteOperation(
|
|
3895
|
+
store,
|
|
3896
|
+
mutation,
|
|
3897
|
+
useLockedCurrent ? { record: current } : void 0,
|
|
3898
|
+
context.clock
|
|
3899
|
+
);
|
|
3900
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
3901
|
+
throw new RouteMutationError(
|
|
3902
|
+
"SCOPE_VIOLATION",
|
|
3903
|
+
`Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
3904
|
+
);
|
|
3905
|
+
}
|
|
3906
|
+
ops.push(op);
|
|
3907
|
+
}
|
|
3908
|
+
return ops;
|
|
3909
|
+
}
|
|
3910
|
+
});
|
|
3911
|
+
} catch (error) {
|
|
3912
|
+
if (error instanceof RouteMutationError) {
|
|
3913
|
+
return {
|
|
3914
|
+
ok: false,
|
|
3915
|
+
code: error.code,
|
|
3916
|
+
message: error.message,
|
|
3917
|
+
retriable: isRetriableRejection(error.code)
|
|
3918
|
+
};
|
|
3919
|
+
}
|
|
3920
|
+
throw error;
|
|
3921
|
+
}
|
|
3922
|
+
if (!result.admitted) {
|
|
3923
|
+
return {
|
|
3924
|
+
ok: false,
|
|
3925
|
+
code: spec.reject?.code ?? "CONDITION_NOT_MET",
|
|
3926
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
3927
|
+
retriable: false
|
|
3928
|
+
};
|
|
3929
|
+
}
|
|
3930
|
+
if (result.idempotent) {
|
|
3931
|
+
const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
|
|
3932
|
+
return { ok: true, idempotent: true, operations: [], records: records2 };
|
|
3933
|
+
}
|
|
3934
|
+
server.relayServerOperations(result.applied);
|
|
3935
|
+
const records = [];
|
|
3936
|
+
for (const op of result.applied) {
|
|
3937
|
+
records.push(await store.findRecord(op.collection, op.recordId));
|
|
3938
|
+
}
|
|
3939
|
+
return { ok: true, idempotent: false, operations: result.applied, records };
|
|
3940
|
+
}
|
|
3294
3941
|
return {
|
|
3295
3942
|
apply(mutation, options) {
|
|
3296
3943
|
return serialize(() => rawApply(mutation, options?.scope));
|
|
3297
3944
|
},
|
|
3298
3945
|
applyConditional(spec, options) {
|
|
3946
|
+
const storeApplyConditional = store.applyConditional;
|
|
3947
|
+
if (storeApplyConditional) {
|
|
3948
|
+
return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
|
|
3949
|
+
}
|
|
3299
3950
|
return serialize(async () => {
|
|
3300
3951
|
if (spec.idempotencyKey) {
|
|
3301
3952
|
const existing = await store.findRecord(
|
|
@@ -3383,7 +4034,8 @@ var DEFAULT_BATCH_SIZE2 = 100;
|
|
|
3383
4034
|
var DEFAULT_SCHEMA_VERSION2 = 1;
|
|
3384
4035
|
var DEFAULT_HOST = "0.0.0.0";
|
|
3385
4036
|
var DEFAULT_PATH = "/";
|
|
3386
|
-
var
|
|
4037
|
+
var RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
|
|
4038
|
+
var KoraSyncServer = class _KoraSyncServer {
|
|
3387
4039
|
store;
|
|
3388
4040
|
auth;
|
|
3389
4041
|
serializer;
|
|
@@ -3413,6 +4065,20 @@ var KoraSyncServer = class {
|
|
|
3413
4065
|
serverVersion = "1.0.0-beta.0";
|
|
3414
4066
|
wsServer = null;
|
|
3415
4067
|
running = false;
|
|
4068
|
+
/**
|
|
4069
|
+
* Periodic tick that retransmits relay batches connected clients have not
|
|
4070
|
+
* acknowledged, so a relay dropped by a lossy transport is redelivered instead of
|
|
4071
|
+
* leaving that client with a permanent version-vector gap (a lost operation).
|
|
4072
|
+
*/
|
|
4073
|
+
relayRetransmitTimer = null;
|
|
4074
|
+
/**
|
|
4075
|
+
* Relay operations a client never acknowledged before it disconnected, buffered by
|
|
4076
|
+
* client node id so they can be redelivered on its next connection. Bounded per
|
|
4077
|
+
* node and expired by age so a client that never returns cannot grow this forever.
|
|
4078
|
+
*/
|
|
4079
|
+
orphanedRelaysByNode = /* @__PURE__ */ new Map();
|
|
4080
|
+
static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
|
|
4081
|
+
static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
|
|
3416
4082
|
constructor(config) {
|
|
3417
4083
|
this.store = config.store;
|
|
3418
4084
|
this.auth = config.auth ?? null;
|
|
@@ -3440,6 +4106,57 @@ var KoraSyncServer = class {
|
|
|
3440
4106
|
if (!this.emitter) {
|
|
3441
4107
|
this.emitter = new SimpleEventEmitter();
|
|
3442
4108
|
}
|
|
4109
|
+
this.relayRetransmitTimer = setInterval(() => {
|
|
4110
|
+
this.retransmitPendingRelays(RELAY_RETRANSMIT_INTERVAL_MS);
|
|
4111
|
+
}, RELAY_RETRANSMIT_INTERVAL_MS);
|
|
4112
|
+
this.relayRetransmitTimer.unref?.();
|
|
4113
|
+
}
|
|
4114
|
+
/**
|
|
4115
|
+
* Retransmit relay batches that connected clients have not acknowledged within
|
|
4116
|
+
* `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
|
|
4117
|
+
* already-applied operation is harmless (clients dedup by content-addressed id).
|
|
4118
|
+
*/
|
|
4119
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
4120
|
+
for (const session of this.sessions.values()) {
|
|
4121
|
+
session.retransmitPendingRelays(staleMs);
|
|
4122
|
+
}
|
|
4123
|
+
this.expireOrphanedRelays();
|
|
4124
|
+
}
|
|
4125
|
+
/**
|
|
4126
|
+
* Buffer relay operations a disconnected client never acknowledged, keyed by its
|
|
4127
|
+
* node id, deduped by operation id and bounded by count. Redelivered when the
|
|
4128
|
+
* client reconnects (see {@link takeOrphanedRelays}).
|
|
4129
|
+
*/
|
|
4130
|
+
bufferOrphanedRelays(nodeId, ops) {
|
|
4131
|
+
if (ops.length === 0) return;
|
|
4132
|
+
const existing = this.orphanedRelaysByNode.get(nodeId);
|
|
4133
|
+
const merged = existing ? existing.ops : [];
|
|
4134
|
+
const seen = new Set(merged.map((op) => op.id));
|
|
4135
|
+
for (const op of ops) {
|
|
4136
|
+
if (!seen.has(op.id)) {
|
|
4137
|
+
merged.push(op);
|
|
4138
|
+
seen.add(op.id);
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
|
|
4142
|
+
this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
|
|
4143
|
+
}
|
|
4144
|
+
/** Remove and return a node's buffered orphaned relay operations, if any. */
|
|
4145
|
+
takeOrphanedRelays(nodeId) {
|
|
4146
|
+
const entry = this.orphanedRelaysByNode.get(nodeId);
|
|
4147
|
+
if (!entry) return [];
|
|
4148
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4149
|
+
return entry.ops;
|
|
4150
|
+
}
|
|
4151
|
+
/** Drop buffered orphaned relays older than the TTL (client never returned). */
|
|
4152
|
+
expireOrphanedRelays() {
|
|
4153
|
+
if (this.orphanedRelaysByNode.size === 0) return;
|
|
4154
|
+
const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
|
|
4155
|
+
for (const [nodeId, entry] of this.orphanedRelaysByNode) {
|
|
4156
|
+
if (entry.bufferedAtMs <= cutoff) {
|
|
4157
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
3443
4160
|
}
|
|
3444
4161
|
/**
|
|
3445
4162
|
* The trusted, scoped data-plane context (`apply` / `query` / `findById`).
|
|
@@ -3566,6 +4283,11 @@ var KoraSyncServer = class {
|
|
|
3566
4283
|
event: "server.stopping",
|
|
3567
4284
|
details: { connectedClients: this.sessions.size }
|
|
3568
4285
|
});
|
|
4286
|
+
if (this.relayRetransmitTimer) {
|
|
4287
|
+
clearInterval(this.relayRetransmitTimer);
|
|
4288
|
+
this.relayRetransmitTimer = null;
|
|
4289
|
+
}
|
|
4290
|
+
this.orphanedRelaysByNode.clear();
|
|
3569
4291
|
this.awarenessRelay.clear();
|
|
3570
4292
|
this.yjsDocRelay.clear();
|
|
3571
4293
|
this.blobChunkRelay.clear();
|
|
@@ -3701,6 +4423,7 @@ var KoraSyncServer = class {
|
|
|
3701
4423
|
auth: this.auth ?? void 0,
|
|
3702
4424
|
serializer: this.serializer,
|
|
3703
4425
|
emitter: sessionEmitter,
|
|
4426
|
+
logger: this.logger,
|
|
3704
4427
|
batchSize: this.batchSize,
|
|
3705
4428
|
schemaVersion: this.schemaVersion,
|
|
3706
4429
|
supportedSchemaVersions: this.supportedSchemaVersions,
|
|
@@ -3727,7 +4450,11 @@ var KoraSyncServer = class {
|
|
|
3727
4450
|
...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
|
|
3728
4451
|
onClose: (sid) => {
|
|
3729
4452
|
this.handleSessionClose(sid);
|
|
3730
|
-
}
|
|
4453
|
+
},
|
|
4454
|
+
onOrphanedRelays: (nodeId, ops) => {
|
|
4455
|
+
this.bufferOrphanedRelays(nodeId, ops);
|
|
4456
|
+
},
|
|
4457
|
+
takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
|
|
3731
4458
|
});
|
|
3732
4459
|
this.sessions.set(sessionId, session);
|
|
3733
4460
|
this.yjsDocRelay.addClient(sessionId, transport);
|
|
@@ -3787,6 +4514,22 @@ var KoraSyncServer = class {
|
|
|
3787
4514
|
}
|
|
3788
4515
|
return result;
|
|
3789
4516
|
}
|
|
4517
|
+
/**
|
|
4518
|
+
* Relay already-applied, server-originated operations to every connected client.
|
|
4519
|
+
* Used by the conditional route apply, which commits its operations atomically
|
|
4520
|
+
* through the store (bypassing {@link applyLocalOperation}) and then fans them
|
|
4521
|
+
* out. Each session still enforces its own per-scope visibility filter.
|
|
4522
|
+
*
|
|
4523
|
+
* @param operations - Operations that have already been committed to the store
|
|
4524
|
+
*/
|
|
4525
|
+
relayServerOperations(operations2) {
|
|
4526
|
+
if (operations2.length === 0) {
|
|
4527
|
+
return;
|
|
4528
|
+
}
|
|
4529
|
+
for (const session of this.sessions.values()) {
|
|
4530
|
+
session.relayOperations(operations2);
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
3790
4533
|
/**
|
|
3791
4534
|
* Collect every blob reference still reachable from live records on the server.
|
|
3792
4535
|
*
|
|
@@ -4004,6 +4747,7 @@ function createProductionServer(config) {
|
|
|
4004
4747
|
const port = config.port ?? (Number(process.env.PORT) || 3001);
|
|
4005
4748
|
const staticDir = config.staticDir ?? "./dist";
|
|
4006
4749
|
const syncPath = config.syncPath ?? "/kora-sync";
|
|
4750
|
+
const crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy ?? "credentialless";
|
|
4007
4751
|
const syncServer = new KoraSyncServer({
|
|
4008
4752
|
store: config.store,
|
|
4009
4753
|
enableDashboard: true,
|
|
@@ -4174,7 +4918,7 @@ function createProductionServer(config) {
|
|
|
4174
4918
|
});
|
|
4175
4919
|
async function handleRequest(req, res) {
|
|
4176
4920
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
4177
|
-
res.setHeader("Cross-Origin-Embedder-Policy",
|
|
4921
|
+
res.setHeader("Cross-Origin-Embedder-Policy", crossOriginEmbedderPolicy);
|
|
4178
4922
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
4179
4923
|
if (url.pathname === "/health") {
|
|
4180
4924
|
const status = await syncServer.getStatus();
|