@korajs/server 1.0.0-beta.1 → 1.0.0-beta.11
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 +1740 -348
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +918 -279
- package/dist/index.d.ts +918 -279
- package/dist/index.js +1720 -326
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -54,11 +54,17 @@ function createDefaultLogger() {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// src/apply/rejection-taxonomy.ts
|
|
58
|
+
var RETRIABLE_REJECTION_CODES = /* @__PURE__ */ new Set(["RATE_LIMIT"]);
|
|
59
|
+
function isRetriableRejection(code) {
|
|
60
|
+
return RETRIABLE_REJECTION_CODES.has(code);
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
// src/store/memory-server-store.ts
|
|
58
64
|
import { generateUUIDv7 } from "@korajs/core";
|
|
59
65
|
|
|
60
66
|
// src/store/materialization.ts
|
|
61
|
-
import { StorageError } from "@korajs/core";
|
|
67
|
+
import { StorageError, quoteIdent, replayOperationsForRecord } from "@korajs/core";
|
|
62
68
|
function fieldTypeToSql(descriptor, dialect) {
|
|
63
69
|
switch (descriptor.kind) {
|
|
64
70
|
case "string":
|
|
@@ -73,6 +79,14 @@ function fieldTypeToSql(descriptor, dialect) {
|
|
|
73
79
|
return dialect === "postgres" ? "BIGINT" : "INTEGER";
|
|
74
80
|
case "array":
|
|
75
81
|
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
82
|
+
case "object":
|
|
83
|
+
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
84
|
+
case "json":
|
|
85
|
+
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
86
|
+
case "blob":
|
|
87
|
+
return "TEXT";
|
|
88
|
+
case "secret":
|
|
89
|
+
return "TEXT";
|
|
76
90
|
case "richtext":
|
|
77
91
|
return dialect === "postgres" ? "BYTEA" : "BLOB";
|
|
78
92
|
}
|
|
@@ -89,13 +103,13 @@ function generateCollectionDDL(name, collection, dialect) {
|
|
|
89
103
|
const columns = ["id TEXT PRIMARY KEY NOT NULL"];
|
|
90
104
|
for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
|
|
91
105
|
const sqlType = fieldTypeToSql(descriptor, dialect);
|
|
92
|
-
let colDef = `${fieldName} ${sqlType}`;
|
|
106
|
+
let colDef = `${quoteIdent(fieldName)} ${sqlType}`;
|
|
93
107
|
if (descriptor.defaultValue !== void 0) {
|
|
94
108
|
colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
|
|
95
109
|
}
|
|
96
110
|
if (descriptor.kind === "enum" && descriptor.enumValues) {
|
|
97
111
|
const values = descriptor.enumValues.map((v) => `'${v}'`).join(", ");
|
|
98
|
-
colDef += ` CHECK (${fieldName} IN (${values}))`;
|
|
112
|
+
colDef += ` CHECK (${quoteIdent(fieldName)} IN (${values}))`;
|
|
99
113
|
}
|
|
100
114
|
columns.push(colDef);
|
|
101
115
|
}
|
|
@@ -103,24 +117,26 @@ function generateCollectionDDL(name, collection, dialect) {
|
|
|
103
117
|
columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
|
|
104
118
|
columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
|
|
105
119
|
columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
|
|
106
|
-
statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
|
|
120
|
+
statements.push(`CREATE TABLE IF NOT EXISTS ${quoteIdent(name)} (
|
|
107
121
|
${columns.join(",\n ")}
|
|
108
122
|
)`);
|
|
109
123
|
for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
|
|
110
124
|
const sqlType = fieldTypeToSql(descriptor, dialect);
|
|
111
|
-
let colDef = `${fieldName} ${sqlType}`;
|
|
125
|
+
let colDef = `${quoteIdent(fieldName)} ${sqlType}`;
|
|
112
126
|
if (descriptor.defaultValue !== void 0) {
|
|
113
127
|
colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
|
|
114
128
|
}
|
|
115
129
|
statements.push(`--kora:safe-alter
|
|
116
|
-
ALTER TABLE ${name} ADD COLUMN ${colDef}`);
|
|
130
|
+
ALTER TABLE ${quoteIdent(name)} ADD COLUMN ${colDef}`);
|
|
117
131
|
}
|
|
118
132
|
for (const indexField of collection.indexes) {
|
|
119
133
|
statements.push(
|
|
120
|
-
`CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
|
|
134
|
+
`CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${quoteIdent(name)} (${quoteIdent(indexField)})`
|
|
121
135
|
);
|
|
122
136
|
}
|
|
123
|
-
statements.push(
|
|
137
|
+
statements.push(
|
|
138
|
+
`CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${quoteIdent(name)} (_deleted)`
|
|
139
|
+
);
|
|
124
140
|
return statements;
|
|
125
141
|
}
|
|
126
142
|
function generateAllCollectionDDL(schema, dialect) {
|
|
@@ -130,30 +146,6 @@ function generateAllCollectionDDL(schema, dialect) {
|
|
|
130
146
|
}
|
|
131
147
|
return statements;
|
|
132
148
|
}
|
|
133
|
-
function replayOperationsForRecord(ops) {
|
|
134
|
-
let record = null;
|
|
135
|
-
let deleted = false;
|
|
136
|
-
for (const op of ops) {
|
|
137
|
-
switch (op.type) {
|
|
138
|
-
case "insert":
|
|
139
|
-
if (op.data) {
|
|
140
|
-
record = { ...op.data };
|
|
141
|
-
deleted = false;
|
|
142
|
-
}
|
|
143
|
-
break;
|
|
144
|
-
case "update":
|
|
145
|
-
if (op.data) {
|
|
146
|
-
record = { ...record ?? {}, ...op.data };
|
|
147
|
-
deleted = false;
|
|
148
|
-
}
|
|
149
|
-
break;
|
|
150
|
-
case "delete":
|
|
151
|
-
deleted = true;
|
|
152
|
-
break;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return deleted ? null : record;
|
|
156
|
-
}
|
|
157
149
|
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
158
150
|
var BASE64_REVERSE = new Map(
|
|
159
151
|
Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
|
|
@@ -204,6 +196,9 @@ function serializeFieldValue(value, descriptor) {
|
|
|
204
196
|
if (value === null || value === void 0) return null;
|
|
205
197
|
switch (descriptor.kind) {
|
|
206
198
|
case "array":
|
|
199
|
+
case "object":
|
|
200
|
+
case "json":
|
|
201
|
+
case "blob":
|
|
207
202
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
208
203
|
case "boolean":
|
|
209
204
|
return value ? 1 : 0;
|
|
@@ -217,6 +212,9 @@ function deserializeFieldValue(value, descriptor) {
|
|
|
217
212
|
if (value === null || value === void 0) return null;
|
|
218
213
|
switch (descriptor.kind) {
|
|
219
214
|
case "array":
|
|
215
|
+
case "object":
|
|
216
|
+
case "json":
|
|
217
|
+
case "blob":
|
|
220
218
|
return typeof value === "string" ? JSON.parse(value) : value;
|
|
221
219
|
case "boolean":
|
|
222
220
|
return value === 1 || value === true;
|
|
@@ -249,6 +247,14 @@ var MemoryServerStore = class {
|
|
|
249
247
|
operations = [];
|
|
250
248
|
operationIndex = /* @__PURE__ */ new Map();
|
|
251
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;
|
|
252
258
|
schema = null;
|
|
253
259
|
/** Materialized records: collection -> recordId -> record data */
|
|
254
260
|
materializedRecords = /* @__PURE__ */ new Map();
|
|
@@ -282,6 +288,8 @@ var MemoryServerStore = class {
|
|
|
282
288
|
}
|
|
283
289
|
this.operations.push(op);
|
|
284
290
|
this.operationIndex.set(op.id, op);
|
|
291
|
+
this.deliverySeqCounter += 1;
|
|
292
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
285
293
|
const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
|
|
286
294
|
if (op.sequenceNumber > currentSeq) {
|
|
287
295
|
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
@@ -301,6 +309,23 @@ var MemoryServerStore = class {
|
|
|
301
309
|
this.assertOpen();
|
|
302
310
|
return this.operations.length;
|
|
303
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
|
+
}
|
|
304
329
|
async materializeCollection(collection) {
|
|
305
330
|
this.assertOpen();
|
|
306
331
|
if (this.schema?.collections[collection]) {
|
|
@@ -445,12 +470,16 @@ var MemoryServerStore = class {
|
|
|
445
470
|
this.operations.length = 0;
|
|
446
471
|
this.operationIndex.clear();
|
|
447
472
|
this.versionVector.clear();
|
|
473
|
+
this.deliverySeqByOpId.clear();
|
|
474
|
+
this.deliverySeqCounter = 0;
|
|
448
475
|
for (const [nid, seq] of versionVector) {
|
|
449
476
|
this.versionVector.set(nid, seq);
|
|
450
477
|
}
|
|
451
478
|
for (const op of operations2) {
|
|
452
479
|
this.operations.push(op);
|
|
453
480
|
this.operationIndex.set(op.id, op);
|
|
481
|
+
this.deliverySeqCounter += 1;
|
|
482
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
454
483
|
if (this.schema?.collections[op.collection]) {
|
|
455
484
|
this.rebuildMaterializedRecord(op.collection, op.recordId);
|
|
456
485
|
}
|
|
@@ -480,11 +509,12 @@ var MemoryServerStore = class {
|
|
|
480
509
|
return a.timestamp.wallTime - b.timestamp.wallTime;
|
|
481
510
|
if (a.timestamp.logical !== b.timestamp.logical)
|
|
482
511
|
return a.timestamp.logical - b.timestamp.logical;
|
|
483
|
-
return a.
|
|
512
|
+
return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
|
|
484
513
|
});
|
|
485
514
|
const parsedOps = recordOps.map((op) => ({
|
|
486
515
|
type: op.type,
|
|
487
|
-
data: op.data
|
|
516
|
+
data: op.data,
|
|
517
|
+
atomicOps: op.atomicOps ?? null
|
|
488
518
|
}));
|
|
489
519
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
490
520
|
if (recordData) {
|
|
@@ -590,8 +620,8 @@ var MemoryServerStore = class {
|
|
|
590
620
|
};
|
|
591
621
|
|
|
592
622
|
// src/store/postgres-server-store.ts
|
|
593
|
-
import { generateUUIDv7 as generateUUIDv72 } from "@korajs/core";
|
|
594
|
-
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";
|
|
595
625
|
|
|
596
626
|
// src/store/drizzle-pg-schema.ts
|
|
597
627
|
import { bigint, index, integer, pgTable, text } from "drizzle-orm/pg-core";
|
|
@@ -607,6 +637,8 @@ var pgOperations = pgTable(
|
|
|
607
637
|
// JSON-serialized, null for deletes
|
|
608
638
|
previousData: text("previous_data"),
|
|
609
639
|
// JSON-serialized, null for insert/delete
|
|
640
|
+
atomicOps: text("atomic_ops"),
|
|
641
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
610
642
|
wallTime: bigint("wall_time", { mode: "number" }).notNull(),
|
|
611
643
|
logical: integer("logical").notNull(),
|
|
612
644
|
timestampNodeId: text("timestamp_node_id").notNull(),
|
|
@@ -614,12 +646,18 @@ var pgOperations = pgTable(
|
|
|
614
646
|
causalDeps: text("causal_deps").notNull().default("[]"),
|
|
615
647
|
// JSON array of op IDs
|
|
616
648
|
schemaVersion: integer("schema_version").notNull(),
|
|
617
|
-
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" })
|
|
618
655
|
},
|
|
619
656
|
(table) => ({
|
|
620
657
|
nodeSeqIdx: index("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
621
658
|
collectionIdx: index("idx_pg_collection").on(table.collection),
|
|
622
|
-
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)
|
|
623
661
|
})
|
|
624
662
|
);
|
|
625
663
|
var pgSyncState = pgTable("sync_state", {
|
|
@@ -636,15 +674,43 @@ var PostgresServerStore = class {
|
|
|
636
674
|
ready;
|
|
637
675
|
schema = null;
|
|
638
676
|
closed = false;
|
|
639
|
-
|
|
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) {
|
|
640
693
|
this.db = db;
|
|
641
694
|
this.nodeId = nodeId ?? generateUUIDv72();
|
|
695
|
+
this.timeSource = timeSource ?? { now: () => Date.now() };
|
|
642
696
|
this.ready = this.initialize();
|
|
643
697
|
}
|
|
644
698
|
getVersionVector() {
|
|
645
699
|
this.assertOpen();
|
|
646
700
|
return new Map(this.versionVector);
|
|
647
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
|
+
}
|
|
648
714
|
getNodeId() {
|
|
649
715
|
return this.nodeId;
|
|
650
716
|
}
|
|
@@ -682,8 +748,9 @@ var PostgresServerStore = class {
|
|
|
682
748
|
return "duplicate";
|
|
683
749
|
}
|
|
684
750
|
const now = Date.now();
|
|
685
|
-
const row = this.serializeOperation(op, now);
|
|
686
751
|
await this.db.transaction(async (tx) => {
|
|
752
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
753
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
687
754
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
688
755
|
await tx.insert(pgSyncState).values({
|
|
689
756
|
nodeId: op.nodeId,
|
|
@@ -706,6 +773,73 @@ var PostgresServerStore = class {
|
|
|
706
773
|
}
|
|
707
774
|
return "applied";
|
|
708
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
|
+
}
|
|
709
843
|
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
710
844
|
this.assertOpen();
|
|
711
845
|
await this.ready;
|
|
@@ -720,6 +854,23 @@ var PostgresServerStore = class {
|
|
|
720
854
|
const result = await this.db.select({ value: count() }).from(pgOperations);
|
|
721
855
|
return result[0]?.value ?? 0;
|
|
722
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
|
+
}
|
|
723
874
|
async materializeCollection(collection) {
|
|
724
875
|
this.assertOpen();
|
|
725
876
|
await this.ready;
|
|
@@ -754,7 +905,7 @@ var PostgresServerStore = class {
|
|
|
754
905
|
this.assertCollection(collection);
|
|
755
906
|
const schema = this.schema;
|
|
756
907
|
const collectionDef = schema.collections[collection];
|
|
757
|
-
const query = sql`SELECT * FROM ${sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
|
|
908
|
+
const query = sql`SELECT * FROM ${sql.raw(quoteIdent2(collection))} WHERE id = ${id} AND _deleted = 0`;
|
|
758
909
|
const rows = await this.db.execute(query);
|
|
759
910
|
if (rows.length === 0) return null;
|
|
760
911
|
return this.deserializeRow(rows[0], collectionDef);
|
|
@@ -771,7 +922,7 @@ var PostgresServerStore = class {
|
|
|
771
922
|
}
|
|
772
923
|
}
|
|
773
924
|
const whereClause = this.buildWhereClause(where ?? {}, false);
|
|
774
|
-
const query = sql`SELECT COUNT(*) as cnt FROM ${sql.raw(collection)} WHERE ${whereClause}`;
|
|
925
|
+
const query = sql`SELECT COUNT(*) as cnt FROM ${sql.raw(quoteIdent2(collection))} WHERE ${whereClause}`;
|
|
775
926
|
const rows = await this.db.execute(query);
|
|
776
927
|
const cnt = rows[0]?.cnt;
|
|
777
928
|
return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
|
|
@@ -783,7 +934,7 @@ var PostgresServerStore = class {
|
|
|
783
934
|
this.assertOpen();
|
|
784
935
|
await this.ready;
|
|
785
936
|
const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
|
|
786
|
-
const rows = await this.db.select().from(pgOperations).orderBy(asc(pgOperations.
|
|
937
|
+
const rows = await this.db.select().from(pgOperations).orderBy(asc(pgOperations.deliverySeq));
|
|
787
938
|
const operations2 = rows.map((row) => this.deserializeOperation(row));
|
|
788
939
|
return buildServerBackup(this.nodeId, operations2, this.versionVector);
|
|
789
940
|
}
|
|
@@ -798,6 +949,7 @@ var PostgresServerStore = class {
|
|
|
798
949
|
const result = await this.applyRemoteOperation(op);
|
|
799
950
|
if (result === "applied") restored++;
|
|
800
951
|
}
|
|
952
|
+
this.sequenceCounter = null;
|
|
801
953
|
return { operationsRestored: restored, success: true };
|
|
802
954
|
}
|
|
803
955
|
const now = Date.now();
|
|
@@ -807,20 +959,49 @@ var PostgresServerStore = class {
|
|
|
807
959
|
for (const [nid, seq] of versionVector) {
|
|
808
960
|
await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
|
|
809
961
|
}
|
|
962
|
+
let deliverySeq = 0;
|
|
810
963
|
for (const op of operations2) {
|
|
811
|
-
|
|
964
|
+
deliverySeq += 1;
|
|
965
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
812
966
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
813
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
|
+
);
|
|
814
972
|
});
|
|
815
973
|
this.versionVector.clear();
|
|
816
974
|
for (const [nid, seq] of versionVector) {
|
|
817
975
|
this.versionVector.set(nid, seq);
|
|
818
976
|
}
|
|
977
|
+
this.sequenceCounter = null;
|
|
819
978
|
return { operationsRestored: operations2.length, success: true };
|
|
820
979
|
}
|
|
821
980
|
// ---------------------------------------------------------------------------
|
|
822
981
|
// Materialization internals
|
|
823
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
|
+
}
|
|
824
1005
|
/**
|
|
825
1006
|
* Rebuild a single record in the materialized collection table by replaying
|
|
826
1007
|
* all operations for that record.
|
|
@@ -831,15 +1012,17 @@ var PostgresServerStore = class {
|
|
|
831
1012
|
const ops = await txOrDb.select({
|
|
832
1013
|
type: pgOperations.type,
|
|
833
1014
|
data: pgOperations.data,
|
|
1015
|
+
atomicOps: pgOperations.atomicOps,
|
|
834
1016
|
wallTime: pgOperations.wallTime
|
|
835
1017
|
}).from(pgOperations).where(and(eq(pgOperations.collection, collection), eq(pgOperations.recordId, recordId))).orderBy(
|
|
836
1018
|
asc(pgOperations.wallTime),
|
|
837
1019
|
asc(pgOperations.logical),
|
|
838
|
-
asc(pgOperations.
|
|
1020
|
+
asc(pgOperations.timestampNodeId)
|
|
839
1021
|
);
|
|
840
1022
|
const parsedOps = ops.map((op) => ({
|
|
841
1023
|
type: op.type,
|
|
842
|
-
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
|
|
843
1026
|
}));
|
|
844
1027
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
845
1028
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
@@ -858,7 +1041,7 @@ var PostgresServerStore = class {
|
|
|
858
1041
|
);
|
|
859
1042
|
} else {
|
|
860
1043
|
await txOrDb.execute(
|
|
861
|
-
sql`UPDATE ${sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1044
|
+
sql`UPDATE ${sql.raw(quoteIdent2(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
862
1045
|
);
|
|
863
1046
|
}
|
|
864
1047
|
}
|
|
@@ -877,16 +1060,16 @@ var PostgresServerStore = class {
|
|
|
877
1060
|
updatedAt,
|
|
878
1061
|
0
|
|
879
1062
|
];
|
|
880
|
-
const columnsSql = sql.raw(allColumns.join(", "));
|
|
1063
|
+
const columnsSql = sql.raw(allColumns.map((c) => quoteIdent2(c)).join(", "));
|
|
881
1064
|
const valuesSql = sql.join(
|
|
882
1065
|
values.map((v) => sql`${v}`),
|
|
883
1066
|
sql.raw(", ")
|
|
884
1067
|
);
|
|
885
1068
|
const updateSet = sql.raw(
|
|
886
|
-
allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
|
|
1069
|
+
allColumns.slice(1).map((c) => `${quoteIdent2(c)} = excluded.${quoteIdent2(c)}`).join(", ")
|
|
887
1070
|
);
|
|
888
1071
|
await txOrDb.execute(
|
|
889
|
-
sql`INSERT INTO ${sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1072
|
+
sql`INSERT INTO ${sql.raw(quoteIdent2(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
890
1073
|
);
|
|
891
1074
|
}
|
|
892
1075
|
/**
|
|
@@ -908,11 +1091,12 @@ var PostgresServerStore = class {
|
|
|
908
1091
|
recordId: pgOperations.recordId,
|
|
909
1092
|
type: pgOperations.type,
|
|
910
1093
|
data: pgOperations.data,
|
|
1094
|
+
atomicOps: pgOperations.atomicOps,
|
|
911
1095
|
wallTime: pgOperations.wallTime
|
|
912
1096
|
}).from(pgOperations).where(eq(pgOperations.collection, collectionName)).orderBy(
|
|
913
1097
|
asc(pgOperations.wallTime),
|
|
914
1098
|
asc(pgOperations.logical),
|
|
915
|
-
asc(pgOperations.
|
|
1099
|
+
asc(pgOperations.timestampNodeId)
|
|
916
1100
|
);
|
|
917
1101
|
if (allOps.length === 0) return;
|
|
918
1102
|
const grouped = /* @__PURE__ */ new Map();
|
|
@@ -928,7 +1112,8 @@ var PostgresServerStore = class {
|
|
|
928
1112
|
for (const [recordId, recordOps] of grouped) {
|
|
929
1113
|
const parsedOps = recordOps.map((op) => ({
|
|
930
1114
|
type: op.type,
|
|
931
|
-
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
|
|
932
1117
|
}));
|
|
933
1118
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
934
1119
|
if (recordData) {
|
|
@@ -946,7 +1131,7 @@ var PostgresServerStore = class {
|
|
|
946
1131
|
);
|
|
947
1132
|
} else {
|
|
948
1133
|
await this.db.execute(
|
|
949
|
-
sql`INSERT INTO ${sql.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
|
|
1134
|
+
sql`INSERT INTO ${sql.raw(quoteIdent2(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
|
|
950
1135
|
);
|
|
951
1136
|
}
|
|
952
1137
|
}
|
|
@@ -959,10 +1144,12 @@ var PostgresServerStore = class {
|
|
|
959
1144
|
options?.where ?? {},
|
|
960
1145
|
options?.includeDeleted ?? false
|
|
961
1146
|
);
|
|
962
|
-
const parts = [
|
|
1147
|
+
const parts = [
|
|
1148
|
+
sql`SELECT * FROM ${sql.raw(quoteIdent2(collection))} WHERE ${whereClause}`
|
|
1149
|
+
];
|
|
963
1150
|
if (options?.orderBy) {
|
|
964
1151
|
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
965
|
-
parts.push(sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
1152
|
+
parts.push(sql.raw(` ORDER BY ${quoteIdent2(options.orderBy)} ${dir}`));
|
|
966
1153
|
}
|
|
967
1154
|
if (options?.limit !== void 0) {
|
|
968
1155
|
parts.push(sql` LIMIT ${options.limit}`);
|
|
@@ -978,7 +1165,7 @@ var PostgresServerStore = class {
|
|
|
978
1165
|
conditions.push(sql.raw("_deleted = 0"));
|
|
979
1166
|
}
|
|
980
1167
|
for (const [key, value] of Object.entries(where)) {
|
|
981
|
-
conditions.push(sql`${sql.raw(key)} = ${value}`);
|
|
1168
|
+
conditions.push(sql`${sql.raw(quoteIdent2(key))} = ${value}`);
|
|
982
1169
|
}
|
|
983
1170
|
if (conditions.length === 0) {
|
|
984
1171
|
return sql.raw("1 = 1");
|
|
@@ -1051,44 +1238,92 @@ var PostgresServerStore = class {
|
|
|
1051
1238
|
}
|
|
1052
1239
|
}
|
|
1053
1240
|
async ensureTables() {
|
|
1054
|
-
await this.db.
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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`
|
|
1079
1314
|
);
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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);
|
|
1087
1322
|
}
|
|
1088
1323
|
// ---------------------------------------------------------------------------
|
|
1089
1324
|
// Operation serialization
|
|
1090
1325
|
// ---------------------------------------------------------------------------
|
|
1091
|
-
serializeOperation(op, receivedAt) {
|
|
1326
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1092
1327
|
return {
|
|
1093
1328
|
id: op.id,
|
|
1094
1329
|
nodeId: op.nodeId,
|
|
@@ -1097,16 +1332,19 @@ var PostgresServerStore = class {
|
|
|
1097
1332
|
recordId: op.recordId,
|
|
1098
1333
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1099
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,
|
|
1100
1336
|
wallTime: op.timestamp.wallTime,
|
|
1101
1337
|
logical: op.timestamp.logical,
|
|
1102
1338
|
timestampNodeId: op.timestamp.nodeId,
|
|
1103
1339
|
sequenceNumber: op.sequenceNumber,
|
|
1104
1340
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1105
1341
|
schemaVersion: op.schemaVersion,
|
|
1106
|
-
receivedAt
|
|
1342
|
+
receivedAt,
|
|
1343
|
+
deliverySeq
|
|
1107
1344
|
};
|
|
1108
1345
|
}
|
|
1109
1346
|
deserializeOperation(row) {
|
|
1347
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1110
1348
|
return {
|
|
1111
1349
|
id: row.id,
|
|
1112
1350
|
nodeId: row.nodeId,
|
|
@@ -1122,7 +1360,8 @@ var PostgresServerStore = class {
|
|
|
1122
1360
|
},
|
|
1123
1361
|
sequenceNumber: row.sequenceNumber,
|
|
1124
1362
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1125
|
-
schemaVersion: row.schemaVersion
|
|
1363
|
+
schemaVersion: row.schemaVersion,
|
|
1364
|
+
...atomicOps ? { atomicOps } : {}
|
|
1126
1365
|
};
|
|
1127
1366
|
}
|
|
1128
1367
|
// ---------------------------------------------------------------------------
|
|
@@ -1173,8 +1412,8 @@ async function loadPostgresDeps() {
|
|
|
1173
1412
|
|
|
1174
1413
|
// src/store/sqlite-server-store.ts
|
|
1175
1414
|
import { createRequire } from "module";
|
|
1176
|
-
import { generateUUIDv7 as generateUUIDv73 } from "@korajs/core";
|
|
1177
|
-
import { and as and2, asc as asc2, between as between2, count as count2, eq as eq2, sql as sql2 } from "drizzle-orm";
|
|
1415
|
+
import { generateUUIDv7 as generateUUIDv73, quoteIdent as quoteIdent3 } from "@korajs/core";
|
|
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";
|
|
1178
1417
|
|
|
1179
1418
|
// src/store/drizzle-schema.ts
|
|
1180
1419
|
import { index as index2, integer as integer2, sqliteTable, text as text2 } from "drizzle-orm/sqlite-core";
|
|
@@ -1190,6 +1429,8 @@ var operations = sqliteTable(
|
|
|
1190
1429
|
// JSON-serialized, null for deletes
|
|
1191
1430
|
previousData: text2("previous_data"),
|
|
1192
1431
|
// JSON-serialized, null for insert/delete
|
|
1432
|
+
atomicOps: text2("atomic_ops"),
|
|
1433
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
1193
1434
|
wallTime: integer2("wall_time").notNull(),
|
|
1194
1435
|
logical: integer2("logical").notNull(),
|
|
1195
1436
|
timestampNodeId: text2("timestamp_node_id").notNull(),
|
|
@@ -1197,12 +1438,17 @@ var operations = sqliteTable(
|
|
|
1197
1438
|
causalDeps: text2("causal_deps").notNull().default("[]"),
|
|
1198
1439
|
// JSON array of op IDs
|
|
1199
1440
|
schemaVersion: integer2("schema_version").notNull(),
|
|
1200
|
-
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")
|
|
1201
1446
|
},
|
|
1202
1447
|
(table) => ({
|
|
1203
1448
|
nodeSeqIdx: index2("idx_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
1204
1449
|
collectionIdx: index2("idx_collection").on(table.collection),
|
|
1205
|
-
receivedIdx: index2("idx_received").on(table.receivedAt)
|
|
1450
|
+
receivedIdx: index2("idx_received").on(table.receivedAt),
|
|
1451
|
+
deliveryIdx: index2("idx_delivery_seq").on(table.deliverySeq)
|
|
1206
1452
|
})
|
|
1207
1453
|
);
|
|
1208
1454
|
var syncState = sqliteTable("sync_state", {
|
|
@@ -1210,6 +1456,10 @@ var syncState = sqliteTable("sync_state", {
|
|
|
1210
1456
|
maxSequenceNumber: integer2("max_sequence_number").notNull(),
|
|
1211
1457
|
lastSeenAt: integer2("last_seen_at").notNull()
|
|
1212
1458
|
});
|
|
1459
|
+
var deliveryCounter = sqliteTable("delivery_counter", {
|
|
1460
|
+
id: integer2("id").primaryKey(),
|
|
1461
|
+
value: integer2("value").notNull()
|
|
1462
|
+
});
|
|
1213
1463
|
|
|
1214
1464
|
// src/store/sqlite-server-store.ts
|
|
1215
1465
|
var esmRequire = createRequire(import.meta.url);
|
|
@@ -1263,12 +1513,15 @@ var SqliteServerStore = class {
|
|
|
1263
1513
|
async applyRemoteOperation(op) {
|
|
1264
1514
|
this.assertOpen();
|
|
1265
1515
|
const now = Date.now();
|
|
1266
|
-
const row = this.serializeOperation(op, now);
|
|
1267
1516
|
const result = this.db.transaction((tx) => {
|
|
1268
|
-
const
|
|
1269
|
-
|
|
1517
|
+
const existing = tx.all(
|
|
1518
|
+
sql2`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
|
|
1519
|
+
);
|
|
1520
|
+
if (existing.length > 0) {
|
|
1270
1521
|
return "duplicate";
|
|
1271
1522
|
}
|
|
1523
|
+
const row = this.serializeOperation(op, now, this.nextDeliverySeq(tx));
|
|
1524
|
+
tx.insert(operations).values(row).run();
|
|
1272
1525
|
tx.insert(syncState).values({
|
|
1273
1526
|
nodeId: op.nodeId,
|
|
1274
1527
|
maxSequenceNumber: op.sequenceNumber,
|
|
@@ -1297,6 +1550,21 @@ var SqliteServerStore = class {
|
|
|
1297
1550
|
const result = this.db.select({ value: count2() }).from(operations).all();
|
|
1298
1551
|
return result[0]?.value ?? 0;
|
|
1299
1552
|
}
|
|
1553
|
+
async getMaxDeliverySequence() {
|
|
1554
|
+
this.assertOpen();
|
|
1555
|
+
const rows = this.db.all(
|
|
1556
|
+
sql2`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
1557
|
+
);
|
|
1558
|
+
return rows[0]?.m ?? 0;
|
|
1559
|
+
}
|
|
1560
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
1561
|
+
this.assertOpen();
|
|
1562
|
+
const rows = this.db.select().from(operations).where(gt2(operations.deliverySeq, afterDeliverySequence)).orderBy(asc2(operations.deliverySeq)).limit(limit).all();
|
|
1563
|
+
return rows.map((row) => ({
|
|
1564
|
+
operation: this.deserializeOperation(row),
|
|
1565
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
1566
|
+
}));
|
|
1567
|
+
}
|
|
1300
1568
|
async materializeCollection(collection) {
|
|
1301
1569
|
this.assertOpen();
|
|
1302
1570
|
if (this.schema?.collections[collection]) {
|
|
@@ -1328,7 +1596,7 @@ var SqliteServerStore = class {
|
|
|
1328
1596
|
this.assertCollection(collection);
|
|
1329
1597
|
const schema = this.schema;
|
|
1330
1598
|
const collectionDef = schema.collections[collection];
|
|
1331
|
-
const query = sql2`SELECT * FROM ${sql2.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
|
|
1599
|
+
const query = sql2`SELECT * FROM ${sql2.raw(quoteIdent3(collection))} WHERE id = ${id} AND _deleted = 0`;
|
|
1332
1600
|
const rows = this.db.all(query);
|
|
1333
1601
|
if (rows.length === 0) return null;
|
|
1334
1602
|
return this.deserializeRow(rows[0], collectionDef);
|
|
@@ -1344,7 +1612,7 @@ var SqliteServerStore = class {
|
|
|
1344
1612
|
}
|
|
1345
1613
|
}
|
|
1346
1614
|
const whereClause = this.buildWhereClause(where ?? {}, false);
|
|
1347
|
-
const query = sql2`SELECT COUNT(*) as cnt FROM ${sql2.raw(collection)} WHERE ${whereClause}`;
|
|
1615
|
+
const query = sql2`SELECT COUNT(*) as cnt FROM ${sql2.raw(quoteIdent3(collection))} WHERE ${whereClause}`;
|
|
1348
1616
|
const rows = this.db.all(query);
|
|
1349
1617
|
return rows[0]?.cnt ?? 0;
|
|
1350
1618
|
}
|
|
@@ -1354,7 +1622,7 @@ var SqliteServerStore = class {
|
|
|
1354
1622
|
async exportBackup() {
|
|
1355
1623
|
this.assertOpen();
|
|
1356
1624
|
const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
|
|
1357
|
-
const rows = this.db.select().from(operations).all();
|
|
1625
|
+
const rows = this.db.select().from(operations).orderBy(asc2(operations.deliverySeq)).all();
|
|
1358
1626
|
const deserialized = rows.map((row) => this.deserializeOperation(row));
|
|
1359
1627
|
const vv = this.getVersionVector();
|
|
1360
1628
|
return buildServerBackup(this.nodeId, deserialized, vv);
|
|
@@ -1374,13 +1642,17 @@ var SqliteServerStore = class {
|
|
|
1374
1642
|
this.db.transaction((tx) => {
|
|
1375
1643
|
tx.run(sql2.raw("DELETE FROM operations"));
|
|
1376
1644
|
tx.run(sql2.raw("DELETE FROM sync_state"));
|
|
1645
|
+
tx.update(deliveryCounter).set({ value: 0 }).where(eq2(deliveryCounter.id, 1)).run();
|
|
1377
1646
|
for (const [nid, seq] of versionVector) {
|
|
1378
1647
|
tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
|
|
1379
1648
|
}
|
|
1649
|
+
let deliverySeq = 0;
|
|
1380
1650
|
for (const op of ops) {
|
|
1381
|
-
|
|
1651
|
+
deliverySeq += 1;
|
|
1652
|
+
const row = this.serializeOperation(op, Date.now(), deliverySeq);
|
|
1382
1653
|
tx.insert(operations).values(row).run();
|
|
1383
1654
|
}
|
|
1655
|
+
tx.update(deliveryCounter).set({ value: deliverySeq }).where(eq2(deliveryCounter.id, 1)).run();
|
|
1384
1656
|
});
|
|
1385
1657
|
return { operationsRestored: ops.length, success: true };
|
|
1386
1658
|
}
|
|
@@ -1398,11 +1670,13 @@ var SqliteServerStore = class {
|
|
|
1398
1670
|
const ops = txOrDb.select({
|
|
1399
1671
|
type: operations.type,
|
|
1400
1672
|
data: operations.data,
|
|
1673
|
+
atomicOps: operations.atomicOps,
|
|
1401
1674
|
wallTime: operations.wallTime
|
|
1402
|
-
}).from(operations).where(and2(eq2(operations.collection, collection), eq2(operations.recordId, recordId))).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.
|
|
1675
|
+
}).from(operations).where(and2(eq2(operations.collection, collection), eq2(operations.recordId, recordId))).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.timestampNodeId)).all();
|
|
1403
1676
|
const parsedOps = ops.map((op) => ({
|
|
1404
1677
|
type: op.type,
|
|
1405
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1678
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1679
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1406
1680
|
}));
|
|
1407
1681
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
1408
1682
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
@@ -1421,7 +1695,7 @@ var SqliteServerStore = class {
|
|
|
1421
1695
|
);
|
|
1422
1696
|
} else {
|
|
1423
1697
|
txOrDb.run(
|
|
1424
|
-
sql2`UPDATE ${sql2.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1698
|
+
sql2`UPDATE ${sql2.raw(quoteIdent3(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1425
1699
|
);
|
|
1426
1700
|
}
|
|
1427
1701
|
}
|
|
@@ -1442,16 +1716,16 @@ var SqliteServerStore = class {
|
|
|
1442
1716
|
0
|
|
1443
1717
|
// _deleted = false
|
|
1444
1718
|
];
|
|
1445
|
-
const columnsSql = sql2.raw(allColumns.join(", "));
|
|
1719
|
+
const columnsSql = sql2.raw(allColumns.map((c) => quoteIdent3(c)).join(", "));
|
|
1446
1720
|
const valuesSql = sql2.join(
|
|
1447
1721
|
values.map((v) => sql2`${v}`),
|
|
1448
1722
|
sql2.raw(", ")
|
|
1449
1723
|
);
|
|
1450
1724
|
const updateSet = sql2.raw(
|
|
1451
|
-
allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
|
|
1725
|
+
allColumns.slice(1).map((c) => `${quoteIdent3(c)} = excluded.${quoteIdent3(c)}`).join(", ")
|
|
1452
1726
|
);
|
|
1453
1727
|
txOrDb.run(
|
|
1454
|
-
sql2`INSERT INTO ${sql2.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1728
|
+
sql2`INSERT INTO ${sql2.raw(quoteIdent3(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1455
1729
|
);
|
|
1456
1730
|
}
|
|
1457
1731
|
/**
|
|
@@ -1474,8 +1748,9 @@ var SqliteServerStore = class {
|
|
|
1474
1748
|
recordId: operations.recordId,
|
|
1475
1749
|
type: operations.type,
|
|
1476
1750
|
data: operations.data,
|
|
1751
|
+
atomicOps: operations.atomicOps,
|
|
1477
1752
|
wallTime: operations.wallTime
|
|
1478
|
-
}).from(operations).where(eq2(operations.collection, collectionName)).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.
|
|
1753
|
+
}).from(operations).where(eq2(operations.collection, collectionName)).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.timestampNodeId)).all();
|
|
1479
1754
|
if (allOps.length === 0) return;
|
|
1480
1755
|
const grouped = /* @__PURE__ */ new Map();
|
|
1481
1756
|
for (const op of allOps) {
|
|
@@ -1491,7 +1766,8 @@ var SqliteServerStore = class {
|
|
|
1491
1766
|
for (const [recordId, recordOps] of grouped) {
|
|
1492
1767
|
const parsedOps = recordOps.map((op) => ({
|
|
1493
1768
|
type: op.type,
|
|
1494
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1769
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1770
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1495
1771
|
}));
|
|
1496
1772
|
const recordData = replayOperationsForRecord(parsedOps);
|
|
1497
1773
|
if (recordData) {
|
|
@@ -1509,7 +1785,7 @@ var SqliteServerStore = class {
|
|
|
1509
1785
|
);
|
|
1510
1786
|
} else {
|
|
1511
1787
|
tx.run(
|
|
1512
|
-
sql2`INSERT INTO ${sql2.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
|
|
1788
|
+
sql2`INSERT INTO ${sql2.raw(quoteIdent3(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
|
|
1513
1789
|
);
|
|
1514
1790
|
}
|
|
1515
1791
|
}
|
|
@@ -1523,10 +1799,12 @@ var SqliteServerStore = class {
|
|
|
1523
1799
|
options?.where ?? {},
|
|
1524
1800
|
options?.includeDeleted ?? false
|
|
1525
1801
|
);
|
|
1526
|
-
const parts = [
|
|
1802
|
+
const parts = [
|
|
1803
|
+
sql2`SELECT * FROM ${sql2.raw(quoteIdent3(collection))} WHERE ${whereClause}`
|
|
1804
|
+
];
|
|
1527
1805
|
if (options?.orderBy) {
|
|
1528
1806
|
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
1529
|
-
parts.push(sql2.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
1807
|
+
parts.push(sql2.raw(` ORDER BY ${quoteIdent3(options.orderBy)} ${dir}`));
|
|
1530
1808
|
}
|
|
1531
1809
|
if (options?.limit !== void 0) {
|
|
1532
1810
|
parts.push(sql2` LIMIT ${options.limit}`);
|
|
@@ -1542,7 +1820,7 @@ var SqliteServerStore = class {
|
|
|
1542
1820
|
conditions.push(sql2.raw("_deleted = 0"));
|
|
1543
1821
|
}
|
|
1544
1822
|
for (const [key, value] of Object.entries(where)) {
|
|
1545
|
-
conditions.push(sql2`${sql2.raw(key)} = ${value}`);
|
|
1823
|
+
conditions.push(sql2`${sql2.raw(quoteIdent3(key))} = ${value}`);
|
|
1546
1824
|
}
|
|
1547
1825
|
if (conditions.length === 0) {
|
|
1548
1826
|
return sql2.raw("1 = 1");
|
|
@@ -1613,6 +1891,7 @@ var SqliteServerStore = class {
|
|
|
1613
1891
|
record_id TEXT NOT NULL,
|
|
1614
1892
|
data TEXT,
|
|
1615
1893
|
previous_data TEXT,
|
|
1894
|
+
atomic_ops TEXT,
|
|
1616
1895
|
wall_time INTEGER NOT NULL,
|
|
1617
1896
|
logical INTEGER NOT NULL,
|
|
1618
1897
|
timestamp_node_id TEXT NOT NULL,
|
|
@@ -1622,9 +1901,35 @@ var SqliteServerStore = class {
|
|
|
1622
1901
|
received_at INTEGER NOT NULL
|
|
1623
1902
|
)
|
|
1624
1903
|
`);
|
|
1904
|
+
try {
|
|
1905
|
+
this.db.run(sql2`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
|
|
1906
|
+
} catch (e) {
|
|
1907
|
+
const msg = e instanceof Error ? e.message : "";
|
|
1908
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
1909
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
1910
|
+
throw e;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
try {
|
|
1914
|
+
this.db.run(sql2`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
|
|
1915
|
+
} catch (e) {
|
|
1916
|
+
const msg = e instanceof Error ? e.message : "";
|
|
1917
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
1918
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
1919
|
+
throw e;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1625
1922
|
this.db.run(sql2`
|
|
1626
1923
|
CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
|
|
1627
1924
|
`);
|
|
1925
|
+
this.db.run(sql2`
|
|
1926
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
|
|
1927
|
+
`);
|
|
1928
|
+
this.db.run(sql2`
|
|
1929
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_seq_unique
|
|
1930
|
+
ON operations (delivery_seq)
|
|
1931
|
+
WHERE delivery_seq IS NOT NULL
|
|
1932
|
+
`);
|
|
1628
1933
|
this.db.run(sql2`
|
|
1629
1934
|
CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
|
|
1630
1935
|
`);
|
|
@@ -1641,11 +1946,60 @@ var SqliteServerStore = class {
|
|
|
1641
1946
|
last_seen_at INTEGER NOT NULL
|
|
1642
1947
|
)
|
|
1643
1948
|
`);
|
|
1949
|
+
this.db.run(sql2`
|
|
1950
|
+
CREATE TABLE IF NOT EXISTS delivery_counter (
|
|
1951
|
+
id INTEGER PRIMARY KEY,
|
|
1952
|
+
value INTEGER NOT NULL
|
|
1953
|
+
)
|
|
1954
|
+
`);
|
|
1955
|
+
this.backfillDeliverySequence();
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* Assign delivery sequences to any operations written before the column existed,
|
|
1959
|
+
* then seed the durable counter from the current max. Ordered by
|
|
1960
|
+
* (received_at, sequence_number, id) so the backfill is deterministic and stable
|
|
1961
|
+
* across restarts. A one-time migration: after it runs, all rows have a value.
|
|
1962
|
+
*/
|
|
1963
|
+
backfillDeliverySequence() {
|
|
1964
|
+
this.db.transaction((tx) => {
|
|
1965
|
+
const maxRow = tx.all(
|
|
1966
|
+
sql2`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
1967
|
+
);
|
|
1968
|
+
let next = maxRow[0]?.m ?? 0;
|
|
1969
|
+
const nullRows = tx.all(
|
|
1970
|
+
sql2`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
|
|
1971
|
+
);
|
|
1972
|
+
for (const row of nullRows) {
|
|
1973
|
+
next += 1;
|
|
1974
|
+
tx.run(sql2`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
|
|
1975
|
+
}
|
|
1976
|
+
tx.insert(deliveryCounter).values({ id: 1, value: next }).onConflictDoUpdate({
|
|
1977
|
+
target: deliveryCounter.id,
|
|
1978
|
+
set: {
|
|
1979
|
+
value: sql2`MAX(${deliveryCounter.value}, ${next})`
|
|
1980
|
+
}
|
|
1981
|
+
}).run();
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Reserve the next delivery sequence inside the append transaction. SQLite locks
|
|
1986
|
+
* the database for the write transaction, and this counter row keeps sequence
|
|
1987
|
+
* allocation shared by every SqliteServerStore instance using the same file.
|
|
1988
|
+
*/
|
|
1989
|
+
nextDeliverySeq(tx) {
|
|
1990
|
+
const rows = tx.update(deliveryCounter).set({ value: sql2`${deliveryCounter.value} + 1` }).where(eq2(deliveryCounter.id, 1)).returning({ value: deliveryCounter.value }).all();
|
|
1991
|
+
const value = rows[0]?.value;
|
|
1992
|
+
if (value === void 0 || value === null) {
|
|
1993
|
+
throw new Error(
|
|
1994
|
+
"delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
return value;
|
|
1644
1998
|
}
|
|
1645
1999
|
// ---------------------------------------------------------------------------
|
|
1646
2000
|
// Operation serialization
|
|
1647
2001
|
// ---------------------------------------------------------------------------
|
|
1648
|
-
serializeOperation(op, receivedAt) {
|
|
2002
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1649
2003
|
return {
|
|
1650
2004
|
id: op.id,
|
|
1651
2005
|
nodeId: op.nodeId,
|
|
@@ -1654,16 +2008,19 @@ var SqliteServerStore = class {
|
|
|
1654
2008
|
recordId: op.recordId,
|
|
1655
2009
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1656
2010
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
2011
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1657
2012
|
wallTime: op.timestamp.wallTime,
|
|
1658
2013
|
logical: op.timestamp.logical,
|
|
1659
2014
|
timestampNodeId: op.timestamp.nodeId,
|
|
1660
2015
|
sequenceNumber: op.sequenceNumber,
|
|
1661
2016
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1662
2017
|
schemaVersion: op.schemaVersion,
|
|
1663
|
-
receivedAt
|
|
2018
|
+
receivedAt,
|
|
2019
|
+
deliverySeq
|
|
1664
2020
|
};
|
|
1665
2021
|
}
|
|
1666
2022
|
deserializeOperation(row) {
|
|
2023
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1667
2024
|
return {
|
|
1668
2025
|
id: row.id,
|
|
1669
2026
|
nodeId: row.nodeId,
|
|
@@ -1679,7 +2036,8 @@ var SqliteServerStore = class {
|
|
|
1679
2036
|
},
|
|
1680
2037
|
sequenceNumber: row.sequenceNumber,
|
|
1681
2038
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1682
|
-
schemaVersion: row.schemaVersion
|
|
2039
|
+
schemaVersion: row.schemaVersion,
|
|
2040
|
+
...atomicOps ? { atomicOps } : {}
|
|
1683
2041
|
};
|
|
1684
2042
|
}
|
|
1685
2043
|
// ---------------------------------------------------------------------------
|
|
@@ -1861,6 +2219,7 @@ var WsServerTransport = class {
|
|
|
1861
2219
|
};
|
|
1862
2220
|
|
|
1863
2221
|
// src/session/client-session.ts
|
|
2222
|
+
import { applyOperationTransforms } from "@korajs/core";
|
|
1864
2223
|
import { generateUUIDv7 as generateUUIDv74, hashBlob } from "@korajs/core";
|
|
1865
2224
|
import { topologicalSort } from "@korajs/core/internal";
|
|
1866
2225
|
import { decodeBlobChunkBytes } from "@korajs/sync";
|
|
@@ -1961,10 +2320,10 @@ function createServerReferentialContext(store) {
|
|
|
1961
2320
|
}
|
|
1962
2321
|
|
|
1963
2322
|
// src/apply/server-side-effect-operation.ts
|
|
1964
|
-
import { HybridLogicalClock, createOperation } from "@korajs/core";
|
|
2323
|
+
import { HybridLogicalClock as HybridLogicalClock2, createOperation } from "@korajs/core";
|
|
1965
2324
|
async function createServerSideEffectOperation(store, parentOp, effect, schemaVersion, sequenceNumber) {
|
|
1966
2325
|
const nodeId = store.getNodeId();
|
|
1967
|
-
const clock = new
|
|
2326
|
+
const clock = new HybridLogicalClock2(nodeId);
|
|
1968
2327
|
clock.receive(parentOp.timestamp);
|
|
1969
2328
|
return createOperation(
|
|
1970
2329
|
{
|
|
@@ -1982,6 +2341,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
|
|
|
1982
2341
|
);
|
|
1983
2342
|
}
|
|
1984
2343
|
function nextServerSequenceNumber(store) {
|
|
2344
|
+
if (store.reserveSequenceNumber) {
|
|
2345
|
+
return store.reserveSequenceNumber();
|
|
2346
|
+
}
|
|
1985
2347
|
const nodeId = store.getNodeId();
|
|
1986
2348
|
const current = store.getVersionVector().get(nodeId) ?? 0;
|
|
1987
2349
|
return current + 1;
|
|
@@ -1991,14 +2353,28 @@ function nextServerSequenceNumber(store) {
|
|
|
1991
2353
|
async function applyServerOperation(store, op, relationLookup) {
|
|
1992
2354
|
const schema = store.getSchema();
|
|
1993
2355
|
const lookup = relationLookup ?? (schema ? buildMergeRelationLookup(schema) : /* @__PURE__ */ new Map());
|
|
2356
|
+
const shapeCheck = validateOperationShape(op, schema);
|
|
2357
|
+
if (!shapeCheck.valid) {
|
|
2358
|
+
return {
|
|
2359
|
+
result: "skipped",
|
|
2360
|
+
appliedOperations: [],
|
|
2361
|
+
rejection: {
|
|
2362
|
+
code: shapeCheck.code ?? "SCHEMA_VALIDATION_ERROR",
|
|
2363
|
+
message: shapeCheck.message ?? `Operation "${op.id}" does not match the server schema`,
|
|
2364
|
+
retriable: false
|
|
2365
|
+
}
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
1994
2368
|
const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
|
|
1995
2369
|
if (!constraintCheck.valid) {
|
|
2370
|
+
const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
|
|
1996
2371
|
return {
|
|
1997
2372
|
result: "skipped",
|
|
1998
2373
|
appliedOperations: [],
|
|
1999
2374
|
rejection: {
|
|
2000
|
-
code
|
|
2001
|
-
message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint
|
|
2375
|
+
code,
|
|
2376
|
+
message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`,
|
|
2377
|
+
retriable: isRetriableRejection(code)
|
|
2002
2378
|
}
|
|
2003
2379
|
};
|
|
2004
2380
|
}
|
|
@@ -2011,7 +2387,8 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2011
2387
|
appliedOperations: [],
|
|
2012
2388
|
rejection: {
|
|
2013
2389
|
code: "REFERENTIAL_INTEGRITY",
|
|
2014
|
-
message: `Operation "${op.id}" violates referential integrity on "${op.collection}"
|
|
2390
|
+
message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`,
|
|
2391
|
+
retriable: isRetriableRejection("REFERENTIAL_INTEGRITY")
|
|
2015
2392
|
}
|
|
2016
2393
|
};
|
|
2017
2394
|
}
|
|
@@ -2020,16 +2397,14 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2020
2397
|
return { result: primaryResult, appliedOperations: [] };
|
|
2021
2398
|
}
|
|
2022
2399
|
const appliedOperations = [op];
|
|
2023
|
-
let serverSeq = nextServerSequenceNumber(store);
|
|
2024
2400
|
for (const effect of referential.sideEffectOps) {
|
|
2025
2401
|
const sideOp = await createServerSideEffectOperation(
|
|
2026
2402
|
store,
|
|
2027
2403
|
op,
|
|
2028
2404
|
effect,
|
|
2029
2405
|
op.schemaVersion,
|
|
2030
|
-
|
|
2406
|
+
nextServerSequenceNumber(store)
|
|
2031
2407
|
);
|
|
2032
|
-
serverSeq += 1;
|
|
2033
2408
|
const sideResult = await store.applyRemoteOperation(sideOp);
|
|
2034
2409
|
if (sideResult === "applied") {
|
|
2035
2410
|
appliedOperations.push(sideOp);
|
|
@@ -2043,6 +2418,67 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2043
2418
|
appliedOperations: result === "applied" ? [op] : []
|
|
2044
2419
|
};
|
|
2045
2420
|
}
|
|
2421
|
+
function validateOperationShape(op, schema) {
|
|
2422
|
+
if (!schema) {
|
|
2423
|
+
return { valid: true };
|
|
2424
|
+
}
|
|
2425
|
+
const collection = schema.collections[op.collection];
|
|
2426
|
+
if (!collection) {
|
|
2427
|
+
return {
|
|
2428
|
+
valid: false,
|
|
2429
|
+
code: "UNKNOWN_COLLECTION",
|
|
2430
|
+
message: `Operation "${op.id}" targets unknown collection "${op.collection}".`
|
|
2431
|
+
};
|
|
2432
|
+
}
|
|
2433
|
+
const declaredFields = new Set(Object.keys(collection.fields));
|
|
2434
|
+
const systemPreviousFields = /* @__PURE__ */ new Set(["id", "_created_at", "_updated_at", "_deleted"]);
|
|
2435
|
+
const invalidDataField = firstInvalidField(op.data, declaredFields);
|
|
2436
|
+
if (invalidDataField) {
|
|
2437
|
+
return {
|
|
2438
|
+
valid: false,
|
|
2439
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2440
|
+
message: `Operation "${op.id}" contains undeclared field "${invalidDataField}" in data for collection "${op.collection}".`
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
const invalidPreviousField = firstInvalidField(
|
|
2444
|
+
op.previousData,
|
|
2445
|
+
declaredFields,
|
|
2446
|
+
systemPreviousFields
|
|
2447
|
+
);
|
|
2448
|
+
if (invalidPreviousField) {
|
|
2449
|
+
return {
|
|
2450
|
+
valid: false,
|
|
2451
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2452
|
+
message: `Operation "${op.id}" contains undeclared field "${invalidPreviousField}" in previousData for collection "${op.collection}".`
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
if (op.atomicOps) {
|
|
2456
|
+
for (const field of Object.keys(op.atomicOps)) {
|
|
2457
|
+
if (!declaredFields.has(field)) {
|
|
2458
|
+
return {
|
|
2459
|
+
valid: false,
|
|
2460
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2461
|
+
message: `Operation "${op.id}" contains undeclared atomic field "${field}" for collection "${op.collection}".`
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
return { valid: true };
|
|
2467
|
+
}
|
|
2468
|
+
function firstInvalidField(value, declaredFields, extraAllowedFields) {
|
|
2469
|
+
if (value === null || value === void 0) {
|
|
2470
|
+
return null;
|
|
2471
|
+
}
|
|
2472
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
2473
|
+
return null;
|
|
2474
|
+
}
|
|
2475
|
+
for (const field of Object.keys(value)) {
|
|
2476
|
+
if (!declaredFields.has(field) && !extraAllowedFields?.has(field)) {
|
|
2477
|
+
return field;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
return null;
|
|
2481
|
+
}
|
|
2046
2482
|
|
|
2047
2483
|
// src/auth/no-auth.ts
|
|
2048
2484
|
var NoAuthProvider = class {
|
|
@@ -2087,24 +2523,74 @@ function mergeScopeMaps(base, overlay, source) {
|
|
|
2087
2523
|
}
|
|
2088
2524
|
|
|
2089
2525
|
// src/scopes/server-scope-filter.ts
|
|
2090
|
-
|
|
2526
|
+
var DEFAULT_MAX_SCOPE_PREDICATE_VALUES = 100;
|
|
2527
|
+
function normalizeScopeMap(scopes, maxValues = DEFAULT_MAX_SCOPE_PREDICATE_VALUES) {
|
|
2528
|
+
const normalized = {};
|
|
2529
|
+
for (const collection of Object.keys(scopes).sort()) {
|
|
2530
|
+
const predicate = {};
|
|
2531
|
+
for (const field of Object.keys(scopes[collection] ?? {}).sort()) {
|
|
2532
|
+
const expected = scopes[collection]?.[field];
|
|
2533
|
+
if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
|
|
2534
|
+
const values = expected.$in;
|
|
2535
|
+
if (!Array.isArray(values))
|
|
2536
|
+
throw new Error(`Invalid $in predicate for ${collection}.${field}`);
|
|
2537
|
+
const unique = [...new Map(values.map((value) => [stableValueKey(value), value])).values()];
|
|
2538
|
+
if (unique.length > maxValues)
|
|
2539
|
+
throw new Error(
|
|
2540
|
+
`Scope predicate for ${collection}.${field} exceeds the ${maxValues}-value limit`
|
|
2541
|
+
);
|
|
2542
|
+
predicate[field] = {
|
|
2543
|
+
$in: unique.sort((a, b) => stableValueKey(a).localeCompare(stableValueKey(b)))
|
|
2544
|
+
};
|
|
2545
|
+
} else predicate[field] = expected;
|
|
2546
|
+
}
|
|
2547
|
+
normalized[collection] = predicate;
|
|
2548
|
+
}
|
|
2549
|
+
return normalized;
|
|
2550
|
+
}
|
|
2551
|
+
function stableValueKey(value) {
|
|
2552
|
+
if (Array.isArray(value)) return `[${value.map(stableValueKey).join(",")}]`;
|
|
2553
|
+
if (value && typeof value === "object")
|
|
2554
|
+
return `{${Object.keys(value).sort().map(
|
|
2555
|
+
(key) => `${JSON.stringify(key)}:${stableValueKey(value[key])}`
|
|
2556
|
+
).join(",")}}`;
|
|
2557
|
+
return JSON.stringify(value);
|
|
2558
|
+
}
|
|
2559
|
+
function operationMatchesScopes(op, scopes, fullRecord) {
|
|
2091
2560
|
if (!scopes) return true;
|
|
2092
2561
|
const collectionScope = scopes[op.collection];
|
|
2093
2562
|
if (!collectionScope) return false;
|
|
2094
|
-
|
|
2563
|
+
if (Object.keys(collectionScope).length === 0) return true;
|
|
2564
|
+
const snapshot = buildSnapshot(op, fullRecord);
|
|
2095
2565
|
if (!snapshot) return false;
|
|
2096
2566
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
2097
|
-
if (snapshot[field]
|
|
2567
|
+
if (!matchesPredicate(snapshot[field], expected)) {
|
|
2098
2568
|
return false;
|
|
2099
2569
|
}
|
|
2100
2570
|
}
|
|
2101
2571
|
return true;
|
|
2102
2572
|
}
|
|
2103
|
-
function
|
|
2573
|
+
function matchesPredicate(actual, expected) {
|
|
2574
|
+
if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
|
|
2575
|
+
const values = expected.$in;
|
|
2576
|
+
return Array.isArray(values) && values.some((value) => Object.is(actual, value));
|
|
2577
|
+
}
|
|
2578
|
+
return Object.is(actual, expected);
|
|
2579
|
+
}
|
|
2580
|
+
function missingScopeFields(op, scopes) {
|
|
2581
|
+
if (!scopes) return [];
|
|
2582
|
+
const collectionScope = scopes[op.collection];
|
|
2583
|
+
if (!collectionScope) return [];
|
|
2584
|
+
if (Object.keys(collectionScope).length === 0) return [];
|
|
2585
|
+
const snapshot = buildSnapshot(op);
|
|
2586
|
+
return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
|
|
2587
|
+
}
|
|
2588
|
+
function buildSnapshot(op, fullRecord) {
|
|
2104
2589
|
const previous = asRecord(op.previousData);
|
|
2105
2590
|
const next = asRecord(op.data);
|
|
2106
|
-
if (!previous && !next) return null;
|
|
2591
|
+
if (!previous && !next && !fullRecord) return null;
|
|
2107
2592
|
return {
|
|
2593
|
+
...fullRecord ?? {},
|
|
2108
2594
|
...previous ?? {},
|
|
2109
2595
|
...next ?? {}
|
|
2110
2596
|
};
|
|
@@ -2195,21 +2681,74 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
|
|
|
2195
2681
|
"[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.)"
|
|
2196
2682
|
);
|
|
2197
2683
|
}
|
|
2198
|
-
|
|
2684
|
+
function stableStringify(value) {
|
|
2685
|
+
if (Array.isArray(value)) {
|
|
2686
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
2687
|
+
}
|
|
2688
|
+
if (value && typeof value === "object") {
|
|
2689
|
+
const record = value;
|
|
2690
|
+
const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
|
|
2691
|
+
return `{${entries.join(",")}}`;
|
|
2692
|
+
}
|
|
2693
|
+
return JSON.stringify(value);
|
|
2694
|
+
}
|
|
2695
|
+
function sameScopeMap(a, b) {
|
|
2696
|
+
const normalize = (value) => value && typeof value === "object" ? normalizeScopeMap(value) : value;
|
|
2697
|
+
return stableStringify(normalize(a) ?? null) === stableStringify(normalize(b) ?? null);
|
|
2698
|
+
}
|
|
2699
|
+
var ClientSession = class _ClientSession {
|
|
2199
2700
|
state = "connected";
|
|
2200
2701
|
clientNodeId = null;
|
|
2201
2702
|
authContext = null;
|
|
2202
2703
|
syncQuerySubsets = [];
|
|
2203
2704
|
resumeDeltaCursor = null;
|
|
2705
|
+
/**
|
|
2706
|
+
* The delivery watermark the client reported at handshake, or null when the client
|
|
2707
|
+
* does not use the delivery watermark (old client, or no watermark yet). When set,
|
|
2708
|
+
* the server drives the gap-free server->client stream from delivery sequences
|
|
2709
|
+
* instead of the version-vector delta.
|
|
2710
|
+
*/
|
|
2711
|
+
clientDeliveryWatermark = null;
|
|
2712
|
+
/**
|
|
2713
|
+
* The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
|
|
2714
|
+
* watermark, as reported in acks). Incremental streaming pushes resume from here, not
|
|
2715
|
+
* from the highest sequence sent, so a dropped or unapplied batch is always
|
|
2716
|
+
* re-included by the next push (which re-scans from the acknowledged position). This
|
|
2717
|
+
* is what makes streaming recovery independent of any bounded retransmit buffer: there
|
|
2718
|
+
* is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
|
|
2719
|
+
* client's reported watermark at handshake.
|
|
2720
|
+
*/
|
|
2721
|
+
lastAckedDeliverySeq = 0;
|
|
2722
|
+
/**
|
|
2723
|
+
* Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
|
|
2724
|
+
* this as its stale window so a slow client does not receive duplicate full-stream
|
|
2725
|
+
* batches every server tick while the first batch is still being applied.
|
|
2726
|
+
*/
|
|
2727
|
+
lastDeliveryPushAttemptAtMs = 0;
|
|
2728
|
+
deliveryStallRepeatCount = 0;
|
|
2729
|
+
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
2730
|
+
deliveryPushChain = Promise.resolve();
|
|
2731
|
+
/**
|
|
2732
|
+
* Relay batches sent to this client but not yet acknowledged, keyed by messageId.
|
|
2733
|
+
* The client acks every operation-batch it applies; on ack the entry is cleared.
|
|
2734
|
+
* {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
|
|
2735
|
+
* dropped by a lossy transport is redelivered instead of leaving the client with a
|
|
2736
|
+
* permanent version-vector gap (a lost operation) that delta sync cannot recover.
|
|
2737
|
+
* Bounded so a silent client cannot grow it without limit.
|
|
2738
|
+
*/
|
|
2739
|
+
pendingRelays = /* @__PURE__ */ new Map();
|
|
2740
|
+
static MAX_PENDING_RELAYS = 1e3;
|
|
2204
2741
|
sessionId;
|
|
2205
2742
|
transport;
|
|
2206
2743
|
store;
|
|
2207
2744
|
auth;
|
|
2208
2745
|
serializer;
|
|
2209
2746
|
emitter;
|
|
2747
|
+
logger;
|
|
2210
2748
|
batchSize;
|
|
2211
2749
|
schemaVersion;
|
|
2212
2750
|
supportedSchemaVersions;
|
|
2751
|
+
operationTransforms;
|
|
2213
2752
|
onRelay;
|
|
2214
2753
|
onAwarenessUpdate;
|
|
2215
2754
|
onYjsDocUpdate;
|
|
@@ -2217,9 +2756,13 @@ var ClientSession = class {
|
|
|
2217
2756
|
onBlobChunkResponse;
|
|
2218
2757
|
persistBlobChunk;
|
|
2219
2758
|
onClose;
|
|
2759
|
+
onOrphanedRelays;
|
|
2760
|
+
takeOrphanedRelays;
|
|
2220
2761
|
maxOperationBytes;
|
|
2221
2762
|
maxOpsPerMinute;
|
|
2222
2763
|
rateLimiter;
|
|
2764
|
+
validateOperation;
|
|
2765
|
+
koraContext;
|
|
2223
2766
|
constructor(options) {
|
|
2224
2767
|
this.sessionId = options.sessionId;
|
|
2225
2768
|
this.transport = options.transport;
|
|
@@ -2227,6 +2770,7 @@ var ClientSession = class {
|
|
|
2227
2770
|
this.auth = options.auth ?? null;
|
|
2228
2771
|
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
2229
2772
|
this.emitter = options.emitter ?? null;
|
|
2773
|
+
this.logger = options.logger ?? null;
|
|
2230
2774
|
this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2231
2775
|
this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
2232
2776
|
const supported = options.supportedSchemaVersions;
|
|
@@ -2234,6 +2778,7 @@ var ClientSession = class {
|
|
|
2234
2778
|
min: this.schemaVersion,
|
|
2235
2779
|
max: this.schemaVersion
|
|
2236
2780
|
};
|
|
2781
|
+
this.operationTransforms = options.operationTransforms ?? [];
|
|
2237
2782
|
this.onRelay = options.onRelay ?? null;
|
|
2238
2783
|
this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
|
|
2239
2784
|
this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
|
|
@@ -2241,9 +2786,13 @@ var ClientSession = class {
|
|
|
2241
2786
|
this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
|
|
2242
2787
|
this.persistBlobChunk = options.persistBlobChunk ?? null;
|
|
2243
2788
|
this.onClose = options.onClose ?? null;
|
|
2789
|
+
this.onOrphanedRelays = options.onOrphanedRelays ?? null;
|
|
2790
|
+
this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
|
|
2244
2791
|
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2245
2792
|
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
2246
2793
|
this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
|
|
2794
|
+
this.validateOperation = options.validateOperation ?? null;
|
|
2795
|
+
this.koraContext = options.koraContext ?? null;
|
|
2247
2796
|
}
|
|
2248
2797
|
/**
|
|
2249
2798
|
* Start handling messages from the client transport.
|
|
@@ -2263,9 +2812,41 @@ var ClientSession = class {
|
|
|
2263
2812
|
*/
|
|
2264
2813
|
relayOperations(operations2) {
|
|
2265
2814
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2815
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2816
|
+
this.pushDeliveryStream();
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2266
2819
|
if (operations2.length === 0) return;
|
|
2267
|
-
|
|
2820
|
+
void this.relayVisibleOperations(operations2);
|
|
2821
|
+
}
|
|
2822
|
+
/**
|
|
2823
|
+
* Push newly-available in-scope operations to a delivery-watermark client, resuming
|
|
2824
|
+
* from the highest delivery sequence already sent. Pushes are serialized so their
|
|
2825
|
+
* batches never interleave (which would break the base/max chain).
|
|
2826
|
+
*/
|
|
2827
|
+
pushDeliveryStream() {
|
|
2828
|
+
this.deliveryPushChain = this.deliveryPushChain.then(async () => {
|
|
2829
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2830
|
+
try {
|
|
2831
|
+
this.lastDeliveryPushAttemptAtMs = Date.now();
|
|
2832
|
+
await this.sendDeliveryStream(
|
|
2833
|
+
this.lastAckedDeliverySeq,
|
|
2834
|
+
false,
|
|
2835
|
+
this.clientNodeId ?? void 0
|
|
2836
|
+
);
|
|
2837
|
+
} catch {
|
|
2838
|
+
}
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
async relayVisibleOperations(operations2) {
|
|
2842
|
+
const visibleOperations = [];
|
|
2843
|
+
for (const op of operations2) {
|
|
2844
|
+
if (await this.operationVisibleToClient(op)) {
|
|
2845
|
+
visibleOperations.push(op);
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2268
2848
|
if (visibleOperations.length === 0) return;
|
|
2849
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2269
2850
|
const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
|
|
2270
2851
|
const msg = {
|
|
2271
2852
|
type: "operation-batch",
|
|
@@ -2274,14 +2855,86 @@ var ClientSession = class {
|
|
|
2274
2855
|
isFinal: true,
|
|
2275
2856
|
batchIndex: 0
|
|
2276
2857
|
};
|
|
2858
|
+
this.trackPendingRelay(msg, visibleOperations);
|
|
2277
2859
|
this.sendToClient(msg);
|
|
2278
2860
|
}
|
|
2861
|
+
/** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
|
|
2862
|
+
trackPendingRelay(msg, ops) {
|
|
2863
|
+
if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
|
|
2864
|
+
const oldest = this.pendingRelays.keys().next().value;
|
|
2865
|
+
if (oldest !== void 0) {
|
|
2866
|
+
this.pendingRelays.delete(oldest);
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
|
|
2870
|
+
}
|
|
2871
|
+
/**
|
|
2872
|
+
* Retransmit relay batches this client has not acknowledged within `staleMs`.
|
|
2873
|
+
* Called on a periodic tick by the server (and directly by tests). Redelivering an
|
|
2874
|
+
* already-applied op is harmless: the client dedups by content-addressed id.
|
|
2875
|
+
*/
|
|
2876
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
2877
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2878
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
if (this.pendingRelays.size === 0) return;
|
|
2882
|
+
const cutoff = Date.now() - staleMs;
|
|
2883
|
+
for (const { message, sentAtMs } of this.pendingRelays.values()) {
|
|
2884
|
+
if (sentAtMs <= cutoff) {
|
|
2885
|
+
this.sendToClient(message);
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
/**
|
|
2890
|
+
* Wake the durable delivery stream for clients that negotiated delivery
|
|
2891
|
+
* watermarks. This is used both for dropped watermark batches and for operations
|
|
2892
|
+
* appended by another server/store instance: the session always scans from its
|
|
2893
|
+
* own acknowledged cursor and applies its visibility filter before sending.
|
|
2894
|
+
*/
|
|
2895
|
+
pushDeliveryStreamIfSupported(staleMs = 0, options = {}) {
|
|
2896
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2897
|
+
if (this.clientDeliveryWatermark === null) return;
|
|
2898
|
+
if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
|
|
2899
|
+
return;
|
|
2900
|
+
}
|
|
2901
|
+
if (options.trackStall) {
|
|
2902
|
+
this.deliveryStallRepeatCount += 1;
|
|
2903
|
+
if (this.deliveryStallRepeatCount >= 3) {
|
|
2904
|
+
this.emitter?.emit({
|
|
2905
|
+
type: "sync:delivery-stalled",
|
|
2906
|
+
sessionId: this.sessionId,
|
|
2907
|
+
watermark: this.lastAckedDeliverySeq,
|
|
2908
|
+
repeatCount: this.deliveryStallRepeatCount
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
this.pushDeliveryStream();
|
|
2913
|
+
}
|
|
2279
2914
|
/**
|
|
2280
2915
|
* Close this session.
|
|
2281
2916
|
*/
|
|
2917
|
+
/**
|
|
2918
|
+
* Hand any unacknowledged relays to the server so they survive this session and are
|
|
2919
|
+
* redelivered when this client reconnects (per-node buffer), then clear them. Called
|
|
2920
|
+
* from every session-teardown path (explicit close and transport close).
|
|
2921
|
+
*/
|
|
2922
|
+
flushOrphanedRelays() {
|
|
2923
|
+
if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
|
|
2924
|
+
const ops = [];
|
|
2925
|
+
for (const entry of this.pendingRelays.values()) {
|
|
2926
|
+
ops.push(...entry.ops);
|
|
2927
|
+
}
|
|
2928
|
+
if (ops.length > 0) {
|
|
2929
|
+
this.onOrphanedRelays(this.clientNodeId, ops);
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
this.pendingRelays.clear();
|
|
2933
|
+
}
|
|
2282
2934
|
close(reason) {
|
|
2283
2935
|
if (this.state === "closed") return;
|
|
2284
2936
|
this.state = "closed";
|
|
2937
|
+
this.flushOrphanedRelays();
|
|
2285
2938
|
if (this.transport.isConnected()) {
|
|
2286
2939
|
this.transport.close(1e3, reason ?? "session closed");
|
|
2287
2940
|
}
|
|
@@ -2336,6 +2989,11 @@ var ClientSession = class {
|
|
|
2336
2989
|
await this.handleOperationBatch(message);
|
|
2337
2990
|
break;
|
|
2338
2991
|
case "acknowledgment":
|
|
2992
|
+
this.pendingRelays.delete(message.acknowledgedMessageId);
|
|
2993
|
+
if (message.deliverySequence !== void 0) {
|
|
2994
|
+
this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
|
|
2995
|
+
this.deliveryStallRepeatCount = 0;
|
|
2996
|
+
}
|
|
2339
2997
|
break;
|
|
2340
2998
|
case "error":
|
|
2341
2999
|
break;
|
|
@@ -2374,6 +3032,18 @@ var ClientSession = class {
|
|
|
2374
3032
|
}
|
|
2375
3033
|
handleMessageFailure(error) {
|
|
2376
3034
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
3035
|
+
this.logger?.log({
|
|
3036
|
+
timestamp: Date.now(),
|
|
3037
|
+
level: "error",
|
|
3038
|
+
event: "session.message_failed",
|
|
3039
|
+
sessionId: this.sessionId,
|
|
3040
|
+
nodeId: this.clientNodeId ?? void 0,
|
|
3041
|
+
error: reason,
|
|
3042
|
+
details: {
|
|
3043
|
+
state: this.state,
|
|
3044
|
+
errorName: error instanceof Error ? error.name : typeof error
|
|
3045
|
+
}
|
|
3046
|
+
});
|
|
2377
3047
|
this.sendError("SYNC_ERROR", reason, true);
|
|
2378
3048
|
this.close(reason);
|
|
2379
3049
|
}
|
|
@@ -2394,14 +3064,28 @@ var ClientSession = class {
|
|
|
2394
3064
|
this.authContext = context;
|
|
2395
3065
|
this.state = "authenticated";
|
|
2396
3066
|
}
|
|
2397
|
-
const
|
|
3067
|
+
const rawResolvedScopes = resolveSessionScopes(this.store.getSchema(), {
|
|
2398
3068
|
handshakeScope: msg.syncScope,
|
|
2399
3069
|
authScopes: this.authContext?.scopes
|
|
2400
3070
|
});
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
3071
|
+
let resolvedScopes;
|
|
3072
|
+
try {
|
|
3073
|
+
resolvedScopes = rawResolvedScopes ? normalizeScopeMap(rawResolvedScopes) : void 0;
|
|
3074
|
+
} catch (error) {
|
|
3075
|
+
this.sendToClient({
|
|
3076
|
+
type: "error",
|
|
3077
|
+
messageId: generateUUIDv74(),
|
|
3078
|
+
code: "SCOPE_PREDICATE_LIMIT",
|
|
3079
|
+
message: error instanceof Error ? error.message : "Invalid scope predicate",
|
|
3080
|
+
retriable: false
|
|
3081
|
+
});
|
|
3082
|
+
this.close("invalid scope predicate");
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
3085
|
+
if (resolvedScopes) {
|
|
3086
|
+
if (this.authContext) {
|
|
3087
|
+
this.authContext = { ...this.authContext, scopes: resolvedScopes };
|
|
3088
|
+
} else {
|
|
2405
3089
|
this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
|
|
2406
3090
|
}
|
|
2407
3091
|
}
|
|
@@ -2412,11 +3096,31 @@ var ClientSession = class {
|
|
|
2412
3096
|
this.syncQuerySubsets = [];
|
|
2413
3097
|
}
|
|
2414
3098
|
this.resumeDeltaCursor = msg.deltaCursor ? decodeDeltaCursor(msg.deltaCursor) : null;
|
|
3099
|
+
this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
|
|
3100
|
+
if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
|
|
3101
|
+
this.clientDeliveryWatermark = 0;
|
|
3102
|
+
}
|
|
3103
|
+
const clientUsesWatermark = this.clientDeliveryWatermark !== null;
|
|
3104
|
+
let serverMaxDelivery = 0;
|
|
3105
|
+
if (clientUsesWatermark) {
|
|
3106
|
+
serverMaxDelivery = await this.store.getMaxDeliverySequence();
|
|
3107
|
+
if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
|
|
3108
|
+
this.clientDeliveryWatermark = 0;
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
2415
3111
|
const serverVector = this.store.getVersionVector();
|
|
2416
3112
|
const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
|
|
2417
3113
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
2418
3114
|
if (!isClientSchemaVersionSupported(msg.schemaVersion, this.supportedSchemaVersions)) {
|
|
2419
3115
|
const { min, max } = this.supportedSchemaVersions;
|
|
3116
|
+
this.emitter?.emit({
|
|
3117
|
+
type: "sync:schema-mismatch",
|
|
3118
|
+
clientSchemaVersion: msg.schemaVersion,
|
|
3119
|
+
serverSchemaVersion: this.schemaVersion,
|
|
3120
|
+
supportedMin: min,
|
|
3121
|
+
supportedMax: max,
|
|
3122
|
+
reason: `${SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`
|
|
3123
|
+
});
|
|
2420
3124
|
const response2 = {
|
|
2421
3125
|
type: "handshake-response",
|
|
2422
3126
|
messageId: generateUUIDv74(),
|
|
@@ -2442,6 +3146,10 @@ var ClientSession = class {
|
|
|
2442
3146
|
accepted: true,
|
|
2443
3147
|
selectedWireFormat,
|
|
2444
3148
|
serverTime: Date.now(),
|
|
3149
|
+
// The server's highest delivery sequence, so a client whose persisted watermark
|
|
3150
|
+
// is ahead of it (server rolled back) can reset to a full resync. Sent only to
|
|
3151
|
+
// watermark-using clients.
|
|
3152
|
+
...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
|
|
2445
3153
|
// Advertise central blob storage so the client uploads the bytes behind
|
|
2446
3154
|
// its blob fields, keeping them available after the author goes offline.
|
|
2447
3155
|
...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
|
|
@@ -2452,17 +3160,40 @@ var ClientSession = class {
|
|
|
2452
3160
|
this.sendToClient(response);
|
|
2453
3161
|
this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
|
|
2454
3162
|
this.state = "syncing";
|
|
2455
|
-
|
|
2456
|
-
|
|
3163
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
3164
|
+
this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
|
|
3165
|
+
const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
|
|
3166
|
+
this.lastDeliveryPushAttemptAtMs = Date.now();
|
|
3167
|
+
await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
|
|
3168
|
+
} else {
|
|
3169
|
+
const clientVector = wireToVersionVector(msg.versionVector);
|
|
3170
|
+
await this.sendDelta(clientVector);
|
|
3171
|
+
}
|
|
2457
3172
|
this.state = "streaming";
|
|
3173
|
+
if (this.takeOrphanedRelays && this.clientNodeId) {
|
|
3174
|
+
const buffered = this.takeOrphanedRelays(this.clientNodeId);
|
|
3175
|
+
if (buffered.length > 0) {
|
|
3176
|
+
this.relayOperations(buffered);
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
2458
3179
|
}
|
|
2459
3180
|
async handleOperationBatch(msg) {
|
|
2460
3181
|
const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2461
3182
|
const applied = [];
|
|
2462
|
-
|
|
3183
|
+
let acknowledgedThrough = 0;
|
|
3184
|
+
let canAdvanceAck = true;
|
|
2463
3185
|
for (const op of operations2) {
|
|
2464
|
-
if (!
|
|
2465
|
-
|
|
3186
|
+
if (!canAdvanceAck) {
|
|
3187
|
+
continue;
|
|
3188
|
+
}
|
|
3189
|
+
if (!await this.operationVisibleToClient(op)) {
|
|
3190
|
+
this.sendOperationRejected(
|
|
3191
|
+
op,
|
|
3192
|
+
"SCOPE_VIOLATION",
|
|
3193
|
+
`Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
|
|
3194
|
+
true
|
|
3195
|
+
);
|
|
3196
|
+
canAdvanceAck = false;
|
|
2466
3197
|
continue;
|
|
2467
3198
|
}
|
|
2468
3199
|
if (!isOperationTimestampValid(op)) {
|
|
@@ -2471,6 +3202,7 @@ var ClientSession = class {
|
|
|
2471
3202
|
`Operation "${op.id}" timestamp is too far in the future`,
|
|
2472
3203
|
false
|
|
2473
3204
|
);
|
|
3205
|
+
canAdvanceAck = false;
|
|
2474
3206
|
continue;
|
|
2475
3207
|
}
|
|
2476
3208
|
if (!this.rateLimiter.allow(1)) {
|
|
@@ -2479,6 +3211,7 @@ var ClientSession = class {
|
|
|
2479
3211
|
`Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
|
|
2480
3212
|
true
|
|
2481
3213
|
);
|
|
3214
|
+
canAdvanceAck = false;
|
|
2482
3215
|
continue;
|
|
2483
3216
|
}
|
|
2484
3217
|
const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
|
|
@@ -2488,27 +3221,73 @@ var ClientSession = class {
|
|
|
2488
3221
|
sizeCheck.message ?? `Operation "${op.id}" is too large`,
|
|
2489
3222
|
false
|
|
2490
3223
|
);
|
|
3224
|
+
canAdvanceAck = false;
|
|
2491
3225
|
continue;
|
|
2492
3226
|
}
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
3227
|
+
const serverOp = this.transformForServerSchema(op);
|
|
3228
|
+
if (serverOp === null) {
|
|
3229
|
+
this.sendOperationRejected(
|
|
3230
|
+
op,
|
|
3231
|
+
"SCHEMA_TRANSFORM_UNAVAILABLE",
|
|
3232
|
+
`Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
|
|
3233
|
+
false
|
|
3234
|
+
);
|
|
3235
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3236
|
+
continue;
|
|
3237
|
+
}
|
|
3238
|
+
if (this.validateOperation && this.koraContext) {
|
|
3239
|
+
let decision;
|
|
3240
|
+
try {
|
|
3241
|
+
decision = await this.validateOperation(serverOp, {
|
|
3242
|
+
auth: this.authContext,
|
|
3243
|
+
kora: this.koraContext
|
|
3244
|
+
});
|
|
3245
|
+
} catch (error) {
|
|
3246
|
+
const message = error instanceof Error ? error.message : "validator error";
|
|
3247
|
+
this.sendOperationRejected(
|
|
3248
|
+
serverOp,
|
|
3249
|
+
"VALIDATION_ERROR",
|
|
3250
|
+
`Validator threw: ${message}`,
|
|
3251
|
+
true
|
|
3252
|
+
);
|
|
3253
|
+
canAdvanceAck = false;
|
|
3254
|
+
continue;
|
|
3255
|
+
}
|
|
3256
|
+
if (decision.action === "reject") {
|
|
3257
|
+
const retriable = decision.retriable ?? isRetriableRejection(decision.code);
|
|
3258
|
+
this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
|
|
3259
|
+
if (retriable) {
|
|
3260
|
+
canAdvanceAck = false;
|
|
3261
|
+
} else {
|
|
3262
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3263
|
+
}
|
|
2497
3264
|
continue;
|
|
2498
3265
|
}
|
|
2499
|
-
if (
|
|
2500
|
-
|
|
3266
|
+
if (decision.action === "ignore") {
|
|
3267
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3268
|
+
continue;
|
|
2501
3269
|
}
|
|
2502
|
-
} catch {
|
|
2503
3270
|
}
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
3271
|
+
const applyResult = await applyServerOperation(this.store, serverOp);
|
|
3272
|
+
if (applyResult.rejection) {
|
|
3273
|
+
this.sendOperationRejected(
|
|
3274
|
+
serverOp,
|
|
3275
|
+
applyResult.rejection.code,
|
|
3276
|
+
applyResult.rejection.message,
|
|
3277
|
+
applyResult.rejection.retriable
|
|
2511
3278
|
);
|
|
3279
|
+
if (applyResult.rejection.retriable) {
|
|
3280
|
+
canAdvanceAck = false;
|
|
3281
|
+
} else {
|
|
3282
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3283
|
+
}
|
|
3284
|
+
continue;
|
|
3285
|
+
}
|
|
3286
|
+
if (applyResult.result === "applied") {
|
|
3287
|
+
applied.push(...applyResult.appliedOperations);
|
|
3288
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3289
|
+
} else {
|
|
3290
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
2512
3291
|
}
|
|
2513
3292
|
}
|
|
2514
3293
|
if (operations2.length > 0) {
|
|
@@ -2523,13 +3302,19 @@ var ClientSession = class {
|
|
|
2523
3302
|
type: "acknowledgment",
|
|
2524
3303
|
messageId: generateUUIDv74(),
|
|
2525
3304
|
acknowledgedMessageId: msg.messageId,
|
|
2526
|
-
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3305
|
+
lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
|
|
2527
3306
|
};
|
|
2528
3307
|
this.sendToClient(ack);
|
|
2529
3308
|
if (applied.length > 0) {
|
|
2530
3309
|
this.onRelay?.(this.sessionId, applied);
|
|
2531
3310
|
}
|
|
2532
3311
|
}
|
|
3312
|
+
transformForServerSchema(op) {
|
|
3313
|
+
if (op.schemaVersion === this.schemaVersion) {
|
|
3314
|
+
return op;
|
|
3315
|
+
}
|
|
3316
|
+
return applyOperationTransforms(op, this.schemaVersion, this.operationTransforms);
|
|
3317
|
+
}
|
|
2533
3318
|
async sendDelta(clientVector) {
|
|
2534
3319
|
const serverVector = this.store.getVersionVector();
|
|
2535
3320
|
const missing = [];
|
|
@@ -2537,8 +3322,11 @@ var ClientSession = class {
|
|
|
2537
3322
|
const clientSeq = clientVector.get(nodeId) ?? 0;
|
|
2538
3323
|
if (serverSeq > clientSeq) {
|
|
2539
3324
|
const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
|
|
2540
|
-
const
|
|
2541
|
-
|
|
3325
|
+
for (const op of ops) {
|
|
3326
|
+
if (await this.operationVisibleToClient(op)) {
|
|
3327
|
+
missing.push(op);
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
2542
3330
|
}
|
|
2543
3331
|
}
|
|
2544
3332
|
if (missing.length === 0) {
|
|
@@ -2590,11 +3378,111 @@ var ClientSession = class {
|
|
|
2590
3378
|
});
|
|
2591
3379
|
}
|
|
2592
3380
|
}
|
|
2593
|
-
|
|
2594
|
-
|
|
3381
|
+
/**
|
|
3382
|
+
* Send the gap-free server->client delivery stream, resuming just after
|
|
3383
|
+
* `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
|
|
3384
|
+
* (commit order), scope-filtered for this session, and sent in batches that chain:
|
|
3385
|
+
* each batch's `baseDeliverySequence` equals the previous batch's
|
|
3386
|
+
* `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
|
|
3387
|
+
* applies a batch only when its watermark equals the base and advances the
|
|
3388
|
+
* watermark to the max, so a dropped batch stalls the watermark and is recovered by
|
|
3389
|
+
* the next handshake resend. Because delivery-sequence order respects causal order
|
|
3390
|
+
* (a dependency is always committed, and thus sequenced, before its dependent), no
|
|
3391
|
+
* topological sort is needed and the client never defers on a missing dependency
|
|
3392
|
+
* that is itself in this stream.
|
|
3393
|
+
*
|
|
3394
|
+
* The final batch advances the watermark to the highest delivery sequence scanned,
|
|
3395
|
+
* not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
|
|
3396
|
+
* next reconnect. Returns the number of operations actually sent.
|
|
3397
|
+
*/
|
|
3398
|
+
async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
|
|
3399
|
+
const scanChunk = Math.max(this.batchSize, 1) * 5;
|
|
3400
|
+
let scanCursor = fromDeliverySeq;
|
|
3401
|
+
let maxScanned = fromDeliverySeq;
|
|
3402
|
+
const visible = [];
|
|
3403
|
+
while (true) {
|
|
3404
|
+
const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
|
|
3405
|
+
const last = chunk[chunk.length - 1];
|
|
3406
|
+
if (last === void 0) break;
|
|
3407
|
+
scanCursor = last.deliverySequence;
|
|
3408
|
+
maxScanned = scanCursor;
|
|
3409
|
+
for (const delivered of chunk) {
|
|
3410
|
+
if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
|
|
3411
|
+
continue;
|
|
3412
|
+
}
|
|
3413
|
+
if (await this.operationVisibleToClient(delivered.operation)) {
|
|
3414
|
+
visible.push(delivered);
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
if (chunk.length < scanChunk) break;
|
|
3418
|
+
}
|
|
3419
|
+
if (visible.length === 0) {
|
|
3420
|
+
if (finalizeWhenEmpty) {
|
|
3421
|
+
this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
|
|
3422
|
+
}
|
|
3423
|
+
return 0;
|
|
3424
|
+
}
|
|
3425
|
+
const totalBatches = Math.ceil(visible.length / this.batchSize);
|
|
3426
|
+
let base = fromDeliverySeq;
|
|
3427
|
+
for (let i = 0; i < totalBatches; i++) {
|
|
3428
|
+
const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
|
|
3429
|
+
const lastInSlice = slice[slice.length - 1];
|
|
3430
|
+
if (lastInSlice === void 0) continue;
|
|
3431
|
+
const isFinal = i === totalBatches - 1;
|
|
3432
|
+
const lastSeq = lastInSlice.deliverySequence;
|
|
3433
|
+
const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
|
|
3434
|
+
this.sendDeliveryBatch(slice, base, max, i, isFinal);
|
|
3435
|
+
base = max;
|
|
3436
|
+
}
|
|
3437
|
+
return visible.length;
|
|
3438
|
+
}
|
|
3439
|
+
/** Build, track, and send one chained delivery-stream batch. */
|
|
3440
|
+
sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
|
|
3441
|
+
const batchMsg = {
|
|
3442
|
+
type: "operation-batch",
|
|
3443
|
+
messageId: generateUUIDv74(),
|
|
3444
|
+
operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
|
|
3445
|
+
isFinal,
|
|
3446
|
+
batchIndex,
|
|
3447
|
+
baseDeliverySequence: base,
|
|
3448
|
+
maxDeliverySequence: max
|
|
3449
|
+
};
|
|
3450
|
+
this.sendToClient(batchMsg);
|
|
3451
|
+
if (slice.length > 0) {
|
|
3452
|
+
this.emitter?.emit({
|
|
3453
|
+
type: "sync:sent",
|
|
3454
|
+
operations: slice.map((delivered) => delivered.operation),
|
|
3455
|
+
batchSize: slice.length
|
|
3456
|
+
});
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
async operationVisibleToClient(op) {
|
|
3460
|
+
const scopes = this.authContext?.scopes;
|
|
3461
|
+
const subsets = this.syncQuerySubsets;
|
|
3462
|
+
const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
|
|
3463
|
+
const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
|
|
3464
|
+
if (!operationMatchesScopes(op, scopes, fullRecord)) {
|
|
2595
3465
|
return false;
|
|
2596
3466
|
}
|
|
2597
|
-
return operationMatchesQuerySubsets(op,
|
|
3467
|
+
return operationMatchesQuerySubsets(op, subsets, fullRecord);
|
|
3468
|
+
}
|
|
3469
|
+
/**
|
|
3470
|
+
* Read a record's current field values from the materialized store for scope /
|
|
3471
|
+
* query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
|
|
3472
|
+
* carries no fields) is still judged against the record's actual scope. Returns
|
|
3473
|
+
* undefined when the record cannot be read (never materialized, or no schema).
|
|
3474
|
+
*/
|
|
3475
|
+
async lookupRecordFields(collection, recordId) {
|
|
3476
|
+
try {
|
|
3477
|
+
const rows = await this.store.queryCollection(collection, {
|
|
3478
|
+
where: { id: recordId },
|
|
3479
|
+
includeDeleted: true,
|
|
3480
|
+
limit: 1
|
|
3481
|
+
});
|
|
3482
|
+
return rows[0];
|
|
3483
|
+
} catch {
|
|
3484
|
+
return void 0;
|
|
3485
|
+
}
|
|
2598
3486
|
}
|
|
2599
3487
|
handleAwarenessUpdate(msg) {
|
|
2600
3488
|
this.onAwarenessUpdate?.(this.sessionId, msg);
|
|
@@ -2612,6 +3500,25 @@ var ClientSession = class {
|
|
|
2612
3500
|
};
|
|
2613
3501
|
this.sendToClient(errorMsg);
|
|
2614
3502
|
}
|
|
3503
|
+
/**
|
|
3504
|
+
* Reject one specific client operation, tied to its id, so the submitter can
|
|
3505
|
+
* divert it out of its pending queue into a durable rejected store rather than
|
|
3506
|
+
* losing it or retrying forever. The op is NOT materialized, so no other
|
|
3507
|
+
* replica ever sees it.
|
|
3508
|
+
*/
|
|
3509
|
+
sendOperationRejected(op, code, message, retriable) {
|
|
3510
|
+
const rejectedMsg = {
|
|
3511
|
+
type: "operation-rejected",
|
|
3512
|
+
messageId: generateUUIDv74(),
|
|
3513
|
+
operationId: op.id,
|
|
3514
|
+
collection: op.collection,
|
|
3515
|
+
recordId: op.recordId,
|
|
3516
|
+
code,
|
|
3517
|
+
message,
|
|
3518
|
+
retriable
|
|
3519
|
+
};
|
|
3520
|
+
this.sendToClient(rejectedMsg);
|
|
3521
|
+
}
|
|
2615
3522
|
setSerializerWireFormat(format) {
|
|
2616
3523
|
if (typeof this.serializer.setWireFormat === "function") {
|
|
2617
3524
|
this.serializer.setWireFormat(format);
|
|
@@ -2620,6 +3527,7 @@ var ClientSession = class {
|
|
|
2620
3527
|
handleTransportClose() {
|
|
2621
3528
|
if (this.state === "closed") return;
|
|
2622
3529
|
this.state = "closed";
|
|
3530
|
+
this.flushOrphanedRelays();
|
|
2623
3531
|
this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
|
|
2624
3532
|
this.onClose?.(this.sessionId);
|
|
2625
3533
|
}
|
|
@@ -2632,7 +3540,7 @@ function selectWireFormat(supportedWireFormats) {
|
|
|
2632
3540
|
}
|
|
2633
3541
|
|
|
2634
3542
|
// src/server/kora-sync-server.ts
|
|
2635
|
-
import { SyncError as SyncError4, generateUUIDv7 as
|
|
3543
|
+
import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv77, isBlobRef } from "@korajs/core";
|
|
2636
3544
|
import { SimpleEventEmitter } from "@korajs/core/internal";
|
|
2637
3545
|
import { JsonMessageSerializer as JsonMessageSerializer2 } from "@korajs/sync";
|
|
2638
3546
|
|
|
@@ -2992,21 +3900,421 @@ var YjsDocRelay = class {
|
|
|
2992
3900
|
}
|
|
2993
3901
|
};
|
|
2994
3902
|
|
|
3903
|
+
// src/server/route-context.ts
|
|
3904
|
+
import {
|
|
3905
|
+
HybridLogicalClock as HybridLogicalClock3,
|
|
3906
|
+
createOperation as createOperation2,
|
|
3907
|
+
generateUUIDv7 as generateUUIDv76,
|
|
3908
|
+
isAtomicOp,
|
|
3909
|
+
resolveAtomicOp,
|
|
3910
|
+
toAtomicOp
|
|
3911
|
+
} from "@korajs/core";
|
|
3912
|
+
|
|
3913
|
+
// src/apply/route-predicate.ts
|
|
3914
|
+
function evaluateField(actual, operators) {
|
|
3915
|
+
if ("$eq" in operators && !Object.is(actual, operators.$eq)) {
|
|
3916
|
+
return false;
|
|
3917
|
+
}
|
|
3918
|
+
if ("$ne" in operators && Object.is(actual, operators.$ne)) {
|
|
3919
|
+
return false;
|
|
3920
|
+
}
|
|
3921
|
+
if (operators.$in !== void 0 && !operators.$in.some((candidate) => Object.is(actual, candidate))) {
|
|
3922
|
+
return false;
|
|
3923
|
+
}
|
|
3924
|
+
const numericChecks = [
|
|
3925
|
+
["$lt", (a, b) => a < b],
|
|
3926
|
+
["$lte", (a, b) => a <= b],
|
|
3927
|
+
["$gt", (a, b) => a > b],
|
|
3928
|
+
["$gte", (a, b) => a >= b]
|
|
3929
|
+
];
|
|
3930
|
+
for (const [operator, compare] of numericChecks) {
|
|
3931
|
+
const bound = operators[operator];
|
|
3932
|
+
if (bound === void 0) {
|
|
3933
|
+
continue;
|
|
3934
|
+
}
|
|
3935
|
+
if (typeof actual !== "number" || typeof bound !== "number") {
|
|
3936
|
+
return false;
|
|
3937
|
+
}
|
|
3938
|
+
if (!compare(actual, bound)) {
|
|
3939
|
+
return false;
|
|
3940
|
+
}
|
|
3941
|
+
}
|
|
3942
|
+
return true;
|
|
3943
|
+
}
|
|
3944
|
+
function evaluateRoutePredicate(record, predicate) {
|
|
3945
|
+
const fields = Object.keys(predicate);
|
|
3946
|
+
if (fields.length === 0) {
|
|
3947
|
+
return true;
|
|
3948
|
+
}
|
|
3949
|
+
if (record === null) {
|
|
3950
|
+
return false;
|
|
3951
|
+
}
|
|
3952
|
+
for (const field of fields) {
|
|
3953
|
+
const operators = predicate[field];
|
|
3954
|
+
if (operators && !evaluateField(record[field], operators)) {
|
|
3955
|
+
return false;
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
return true;
|
|
3959
|
+
}
|
|
3960
|
+
|
|
3961
|
+
// src/server/route-context.ts
|
|
3962
|
+
function recordMatchesScope(collection, record, scope) {
|
|
3963
|
+
const collectionScope = scope[collection];
|
|
3964
|
+
if (!collectionScope) {
|
|
3965
|
+
return false;
|
|
3966
|
+
}
|
|
3967
|
+
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
3968
|
+
if (record[field] !== expected) {
|
|
3969
|
+
return false;
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3972
|
+
return true;
|
|
3973
|
+
}
|
|
3974
|
+
function pickFields(record, keys) {
|
|
3975
|
+
const picked = {};
|
|
3976
|
+
for (const key of keys) {
|
|
3977
|
+
if (key in record) {
|
|
3978
|
+
picked[key] = record[key];
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
return picked;
|
|
3982
|
+
}
|
|
3983
|
+
async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
|
|
3984
|
+
const nodeId = store.getNodeId();
|
|
3985
|
+
const clock = clockOverride ?? new HybridLogicalClock3(nodeId);
|
|
3986
|
+
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
3987
|
+
const sequenceNumber = nextServerSequenceNumber(store);
|
|
3988
|
+
if (mutation.type === "insert") {
|
|
3989
|
+
return createOperation2(
|
|
3990
|
+
{
|
|
3991
|
+
nodeId,
|
|
3992
|
+
type: "insert",
|
|
3993
|
+
collection: mutation.collection,
|
|
3994
|
+
recordId: mutation.recordId ?? generateUUIDv76(),
|
|
3995
|
+
data: mutation.data ?? {},
|
|
3996
|
+
previousData: null,
|
|
3997
|
+
sequenceNumber,
|
|
3998
|
+
causalDeps: [],
|
|
3999
|
+
schemaVersion
|
|
4000
|
+
},
|
|
4001
|
+
clock
|
|
4002
|
+
);
|
|
4003
|
+
}
|
|
4004
|
+
if (!mutation.recordId) {
|
|
4005
|
+
throw new RouteMutationError(
|
|
4006
|
+
"MISSING_RECORD_ID",
|
|
4007
|
+
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
4008
|
+
);
|
|
4009
|
+
}
|
|
4010
|
+
const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
|
|
4011
|
+
if (mutation.type === "update") {
|
|
4012
|
+
const rawData = mutation.data ?? {};
|
|
4013
|
+
const data = {};
|
|
4014
|
+
const atomicOps = {};
|
|
4015
|
+
for (const key of Object.keys(rawData)) {
|
|
4016
|
+
const value = rawData[key];
|
|
4017
|
+
if (isAtomicOp(value)) {
|
|
4018
|
+
data[key] = resolveAtomicOp(current ? current[key] : void 0, value);
|
|
4019
|
+
atomicOps[key] = toAtomicOp(value);
|
|
4020
|
+
} else {
|
|
4021
|
+
data[key] = value;
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
return createOperation2(
|
|
4025
|
+
{
|
|
4026
|
+
nodeId,
|
|
4027
|
+
type: "update",
|
|
4028
|
+
collection: mutation.collection,
|
|
4029
|
+
recordId: mutation.recordId,
|
|
4030
|
+
data,
|
|
4031
|
+
previousData: current ? pickFields(current, Object.keys(rawData)) : {},
|
|
4032
|
+
sequenceNumber,
|
|
4033
|
+
causalDeps: [],
|
|
4034
|
+
schemaVersion,
|
|
4035
|
+
...Object.keys(atomicOps).length > 0 ? { atomicOps } : {}
|
|
4036
|
+
},
|
|
4037
|
+
clock
|
|
4038
|
+
);
|
|
4039
|
+
}
|
|
4040
|
+
return createOperation2(
|
|
4041
|
+
{
|
|
4042
|
+
nodeId,
|
|
4043
|
+
type: "delete",
|
|
4044
|
+
collection: mutation.collection,
|
|
4045
|
+
recordId: mutation.recordId,
|
|
4046
|
+
data: null,
|
|
4047
|
+
previousData: current ? { ...current } : null,
|
|
4048
|
+
sequenceNumber,
|
|
4049
|
+
causalDeps: [],
|
|
4050
|
+
schemaVersion
|
|
4051
|
+
},
|
|
4052
|
+
clock
|
|
4053
|
+
);
|
|
4054
|
+
}
|
|
4055
|
+
var RouteMutationError = class extends Error {
|
|
4056
|
+
constructor(code, message) {
|
|
4057
|
+
super(message);
|
|
4058
|
+
this.code = code;
|
|
4059
|
+
this.name = "RouteMutationError";
|
|
4060
|
+
}
|
|
4061
|
+
code;
|
|
4062
|
+
};
|
|
4063
|
+
function createRouteContext(server, store) {
|
|
4064
|
+
let mutationTail = Promise.resolve();
|
|
4065
|
+
function serialize(work) {
|
|
4066
|
+
const run = mutationTail.then(work, work);
|
|
4067
|
+
mutationTail = run.then(
|
|
4068
|
+
() => void 0,
|
|
4069
|
+
() => void 0
|
|
4070
|
+
);
|
|
4071
|
+
return run;
|
|
4072
|
+
}
|
|
4073
|
+
async function prepareMutation(mutation, scope) {
|
|
4074
|
+
let op;
|
|
4075
|
+
try {
|
|
4076
|
+
op = await buildRouteOperation(store, mutation);
|
|
4077
|
+
} catch (error) {
|
|
4078
|
+
if (error instanceof RouteMutationError) {
|
|
4079
|
+
return {
|
|
4080
|
+
ok: false,
|
|
4081
|
+
rejection: {
|
|
4082
|
+
ok: false,
|
|
4083
|
+
code: error.code,
|
|
4084
|
+
message: error.message,
|
|
4085
|
+
retriable: isRetriableRejection(error.code)
|
|
4086
|
+
}
|
|
4087
|
+
};
|
|
4088
|
+
}
|
|
4089
|
+
throw error;
|
|
4090
|
+
}
|
|
4091
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
4092
|
+
return {
|
|
4093
|
+
ok: false,
|
|
4094
|
+
rejection: {
|
|
4095
|
+
ok: false,
|
|
4096
|
+
code: "SCOPE_VIOLATION",
|
|
4097
|
+
message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
|
|
4098
|
+
retriable: isRetriableRejection("SCOPE_VIOLATION")
|
|
4099
|
+
}
|
|
4100
|
+
};
|
|
4101
|
+
}
|
|
4102
|
+
return { ok: true, op };
|
|
4103
|
+
}
|
|
4104
|
+
async function commitOperation(op, collection) {
|
|
4105
|
+
const result = await server.applyLocalOperation(op);
|
|
4106
|
+
if (result.result !== "applied") {
|
|
4107
|
+
const code = result.rejection?.code ?? "NOT_APPLIED";
|
|
4108
|
+
return {
|
|
4109
|
+
ok: false,
|
|
4110
|
+
code,
|
|
4111
|
+
message: result.rejection?.message ?? `Operation on "${collection}" was not applied (${result.result}).`,
|
|
4112
|
+
// Prefer the pipeline's own classification when present; fall back
|
|
4113
|
+
// to classifying the code so this stays correct if new codes appear.
|
|
4114
|
+
retriable: result.rejection?.retriable ?? isRetriableRejection(code)
|
|
4115
|
+
};
|
|
4116
|
+
}
|
|
4117
|
+
const record = await store.findRecord(collection, op.recordId);
|
|
4118
|
+
return { ok: true, operation: op, record };
|
|
4119
|
+
}
|
|
4120
|
+
async function rawApply(mutation, scope) {
|
|
4121
|
+
const prepared = await prepareMutation(mutation, scope);
|
|
4122
|
+
if (!prepared.ok) {
|
|
4123
|
+
return prepared.rejection;
|
|
4124
|
+
}
|
|
4125
|
+
return commitOperation(prepared.op, mutation.collection);
|
|
4126
|
+
}
|
|
4127
|
+
async function runConditionalViaStore(storeApplyConditional, spec, options) {
|
|
4128
|
+
const scope = options?.scope;
|
|
4129
|
+
const mutations = [];
|
|
4130
|
+
const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
|
|
4131
|
+
if (hasTargetUpdate) {
|
|
4132
|
+
mutations.push({
|
|
4133
|
+
collection: spec.collection,
|
|
4134
|
+
type: "update",
|
|
4135
|
+
recordId: spec.id,
|
|
4136
|
+
data: spec.update
|
|
4137
|
+
});
|
|
4138
|
+
}
|
|
4139
|
+
if (spec.also) {
|
|
4140
|
+
mutations.push(...spec.also);
|
|
4141
|
+
}
|
|
4142
|
+
let result;
|
|
4143
|
+
try {
|
|
4144
|
+
result = await storeApplyConditional({
|
|
4145
|
+
target: { collection: spec.collection, id: spec.id },
|
|
4146
|
+
...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
|
|
4147
|
+
admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
|
|
4148
|
+
buildOperations: async (current, context) => {
|
|
4149
|
+
const ops = [];
|
|
4150
|
+
for (let i = 0; i < mutations.length; i++) {
|
|
4151
|
+
const mutation = mutations[i];
|
|
4152
|
+
const useLockedCurrent = i === 0 && hasTargetUpdate;
|
|
4153
|
+
const op = await buildRouteOperation(
|
|
4154
|
+
store,
|
|
4155
|
+
mutation,
|
|
4156
|
+
useLockedCurrent ? { record: current } : void 0,
|
|
4157
|
+
context.clock
|
|
4158
|
+
);
|
|
4159
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
4160
|
+
throw new RouteMutationError(
|
|
4161
|
+
"SCOPE_VIOLATION",
|
|
4162
|
+
`Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
4163
|
+
);
|
|
4164
|
+
}
|
|
4165
|
+
ops.push(op);
|
|
4166
|
+
}
|
|
4167
|
+
return ops;
|
|
4168
|
+
}
|
|
4169
|
+
});
|
|
4170
|
+
} catch (error) {
|
|
4171
|
+
if (error instanceof RouteMutationError) {
|
|
4172
|
+
return {
|
|
4173
|
+
ok: false,
|
|
4174
|
+
code: error.code,
|
|
4175
|
+
message: error.message,
|
|
4176
|
+
retriable: isRetriableRejection(error.code)
|
|
4177
|
+
};
|
|
4178
|
+
}
|
|
4179
|
+
throw error;
|
|
4180
|
+
}
|
|
4181
|
+
if (!result.admitted) {
|
|
4182
|
+
return {
|
|
4183
|
+
ok: false,
|
|
4184
|
+
code: spec.reject?.code ?? "CONDITION_NOT_MET",
|
|
4185
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
4186
|
+
retriable: false
|
|
4187
|
+
};
|
|
4188
|
+
}
|
|
4189
|
+
if (result.idempotent) {
|
|
4190
|
+
const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
|
|
4191
|
+
return { ok: true, idempotent: true, operations: [], records: records2 };
|
|
4192
|
+
}
|
|
4193
|
+
server.relayServerOperations(result.applied);
|
|
4194
|
+
const records = [];
|
|
4195
|
+
for (const op of result.applied) {
|
|
4196
|
+
records.push(await store.findRecord(op.collection, op.recordId));
|
|
4197
|
+
}
|
|
4198
|
+
return { ok: true, idempotent: false, operations: result.applied, records };
|
|
4199
|
+
}
|
|
4200
|
+
return {
|
|
4201
|
+
apply(mutation, options) {
|
|
4202
|
+
return serialize(() => rawApply(mutation, options?.scope));
|
|
4203
|
+
},
|
|
4204
|
+
applyConditional(spec, options) {
|
|
4205
|
+
const storeApplyConditional = store.applyConditional;
|
|
4206
|
+
if (storeApplyConditional) {
|
|
4207
|
+
return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
|
|
4208
|
+
}
|
|
4209
|
+
return serialize(async () => {
|
|
4210
|
+
if (spec.idempotencyKey) {
|
|
4211
|
+
const existing = await store.findRecord(
|
|
4212
|
+
spec.idempotencyKey.collection,
|
|
4213
|
+
spec.idempotencyKey.id
|
|
4214
|
+
);
|
|
4215
|
+
if (existing) {
|
|
4216
|
+
return { ok: true, idempotent: true, operations: [], records: [existing] };
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
if (spec.if && Object.keys(spec.if).length > 0) {
|
|
4220
|
+
const target = await store.findRecord(spec.collection, spec.id);
|
|
4221
|
+
if (!evaluateRoutePredicate(target, spec.if)) {
|
|
4222
|
+
const code = spec.reject?.code ?? "CONDITION_NOT_MET";
|
|
4223
|
+
return {
|
|
4224
|
+
ok: false,
|
|
4225
|
+
code,
|
|
4226
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
4227
|
+
retriable: false
|
|
4228
|
+
};
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
const mutations = [];
|
|
4232
|
+
if (spec.update !== void 0 && spec.update !== null) {
|
|
4233
|
+
mutations.push({
|
|
4234
|
+
collection: spec.collection,
|
|
4235
|
+
type: "update",
|
|
4236
|
+
recordId: spec.id,
|
|
4237
|
+
data: spec.update
|
|
4238
|
+
});
|
|
4239
|
+
}
|
|
4240
|
+
if (spec.also) {
|
|
4241
|
+
mutations.push(...spec.also);
|
|
4242
|
+
}
|
|
4243
|
+
const prepared = [];
|
|
4244
|
+
for (const mutation of mutations) {
|
|
4245
|
+
const p = await prepareMutation(mutation, options?.scope);
|
|
4246
|
+
if (!p.ok) {
|
|
4247
|
+
return p.rejection;
|
|
4248
|
+
}
|
|
4249
|
+
prepared.push({ op: p.op, collection: mutation.collection });
|
|
4250
|
+
}
|
|
4251
|
+
const operations2 = [];
|
|
4252
|
+
const records = [];
|
|
4253
|
+
for (const { op, collection } of prepared) {
|
|
4254
|
+
const result = await commitOperation(op, collection);
|
|
4255
|
+
if (!result.ok) {
|
|
4256
|
+
return {
|
|
4257
|
+
ok: false,
|
|
4258
|
+
code: result.code,
|
|
4259
|
+
message: result.message,
|
|
4260
|
+
retriable: result.retriable
|
|
4261
|
+
};
|
|
4262
|
+
}
|
|
4263
|
+
operations2.push(result.operation);
|
|
4264
|
+
records.push(result.record);
|
|
4265
|
+
}
|
|
4266
|
+
return { ok: true, idempotent: false, operations: operations2, records };
|
|
4267
|
+
});
|
|
4268
|
+
},
|
|
4269
|
+
async query(collection, options) {
|
|
4270
|
+
const { scope, ...queryOptions } = options ?? {};
|
|
4271
|
+
const records = await store.queryCollection(collection, queryOptions);
|
|
4272
|
+
if (!scope) {
|
|
4273
|
+
return records;
|
|
4274
|
+
}
|
|
4275
|
+
return records.filter((record) => recordMatchesScope(collection, record, scope));
|
|
4276
|
+
},
|
|
4277
|
+
async findById(collection, id, options) {
|
|
4278
|
+
const record = await store.findRecord(collection, id);
|
|
4279
|
+
if (!record) {
|
|
4280
|
+
return null;
|
|
4281
|
+
}
|
|
4282
|
+
if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
|
|
4283
|
+
return null;
|
|
4284
|
+
}
|
|
4285
|
+
return record;
|
|
4286
|
+
}
|
|
4287
|
+
};
|
|
4288
|
+
}
|
|
4289
|
+
|
|
2995
4290
|
// src/server/kora-sync-server.ts
|
|
2996
4291
|
var DEFAULT_MAX_CONNECTIONS = 0;
|
|
2997
4292
|
var DEFAULT_BATCH_SIZE2 = 100;
|
|
2998
4293
|
var DEFAULT_SCHEMA_VERSION2 = 1;
|
|
2999
4294
|
var DEFAULT_HOST = "0.0.0.0";
|
|
3000
4295
|
var DEFAULT_PATH = "/";
|
|
3001
|
-
var
|
|
4296
|
+
var DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
|
|
4297
|
+
var DEFAULT_DELIVERY_POLL_INTERVAL_MS = 2e3;
|
|
4298
|
+
function validateIntervalOption(name, value) {
|
|
4299
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
4300
|
+
throw new SyncError4(`${name} must be a non-negative integer number of milliseconds`, {
|
|
4301
|
+
[name]: value
|
|
4302
|
+
});
|
|
4303
|
+
}
|
|
4304
|
+
return value;
|
|
4305
|
+
}
|
|
4306
|
+
var KoraSyncServer = class _KoraSyncServer {
|
|
3002
4307
|
store;
|
|
3003
4308
|
auth;
|
|
3004
4309
|
serializer;
|
|
3005
4310
|
emitter;
|
|
3006
4311
|
maxConnections;
|
|
3007
4312
|
batchSize;
|
|
4313
|
+
relayRetransmitIntervalMs;
|
|
4314
|
+
deliveryPollIntervalMs;
|
|
3008
4315
|
schemaVersion;
|
|
3009
4316
|
supportedSchemaVersions;
|
|
4317
|
+
operationTransforms;
|
|
3010
4318
|
port;
|
|
3011
4319
|
host;
|
|
3012
4320
|
path;
|
|
@@ -3016,24 +4324,63 @@ var KoraSyncServer = class {
|
|
|
3016
4324
|
yjsDocRelay = new YjsDocRelay();
|
|
3017
4325
|
blobChunkRelay;
|
|
3018
4326
|
persistBlobChunk;
|
|
4327
|
+
maxOperationBytes;
|
|
4328
|
+
maxOpsPerMinute;
|
|
4329
|
+
validateOperation;
|
|
4330
|
+
koraContext;
|
|
3019
4331
|
sessions = /* @__PURE__ */ new Map();
|
|
3020
4332
|
httpClients = /* @__PURE__ */ new Map();
|
|
3021
4333
|
httpSessionToClient = /* @__PURE__ */ new Map();
|
|
3022
|
-
|
|
4334
|
+
// Informational value reported by getStatus(). Keep in sync with the
|
|
4335
|
+
// @korajs/server package version on release; it is not used for protocol negotiation.
|
|
4336
|
+
serverVersion = "1.0.0-beta.0";
|
|
3023
4337
|
wsServer = null;
|
|
3024
4338
|
running = false;
|
|
4339
|
+
/**
|
|
4340
|
+
* Periodic tick that retransmits relay batches connected clients have not
|
|
4341
|
+
* acknowledged, so a relay dropped by a lossy transport is redelivered instead of
|
|
4342
|
+
* leaving that client with a permanent version-vector gap (a lost operation).
|
|
4343
|
+
*/
|
|
4344
|
+
relayRetransmitTimer = null;
|
|
4345
|
+
deliveryPollTimer = null;
|
|
4346
|
+
lastObservedDeliverySequence = 0;
|
|
4347
|
+
deliveryPollInFlight = false;
|
|
4348
|
+
/**
|
|
4349
|
+
* Relay operations a client never acknowledged before it disconnected, buffered by
|
|
4350
|
+
* client node id so they can be redelivered on its next connection. Bounded per
|
|
4351
|
+
* node and expired by age so a client that never returns cannot grow this forever.
|
|
4352
|
+
*/
|
|
4353
|
+
orphanedRelaysByNode = /* @__PURE__ */ new Map();
|
|
4354
|
+
static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
|
|
4355
|
+
static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
|
|
3025
4356
|
constructor(config) {
|
|
3026
4357
|
this.store = config.store;
|
|
4358
|
+
const storeSchemaVersion = this.store.getSchema()?.version;
|
|
4359
|
+
if (config.schemaVersion !== void 0 && storeSchemaVersion !== void 0 && config.schemaVersion !== storeSchemaVersion) {
|
|
4360
|
+
throw new SyncError4("Sync schema version does not match the configured store schema.", {
|
|
4361
|
+
syncSchemaVersion: config.schemaVersion,
|
|
4362
|
+
storeSchemaVersion
|
|
4363
|
+
});
|
|
4364
|
+
}
|
|
3027
4365
|
this.auth = config.auth ?? null;
|
|
3028
4366
|
this.serializer = config.serializer ?? new JsonMessageSerializer2();
|
|
3029
4367
|
this.emitter = config.emitter ?? null;
|
|
3030
4368
|
this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
3031
4369
|
this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
|
|
3032
|
-
this.
|
|
4370
|
+
this.relayRetransmitIntervalMs = validateIntervalOption(
|
|
4371
|
+
"relayRetransmitIntervalMs",
|
|
4372
|
+
config.relayRetransmitIntervalMs ?? DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS
|
|
4373
|
+
);
|
|
4374
|
+
this.deliveryPollIntervalMs = validateIntervalOption(
|
|
4375
|
+
"deliveryPollIntervalMs",
|
|
4376
|
+
config.deliveryPollIntervalMs ?? DEFAULT_DELIVERY_POLL_INTERVAL_MS
|
|
4377
|
+
);
|
|
4378
|
+
this.schemaVersion = config.schemaVersion ?? storeSchemaVersion ?? DEFAULT_SCHEMA_VERSION2;
|
|
3033
4379
|
this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
|
|
3034
4380
|
min: this.schemaVersion,
|
|
3035
4381
|
max: this.schemaVersion
|
|
3036
4382
|
};
|
|
4383
|
+
this.operationTransforms = config.operationTransforms ?? [];
|
|
3037
4384
|
this.port = config.port;
|
|
3038
4385
|
this.host = config.host ?? DEFAULT_HOST;
|
|
3039
4386
|
this.path = config.path ?? DEFAULT_PATH;
|
|
@@ -3042,10 +4389,116 @@ var KoraSyncServer = class {
|
|
|
3042
4389
|
this.metrics.setSchemaVersion(this.schemaVersion);
|
|
3043
4390
|
this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
|
|
3044
4391
|
this.persistBlobChunk = config.persistBlobChunk ?? null;
|
|
4392
|
+
this.maxOperationBytes = config.maxOperationBytes;
|
|
4393
|
+
this.maxOpsPerMinute = config.maxOpsPerMinute;
|
|
4394
|
+
this.validateOperation = config.validateOperation;
|
|
4395
|
+
this.koraContext = createRouteContext(this, this.store);
|
|
3045
4396
|
if (!this.emitter) {
|
|
3046
4397
|
this.emitter = new SimpleEventEmitter();
|
|
3047
4398
|
}
|
|
3048
4399
|
}
|
|
4400
|
+
ensureBackgroundTimersStarted() {
|
|
4401
|
+
if (this.relayRetransmitIntervalMs > 0 && !this.relayRetransmitTimer) {
|
|
4402
|
+
this.relayRetransmitTimer = setInterval(() => {
|
|
4403
|
+
this.retransmitPendingRelays(this.relayRetransmitIntervalMs);
|
|
4404
|
+
}, this.relayRetransmitIntervalMs);
|
|
4405
|
+
this.relayRetransmitTimer.unref?.();
|
|
4406
|
+
}
|
|
4407
|
+
if (this.deliveryPollIntervalMs > 0 && !this.deliveryPollTimer) {
|
|
4408
|
+
this.deliveryPollTimer = setInterval(() => {
|
|
4409
|
+
void this.pollDeliveryLog();
|
|
4410
|
+
}, this.deliveryPollIntervalMs);
|
|
4411
|
+
this.deliveryPollTimer.unref?.();
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
/**
|
|
4415
|
+
* Retransmit relay batches that connected clients have not acknowledged within
|
|
4416
|
+
* `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
|
|
4417
|
+
* already-applied operation is harmless (clients dedup by content-addressed id).
|
|
4418
|
+
*/
|
|
4419
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
4420
|
+
for (const session of this.sessions.values()) {
|
|
4421
|
+
session.retransmitPendingRelays(staleMs);
|
|
4422
|
+
}
|
|
4423
|
+
this.expireOrphanedRelays();
|
|
4424
|
+
}
|
|
4425
|
+
/**
|
|
4426
|
+
* Check whether the authoritative delivery log advanced without passing through
|
|
4427
|
+
* this server instance, then wake delivery-watermark sessions to scan from their
|
|
4428
|
+
* own acknowledged cursors. Session-level filtering remains the only fan-out path.
|
|
4429
|
+
*/
|
|
4430
|
+
async pollDeliveryLog() {
|
|
4431
|
+
if (this.deliveryPollInFlight) return;
|
|
4432
|
+
this.deliveryPollInFlight = true;
|
|
4433
|
+
try {
|
|
4434
|
+
const maxDeliverySequence = await this.store.getMaxDeliverySequence();
|
|
4435
|
+
if (maxDeliverySequence <= this.lastObservedDeliverySequence) {
|
|
4436
|
+
for (const session of this.sessions.values()) {
|
|
4437
|
+
session.pushDeliveryStreamIfSupported(this.deliveryPollIntervalMs, {
|
|
4438
|
+
trackStall: true
|
|
4439
|
+
});
|
|
4440
|
+
}
|
|
4441
|
+
return;
|
|
4442
|
+
}
|
|
4443
|
+
this.lastObservedDeliverySequence = maxDeliverySequence;
|
|
4444
|
+
for (const session of this.sessions.values()) {
|
|
4445
|
+
session.pushDeliveryStreamIfSupported(0);
|
|
4446
|
+
}
|
|
4447
|
+
} catch (error) {
|
|
4448
|
+
this.logger.log({
|
|
4449
|
+
timestamp: Date.now(),
|
|
4450
|
+
level: "warn",
|
|
4451
|
+
event: "delivery_poll.failed",
|
|
4452
|
+
details: { error: error instanceof Error ? error.message : String(error) }
|
|
4453
|
+
});
|
|
4454
|
+
} finally {
|
|
4455
|
+
this.deliveryPollInFlight = false;
|
|
4456
|
+
}
|
|
4457
|
+
}
|
|
4458
|
+
/**
|
|
4459
|
+
* Buffer relay operations a disconnected client never acknowledged, keyed by its
|
|
4460
|
+
* node id, deduped by operation id and bounded by count. Redelivered when the
|
|
4461
|
+
* client reconnects (see {@link takeOrphanedRelays}).
|
|
4462
|
+
*/
|
|
4463
|
+
bufferOrphanedRelays(nodeId, ops) {
|
|
4464
|
+
if (ops.length === 0) return;
|
|
4465
|
+
const existing = this.orphanedRelaysByNode.get(nodeId);
|
|
4466
|
+
const merged = existing ? existing.ops : [];
|
|
4467
|
+
const seen = new Set(merged.map((op) => op.id));
|
|
4468
|
+
for (const op of ops) {
|
|
4469
|
+
if (!seen.has(op.id)) {
|
|
4470
|
+
merged.push(op);
|
|
4471
|
+
seen.add(op.id);
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
|
|
4475
|
+
this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
|
|
4476
|
+
}
|
|
4477
|
+
/** Remove and return a node's buffered orphaned relay operations, if any. */
|
|
4478
|
+
takeOrphanedRelays(nodeId) {
|
|
4479
|
+
const entry = this.orphanedRelaysByNode.get(nodeId);
|
|
4480
|
+
if (!entry) return [];
|
|
4481
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4482
|
+
return entry.ops;
|
|
4483
|
+
}
|
|
4484
|
+
/** Drop buffered orphaned relays older than the TTL (client never returned). */
|
|
4485
|
+
expireOrphanedRelays() {
|
|
4486
|
+
if (this.orphanedRelaysByNode.size === 0) return;
|
|
4487
|
+
const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
|
|
4488
|
+
for (const [nodeId, entry] of this.orphanedRelaysByNode) {
|
|
4489
|
+
if (entry.bufferedAtMs <= cutoff) {
|
|
4490
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4491
|
+
}
|
|
4492
|
+
}
|
|
4493
|
+
}
|
|
4494
|
+
/**
|
|
4495
|
+
* The trusted, scoped data-plane context (`apply` / `query` / `findById`).
|
|
4496
|
+
* Shared with the production server so HTTP routes, the operation validator,
|
|
4497
|
+
* and `server.kora` are all the same object over the same pipeline.
|
|
4498
|
+
*/
|
|
4499
|
+
getKoraContext() {
|
|
4500
|
+
return this.koraContext;
|
|
4501
|
+
}
|
|
3049
4502
|
/**
|
|
3050
4503
|
* Subscribe to session-level events for metrics collection and logging.
|
|
3051
4504
|
* Called when a new session is created.
|
|
@@ -3094,6 +4547,21 @@ var KoraSyncServer = class {
|
|
|
3094
4547
|
details: { reason: event.reason }
|
|
3095
4548
|
});
|
|
3096
4549
|
});
|
|
4550
|
+
sessionEmitter.on("sync:schema-mismatch", (event) => {
|
|
4551
|
+
this.logger.log({
|
|
4552
|
+
timestamp: Date.now(),
|
|
4553
|
+
level: "warn",
|
|
4554
|
+
event: "session.schema_mismatch",
|
|
4555
|
+
sessionId,
|
|
4556
|
+
details: {
|
|
4557
|
+
clientSchemaVersion: event.clientSchemaVersion,
|
|
4558
|
+
serverSchemaVersion: event.serverSchemaVersion,
|
|
4559
|
+
supportedMin: event.supportedMin,
|
|
4560
|
+
supportedMax: event.supportedMax,
|
|
4561
|
+
reason: event.reason
|
|
4562
|
+
}
|
|
4563
|
+
});
|
|
4564
|
+
});
|
|
3097
4565
|
}
|
|
3098
4566
|
/**
|
|
3099
4567
|
* Get the metrics collector for external access (e.g., HTTP endpoints).
|
|
@@ -3163,6 +4631,15 @@ var KoraSyncServer = class {
|
|
|
3163
4631
|
event: "server.stopping",
|
|
3164
4632
|
details: { connectedClients: this.sessions.size }
|
|
3165
4633
|
});
|
|
4634
|
+
if (this.relayRetransmitTimer) {
|
|
4635
|
+
clearInterval(this.relayRetransmitTimer);
|
|
4636
|
+
this.relayRetransmitTimer = null;
|
|
4637
|
+
}
|
|
4638
|
+
if (this.deliveryPollTimer) {
|
|
4639
|
+
clearInterval(this.deliveryPollTimer);
|
|
4640
|
+
this.deliveryPollTimer = null;
|
|
4641
|
+
}
|
|
4642
|
+
this.orphanedRelaysByNode.clear();
|
|
3166
4643
|
this.awarenessRelay.clear();
|
|
3167
4644
|
this.yjsDocRelay.clear();
|
|
3168
4645
|
this.blobChunkRelay.clear();
|
|
@@ -3227,7 +4704,7 @@ var KoraSyncServer = class {
|
|
|
3227
4704
|
if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
|
|
3228
4705
|
transport.send({
|
|
3229
4706
|
type: "error",
|
|
3230
|
-
messageId:
|
|
4707
|
+
messageId: generateUUIDv77(),
|
|
3231
4708
|
code: "MAX_CONNECTIONS",
|
|
3232
4709
|
message: `Server has reached maximum connections (${this.maxConnections})`,
|
|
3233
4710
|
retriable: true
|
|
@@ -3245,7 +4722,8 @@ var KoraSyncServer = class {
|
|
|
3245
4722
|
max: this.maxConnections
|
|
3246
4723
|
});
|
|
3247
4724
|
}
|
|
3248
|
-
|
|
4725
|
+
this.ensureBackgroundTimersStarted();
|
|
4726
|
+
const sessionId = generateUUIDv77();
|
|
3249
4727
|
this.metrics.recordConnection(sessionId);
|
|
3250
4728
|
const sessionEmitter = new SimpleEventEmitter();
|
|
3251
4729
|
sessionEmitter.on("sync:connected", (event) => {
|
|
@@ -3291,6 +4769,33 @@ var KoraSyncServer = class {
|
|
|
3291
4769
|
sessionId
|
|
3292
4770
|
});
|
|
3293
4771
|
});
|
|
4772
|
+
sessionEmitter.on("sync:schema-mismatch", (event) => {
|
|
4773
|
+
this.logger.log({
|
|
4774
|
+
timestamp: Date.now(),
|
|
4775
|
+
level: "warn",
|
|
4776
|
+
event: "session.schema_mismatch",
|
|
4777
|
+
sessionId,
|
|
4778
|
+
details: {
|
|
4779
|
+
clientSchemaVersion: event.clientSchemaVersion,
|
|
4780
|
+
serverSchemaVersion: event.serverSchemaVersion,
|
|
4781
|
+
supportedMin: event.supportedMin,
|
|
4782
|
+
supportedMax: event.supportedMax,
|
|
4783
|
+
reason: event.reason
|
|
4784
|
+
}
|
|
4785
|
+
});
|
|
4786
|
+
});
|
|
4787
|
+
sessionEmitter.on("sync:delivery-stalled", (event) => {
|
|
4788
|
+
this.logger.log({
|
|
4789
|
+
timestamp: Date.now(),
|
|
4790
|
+
level: "warn",
|
|
4791
|
+
event: "session.delivery_stalled",
|
|
4792
|
+
sessionId,
|
|
4793
|
+
details: {
|
|
4794
|
+
watermark: event.watermark,
|
|
4795
|
+
repeatCount: event.repeatCount
|
|
4796
|
+
}
|
|
4797
|
+
});
|
|
4798
|
+
});
|
|
3294
4799
|
const session = new ClientSession({
|
|
3295
4800
|
sessionId,
|
|
3296
4801
|
transport,
|
|
@@ -3298,9 +4803,11 @@ var KoraSyncServer = class {
|
|
|
3298
4803
|
auth: this.auth ?? void 0,
|
|
3299
4804
|
serializer: this.serializer,
|
|
3300
4805
|
emitter: sessionEmitter,
|
|
4806
|
+
logger: this.logger,
|
|
3301
4807
|
batchSize: this.batchSize,
|
|
3302
4808
|
schemaVersion: this.schemaVersion,
|
|
3303
4809
|
supportedSchemaVersions: this.supportedSchemaVersions,
|
|
4810
|
+
operationTransforms: this.operationTransforms,
|
|
3304
4811
|
onRelay: (sourceSessionId, operations2) => {
|
|
3305
4812
|
this.handleRelay(sourceSessionId, operations2);
|
|
3306
4813
|
},
|
|
@@ -3317,9 +4824,18 @@ var KoraSyncServer = class {
|
|
|
3317
4824
|
this.blobChunkRelay.handleResponse(sourceSessionId, message);
|
|
3318
4825
|
},
|
|
3319
4826
|
...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
|
|
4827
|
+
// Only forward when configured, so an unset server value leaves the
|
|
4828
|
+
// session on its own documented default rather than `undefined`.
|
|
4829
|
+
...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
|
|
4830
|
+
...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
|
|
4831
|
+
...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
|
|
3320
4832
|
onClose: (sid) => {
|
|
3321
4833
|
this.handleSessionClose(sid);
|
|
3322
|
-
}
|
|
4834
|
+
},
|
|
4835
|
+
onOrphanedRelays: (nodeId, ops) => {
|
|
4836
|
+
this.bufferOrphanedRelays(nodeId, ops);
|
|
4837
|
+
},
|
|
4838
|
+
takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
|
|
3323
4839
|
});
|
|
3324
4840
|
this.sessions.set(sessionId, session);
|
|
3325
4841
|
this.yjsDocRelay.addClient(sessionId, transport);
|
|
@@ -3379,6 +4895,22 @@ var KoraSyncServer = class {
|
|
|
3379
4895
|
}
|
|
3380
4896
|
return result;
|
|
3381
4897
|
}
|
|
4898
|
+
/**
|
|
4899
|
+
* Relay already-applied, server-originated operations to every connected client.
|
|
4900
|
+
* Used by the conditional route apply, which commits its operations atomically
|
|
4901
|
+
* through the store (bypassing {@link applyLocalOperation}) and then fans them
|
|
4902
|
+
* out. Each session still enforces its own per-scope visibility filter.
|
|
4903
|
+
*
|
|
4904
|
+
* @param operations - Operations that have already been committed to the store
|
|
4905
|
+
*/
|
|
4906
|
+
relayServerOperations(operations2) {
|
|
4907
|
+
if (operations2.length === 0) {
|
|
4908
|
+
return;
|
|
4909
|
+
}
|
|
4910
|
+
for (const session of this.sessions.values()) {
|
|
4911
|
+
session.relayOperations(operations2);
|
|
4912
|
+
}
|
|
4913
|
+
}
|
|
3382
4914
|
/**
|
|
3383
4915
|
* Collect every blob reference still reachable from live records on the server.
|
|
3384
4916
|
*
|
|
@@ -3577,159 +5109,6 @@ function createKoraServer(config) {
|
|
|
3577
5109
|
return new KoraSyncServer(config);
|
|
3578
5110
|
}
|
|
3579
5111
|
|
|
3580
|
-
// src/server/route-context.ts
|
|
3581
|
-
import { HybridLogicalClock as HybridLogicalClock2, createOperation as createOperation2, generateUUIDv7 as generateUUIDv77 } from "@korajs/core";
|
|
3582
|
-
function recordMatchesScope(collection, record, scope) {
|
|
3583
|
-
const collectionScope = scope[collection];
|
|
3584
|
-
if (!collectionScope) {
|
|
3585
|
-
return false;
|
|
3586
|
-
}
|
|
3587
|
-
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
3588
|
-
if (record[field] !== expected) {
|
|
3589
|
-
return false;
|
|
3590
|
-
}
|
|
3591
|
-
}
|
|
3592
|
-
return true;
|
|
3593
|
-
}
|
|
3594
|
-
function pickFields(record, keys) {
|
|
3595
|
-
const picked = {};
|
|
3596
|
-
for (const key of keys) {
|
|
3597
|
-
if (key in record) {
|
|
3598
|
-
picked[key] = record[key];
|
|
3599
|
-
}
|
|
3600
|
-
}
|
|
3601
|
-
return picked;
|
|
3602
|
-
}
|
|
3603
|
-
async function buildRouteOperation(store, mutation) {
|
|
3604
|
-
const nodeId = store.getNodeId();
|
|
3605
|
-
const clock = new HybridLogicalClock2(nodeId);
|
|
3606
|
-
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
3607
|
-
const sequenceNumber = nextServerSequenceNumber(store);
|
|
3608
|
-
if (mutation.type === "insert") {
|
|
3609
|
-
return createOperation2(
|
|
3610
|
-
{
|
|
3611
|
-
nodeId,
|
|
3612
|
-
type: "insert",
|
|
3613
|
-
collection: mutation.collection,
|
|
3614
|
-
recordId: mutation.recordId ?? generateUUIDv77(),
|
|
3615
|
-
data: mutation.data ?? {},
|
|
3616
|
-
previousData: null,
|
|
3617
|
-
sequenceNumber,
|
|
3618
|
-
causalDeps: [],
|
|
3619
|
-
schemaVersion
|
|
3620
|
-
},
|
|
3621
|
-
clock
|
|
3622
|
-
);
|
|
3623
|
-
}
|
|
3624
|
-
if (!mutation.recordId) {
|
|
3625
|
-
throw new RouteMutationError(
|
|
3626
|
-
"MISSING_RECORD_ID",
|
|
3627
|
-
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
3628
|
-
);
|
|
3629
|
-
}
|
|
3630
|
-
const current = await store.findRecord(mutation.collection, mutation.recordId);
|
|
3631
|
-
if (mutation.type === "update") {
|
|
3632
|
-
const data = mutation.data ?? {};
|
|
3633
|
-
return createOperation2(
|
|
3634
|
-
{
|
|
3635
|
-
nodeId,
|
|
3636
|
-
type: "update",
|
|
3637
|
-
collection: mutation.collection,
|
|
3638
|
-
recordId: mutation.recordId,
|
|
3639
|
-
data,
|
|
3640
|
-
previousData: current ? pickFields(current, Object.keys(data)) : {},
|
|
3641
|
-
sequenceNumber,
|
|
3642
|
-
causalDeps: [],
|
|
3643
|
-
schemaVersion
|
|
3644
|
-
},
|
|
3645
|
-
clock
|
|
3646
|
-
);
|
|
3647
|
-
}
|
|
3648
|
-
return createOperation2(
|
|
3649
|
-
{
|
|
3650
|
-
nodeId,
|
|
3651
|
-
type: "delete",
|
|
3652
|
-
collection: mutation.collection,
|
|
3653
|
-
recordId: mutation.recordId,
|
|
3654
|
-
data: null,
|
|
3655
|
-
previousData: current ? { ...current } : null,
|
|
3656
|
-
sequenceNumber,
|
|
3657
|
-
causalDeps: [],
|
|
3658
|
-
schemaVersion
|
|
3659
|
-
},
|
|
3660
|
-
clock
|
|
3661
|
-
);
|
|
3662
|
-
}
|
|
3663
|
-
var RouteMutationError = class extends Error {
|
|
3664
|
-
constructor(code, message) {
|
|
3665
|
-
super(message);
|
|
3666
|
-
this.code = code;
|
|
3667
|
-
this.name = "RouteMutationError";
|
|
3668
|
-
}
|
|
3669
|
-
code;
|
|
3670
|
-
};
|
|
3671
|
-
function createRouteContext(server, store) {
|
|
3672
|
-
let mutationTail = Promise.resolve();
|
|
3673
|
-
function serialize(work) {
|
|
3674
|
-
const run = mutationTail.then(work, work);
|
|
3675
|
-
mutationTail = run.then(
|
|
3676
|
-
() => void 0,
|
|
3677
|
-
() => void 0
|
|
3678
|
-
);
|
|
3679
|
-
return run;
|
|
3680
|
-
}
|
|
3681
|
-
return {
|
|
3682
|
-
apply(mutation, options) {
|
|
3683
|
-
return serialize(async () => {
|
|
3684
|
-
let op;
|
|
3685
|
-
try {
|
|
3686
|
-
op = await buildRouteOperation(store, mutation);
|
|
3687
|
-
} catch (error) {
|
|
3688
|
-
if (error instanceof RouteMutationError) {
|
|
3689
|
-
return { ok: false, code: error.code, message: error.message };
|
|
3690
|
-
}
|
|
3691
|
-
throw error;
|
|
3692
|
-
}
|
|
3693
|
-
if (options?.scope && !operationMatchesScopes(op, options.scope)) {
|
|
3694
|
-
return {
|
|
3695
|
-
ok: false,
|
|
3696
|
-
code: "SCOPE_VIOLATION",
|
|
3697
|
-
message: `Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
3698
|
-
};
|
|
3699
|
-
}
|
|
3700
|
-
const result = await server.applyLocalOperation(op);
|
|
3701
|
-
if (result.result !== "applied") {
|
|
3702
|
-
return {
|
|
3703
|
-
ok: false,
|
|
3704
|
-
code: result.rejection?.code ?? "NOT_APPLIED",
|
|
3705
|
-
message: result.rejection?.message ?? `Operation on "${mutation.collection}" was not applied (${result.result}).`
|
|
3706
|
-
};
|
|
3707
|
-
}
|
|
3708
|
-
const record = await store.findRecord(mutation.collection, op.recordId);
|
|
3709
|
-
return { ok: true, operation: op, record };
|
|
3710
|
-
});
|
|
3711
|
-
},
|
|
3712
|
-
async query(collection, options) {
|
|
3713
|
-
const { scope, ...queryOptions } = options ?? {};
|
|
3714
|
-
const records = await store.queryCollection(collection, queryOptions);
|
|
3715
|
-
if (!scope) {
|
|
3716
|
-
return records;
|
|
3717
|
-
}
|
|
3718
|
-
return records.filter((record) => recordMatchesScope(collection, record, scope));
|
|
3719
|
-
},
|
|
3720
|
-
async findById(collection, id, options) {
|
|
3721
|
-
const record = await store.findRecord(collection, id);
|
|
3722
|
-
if (!record) {
|
|
3723
|
-
return null;
|
|
3724
|
-
}
|
|
3725
|
-
if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
|
|
3726
|
-
return null;
|
|
3727
|
-
}
|
|
3728
|
-
return record;
|
|
3729
|
-
}
|
|
3730
|
-
};
|
|
3731
|
-
}
|
|
3732
|
-
|
|
3733
5112
|
// src/server/production-server.ts
|
|
3734
5113
|
var MIME_TYPES = {
|
|
3735
5114
|
".html": "text/html",
|
|
@@ -3749,12 +5128,13 @@ function createProductionServer(config) {
|
|
|
3749
5128
|
const port = config.port ?? (Number(process.env.PORT) || 3001);
|
|
3750
5129
|
const staticDir = config.staticDir ?? "./dist";
|
|
3751
5130
|
const syncPath = config.syncPath ?? "/kora-sync";
|
|
5131
|
+
const crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy ?? "credentialless";
|
|
3752
5132
|
const syncServer = new KoraSyncServer({
|
|
3753
5133
|
store: config.store,
|
|
3754
5134
|
enableDashboard: true,
|
|
3755
5135
|
...config.syncOptions
|
|
3756
5136
|
});
|
|
3757
|
-
const routeContext =
|
|
5137
|
+
const routeContext = syncServer.getKoraContext();
|
|
3758
5138
|
let httpServer = null;
|
|
3759
5139
|
function getOperationalToken(kind) {
|
|
3760
5140
|
const auth = config.operationalAuth;
|
|
@@ -3894,6 +5274,10 @@ function createProductionServer(config) {
|
|
|
3894
5274
|
res.end(JSON.stringify(result.body ?? null));
|
|
3895
5275
|
}
|
|
3896
5276
|
return {
|
|
5277
|
+
kora: routeContext,
|
|
5278
|
+
getLiveBlobRefs() {
|
|
5279
|
+
return syncServer.getLiveBlobRefs();
|
|
5280
|
+
},
|
|
3897
5281
|
async start() {
|
|
3898
5282
|
const { createServer } = await import("http");
|
|
3899
5283
|
const { createReadStream, existsSync, statSync } = await import("fs");
|
|
@@ -3915,7 +5299,7 @@ function createProductionServer(config) {
|
|
|
3915
5299
|
});
|
|
3916
5300
|
async function handleRequest(req, res) {
|
|
3917
5301
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
3918
|
-
res.setHeader("Cross-Origin-Embedder-Policy",
|
|
5302
|
+
res.setHeader("Cross-Origin-Embedder-Policy", crossOriginEmbedderPolicy);
|
|
3919
5303
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
3920
5304
|
if (url.pathname === "/health") {
|
|
3921
5305
|
const status = await syncServer.getStatus();
|
|
@@ -4089,8 +5473,13 @@ data: ${JSON.stringify(s)}
|
|
|
4089
5473
|
socket.destroy();
|
|
4090
5474
|
}
|
|
4091
5475
|
});
|
|
4092
|
-
return new Promise((resolve2) => {
|
|
5476
|
+
return new Promise((resolve2, reject) => {
|
|
5477
|
+
const onError = (error) => {
|
|
5478
|
+
reject(error);
|
|
5479
|
+
};
|
|
5480
|
+
httpServer?.once("error", onError);
|
|
4093
5481
|
httpServer?.listen(port, "0.0.0.0", () => {
|
|
5482
|
+
httpServer?.off("error", onError);
|
|
4094
5483
|
resolve2(`http://localhost:${port}`);
|
|
4095
5484
|
});
|
|
4096
5485
|
});
|
|
@@ -4185,6 +5574,7 @@ function formatUptime(ms) {
|
|
|
4185
5574
|
export {
|
|
4186
5575
|
AwarenessRelay,
|
|
4187
5576
|
ClientSession,
|
|
5577
|
+
DEFAULT_MAX_SCOPE_PREDICATE_VALUES,
|
|
4188
5578
|
HttpServerTransport,
|
|
4189
5579
|
KoraAuthProvider,
|
|
4190
5580
|
KoraSyncServer,
|
|
@@ -4192,6 +5582,7 @@ export {
|
|
|
4192
5582
|
MixedAuthProvider,
|
|
4193
5583
|
NoAuthProvider,
|
|
4194
5584
|
PostgresServerStore,
|
|
5585
|
+
RETRIABLE_REJECTION_CODES,
|
|
4195
5586
|
SqliteServerStore,
|
|
4196
5587
|
TokenAuthProvider,
|
|
4197
5588
|
WsServerTransport,
|
|
@@ -4202,6 +5593,9 @@ export {
|
|
|
4202
5593
|
createPrettyLogger,
|
|
4203
5594
|
createProductionServer,
|
|
4204
5595
|
createSilentLogger,
|
|
4205
|
-
createSqliteServerStore
|
|
5596
|
+
createSqliteServerStore,
|
|
5597
|
+
isRetriableRejection,
|
|
5598
|
+
normalizeScopeMap,
|
|
5599
|
+
operationMatchesScopes
|
|
4206
5600
|
};
|
|
4207
5601
|
//# sourceMappingURL=index.js.map
|