@korajs/server 1.0.0-beta.0 → 1.0.0-beta.10
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 +1957 -219
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +962 -183
- package/dist/index.d.ts +962 -183
- package/dist/index.js +1919 -178
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
package/dist/index.cjs
CHANGED
|
@@ -12,11 +12,11 @@ var __export = (target, all) => {
|
|
|
12
12
|
for (var name in all)
|
|
13
13
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
14
14
|
};
|
|
15
|
-
var __copyProps = (to, from, except,
|
|
15
|
+
var __copyProps = (to, from, except, desc2) => {
|
|
16
16
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
17
17
|
for (let key of __getOwnPropNames(from))
|
|
18
18
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
19
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(
|
|
19
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc2 = __getOwnPropDesc(from, key)) || desc2.enumerable });
|
|
20
20
|
}
|
|
21
21
|
return to;
|
|
22
22
|
};
|
|
@@ -176,6 +176,7 @@ __export(index_exports, {
|
|
|
176
176
|
MixedAuthProvider: () => MixedAuthProvider,
|
|
177
177
|
NoAuthProvider: () => NoAuthProvider,
|
|
178
178
|
PostgresServerStore: () => PostgresServerStore,
|
|
179
|
+
RETRIABLE_REJECTION_CODES: () => RETRIABLE_REJECTION_CODES,
|
|
179
180
|
SqliteServerStore: () => SqliteServerStore,
|
|
180
181
|
TokenAuthProvider: () => TokenAuthProvider,
|
|
181
182
|
WsServerTransport: () => WsServerTransport,
|
|
@@ -186,7 +187,8 @@ __export(index_exports, {
|
|
|
186
187
|
createPrettyLogger: () => createPrettyLogger,
|
|
187
188
|
createProductionServer: () => createProductionServer,
|
|
188
189
|
createSilentLogger: () => createSilentLogger,
|
|
189
|
-
createSqliteServerStore: () => createSqliteServerStore
|
|
190
|
+
createSqliteServerStore: () => createSqliteServerStore,
|
|
191
|
+
isRetriableRejection: () => isRetriableRejection
|
|
190
192
|
});
|
|
191
193
|
module.exports = __toCommonJS(index_exports);
|
|
192
194
|
init_cjs_shims();
|
|
@@ -248,6 +250,13 @@ function createDefaultLogger() {
|
|
|
248
250
|
}
|
|
249
251
|
}
|
|
250
252
|
|
|
253
|
+
// src/apply/rejection-taxonomy.ts
|
|
254
|
+
init_cjs_shims();
|
|
255
|
+
var RETRIABLE_REJECTION_CODES = /* @__PURE__ */ new Set(["RATE_LIMIT"]);
|
|
256
|
+
function isRetriableRejection(code) {
|
|
257
|
+
return RETRIABLE_REJECTION_CODES.has(code);
|
|
258
|
+
}
|
|
259
|
+
|
|
251
260
|
// src/store/memory-server-store.ts
|
|
252
261
|
init_cjs_shims();
|
|
253
262
|
var import_core2 = require("@korajs/core");
|
|
@@ -269,6 +278,14 @@ function fieldTypeToSql(descriptor, dialect) {
|
|
|
269
278
|
return dialect === "postgres" ? "BIGINT" : "INTEGER";
|
|
270
279
|
case "array":
|
|
271
280
|
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
281
|
+
case "object":
|
|
282
|
+
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
283
|
+
case "json":
|
|
284
|
+
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
285
|
+
case "blob":
|
|
286
|
+
return "TEXT";
|
|
287
|
+
case "secret":
|
|
288
|
+
return "TEXT";
|
|
272
289
|
case "richtext":
|
|
273
290
|
return dialect === "postgres" ? "BYTEA" : "BLOB";
|
|
274
291
|
}
|
|
@@ -285,13 +302,13 @@ function generateCollectionDDL(name, collection, dialect) {
|
|
|
285
302
|
const columns = ["id TEXT PRIMARY KEY NOT NULL"];
|
|
286
303
|
for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
|
|
287
304
|
const sqlType = fieldTypeToSql(descriptor, dialect);
|
|
288
|
-
let colDef = `${fieldName} ${sqlType}`;
|
|
305
|
+
let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
|
|
289
306
|
if (descriptor.defaultValue !== void 0) {
|
|
290
307
|
colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
|
|
291
308
|
}
|
|
292
309
|
if (descriptor.kind === "enum" && descriptor.enumValues) {
|
|
293
310
|
const values = descriptor.enumValues.map((v) => `'${v}'`).join(", ");
|
|
294
|
-
colDef += ` CHECK (${fieldName} IN (${values}))`;
|
|
311
|
+
colDef += ` CHECK (${(0, import_core.quoteIdent)(fieldName)} IN (${values}))`;
|
|
295
312
|
}
|
|
296
313
|
columns.push(colDef);
|
|
297
314
|
}
|
|
@@ -299,24 +316,26 @@ function generateCollectionDDL(name, collection, dialect) {
|
|
|
299
316
|
columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
|
|
300
317
|
columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
|
|
301
318
|
columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
|
|
302
|
-
statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
|
|
319
|
+
statements.push(`CREATE TABLE IF NOT EXISTS ${(0, import_core.quoteIdent)(name)} (
|
|
303
320
|
${columns.join(",\n ")}
|
|
304
321
|
)`);
|
|
305
322
|
for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
|
|
306
323
|
const sqlType = fieldTypeToSql(descriptor, dialect);
|
|
307
|
-
let colDef = `${fieldName} ${sqlType}`;
|
|
324
|
+
let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
|
|
308
325
|
if (descriptor.defaultValue !== void 0) {
|
|
309
326
|
colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
|
|
310
327
|
}
|
|
311
328
|
statements.push(`--kora:safe-alter
|
|
312
|
-
ALTER TABLE ${name} ADD COLUMN ${colDef}`);
|
|
329
|
+
ALTER TABLE ${(0, import_core.quoteIdent)(name)} ADD COLUMN ${colDef}`);
|
|
313
330
|
}
|
|
314
331
|
for (const indexField of collection.indexes) {
|
|
315
332
|
statements.push(
|
|
316
|
-
`CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
|
|
333
|
+
`CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${(0, import_core.quoteIdent)(name)} (${(0, import_core.quoteIdent)(indexField)})`
|
|
317
334
|
);
|
|
318
335
|
}
|
|
319
|
-
statements.push(
|
|
336
|
+
statements.push(
|
|
337
|
+
`CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${(0, import_core.quoteIdent)(name)} (_deleted)`
|
|
338
|
+
);
|
|
320
339
|
return statements;
|
|
321
340
|
}
|
|
322
341
|
function generateAllCollectionDDL(schema, dialect) {
|
|
@@ -326,30 +345,6 @@ function generateAllCollectionDDL(schema, dialect) {
|
|
|
326
345
|
}
|
|
327
346
|
return statements;
|
|
328
347
|
}
|
|
329
|
-
function replayOperationsForRecord(ops) {
|
|
330
|
-
let record = null;
|
|
331
|
-
let deleted = false;
|
|
332
|
-
for (const op of ops) {
|
|
333
|
-
switch (op.type) {
|
|
334
|
-
case "insert":
|
|
335
|
-
if (op.data) {
|
|
336
|
-
record = { ...op.data };
|
|
337
|
-
deleted = false;
|
|
338
|
-
}
|
|
339
|
-
break;
|
|
340
|
-
case "update":
|
|
341
|
-
if (op.data) {
|
|
342
|
-
record = { ...record ?? {}, ...op.data };
|
|
343
|
-
deleted = false;
|
|
344
|
-
}
|
|
345
|
-
break;
|
|
346
|
-
case "delete":
|
|
347
|
-
deleted = true;
|
|
348
|
-
break;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
return deleted ? null : record;
|
|
352
|
-
}
|
|
353
348
|
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
354
349
|
var BASE64_REVERSE = new Map(
|
|
355
350
|
Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
|
|
@@ -400,6 +395,9 @@ function serializeFieldValue(value, descriptor) {
|
|
|
400
395
|
if (value === null || value === void 0) return null;
|
|
401
396
|
switch (descriptor.kind) {
|
|
402
397
|
case "array":
|
|
398
|
+
case "object":
|
|
399
|
+
case "json":
|
|
400
|
+
case "blob":
|
|
403
401
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
404
402
|
case "boolean":
|
|
405
403
|
return value ? 1 : 0;
|
|
@@ -413,6 +411,9 @@ function deserializeFieldValue(value, descriptor) {
|
|
|
413
411
|
if (value === null || value === void 0) return null;
|
|
414
412
|
switch (descriptor.kind) {
|
|
415
413
|
case "array":
|
|
414
|
+
case "object":
|
|
415
|
+
case "json":
|
|
416
|
+
case "blob":
|
|
416
417
|
return typeof value === "string" ? JSON.parse(value) : value;
|
|
417
418
|
case "boolean":
|
|
418
419
|
return value === 1 || value === true;
|
|
@@ -445,6 +446,14 @@ var MemoryServerStore = class {
|
|
|
445
446
|
operations = [];
|
|
446
447
|
operationIndex = /* @__PURE__ */ new Map();
|
|
447
448
|
versionVector = /* @__PURE__ */ new Map();
|
|
449
|
+
/**
|
|
450
|
+
* Server-assigned delivery sequence per operation id. Single-process, so a plain
|
|
451
|
+
* counter incremented at insert time gives commit-order delivery sequence with no
|
|
452
|
+
* gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
|
|
453
|
+
* is a forward walk.
|
|
454
|
+
*/
|
|
455
|
+
deliverySeqByOpId = /* @__PURE__ */ new Map();
|
|
456
|
+
deliverySeqCounter = 0;
|
|
448
457
|
schema = null;
|
|
449
458
|
/** Materialized records: collection -> recordId -> record data */
|
|
450
459
|
materializedRecords = /* @__PURE__ */ new Map();
|
|
@@ -478,6 +487,8 @@ var MemoryServerStore = class {
|
|
|
478
487
|
}
|
|
479
488
|
this.operations.push(op);
|
|
480
489
|
this.operationIndex.set(op.id, op);
|
|
490
|
+
this.deliverySeqCounter += 1;
|
|
491
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
481
492
|
const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
|
|
482
493
|
if (op.sequenceNumber > currentSeq) {
|
|
483
494
|
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
@@ -497,6 +508,23 @@ var MemoryServerStore = class {
|
|
|
497
508
|
this.assertOpen();
|
|
498
509
|
return this.operations.length;
|
|
499
510
|
}
|
|
511
|
+
async getMaxDeliverySequence() {
|
|
512
|
+
this.assertOpen();
|
|
513
|
+
return this.deliverySeqCounter;
|
|
514
|
+
}
|
|
515
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
516
|
+
this.assertOpen();
|
|
517
|
+
const result = [];
|
|
518
|
+
if (limit <= 0) return result;
|
|
519
|
+
for (const op of this.operations) {
|
|
520
|
+
const deliverySequence = this.deliverySeqByOpId.get(op.id) ?? 0;
|
|
521
|
+
if (deliverySequence > afterDeliverySequence) {
|
|
522
|
+
result.push({ operation: op, deliverySequence });
|
|
523
|
+
if (result.length >= limit) break;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
500
528
|
async materializeCollection(collection) {
|
|
501
529
|
this.assertOpen();
|
|
502
530
|
if (this.schema?.collections[collection]) {
|
|
@@ -641,12 +669,16 @@ var MemoryServerStore = class {
|
|
|
641
669
|
this.operations.length = 0;
|
|
642
670
|
this.operationIndex.clear();
|
|
643
671
|
this.versionVector.clear();
|
|
672
|
+
this.deliverySeqByOpId.clear();
|
|
673
|
+
this.deliverySeqCounter = 0;
|
|
644
674
|
for (const [nid, seq] of versionVector) {
|
|
645
675
|
this.versionVector.set(nid, seq);
|
|
646
676
|
}
|
|
647
677
|
for (const op of operations2) {
|
|
648
678
|
this.operations.push(op);
|
|
649
679
|
this.operationIndex.set(op.id, op);
|
|
680
|
+
this.deliverySeqCounter += 1;
|
|
681
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
650
682
|
if (this.schema?.collections[op.collection]) {
|
|
651
683
|
this.rebuildMaterializedRecord(op.collection, op.recordId);
|
|
652
684
|
}
|
|
@@ -676,13 +708,14 @@ var MemoryServerStore = class {
|
|
|
676
708
|
return a.timestamp.wallTime - b.timestamp.wallTime;
|
|
677
709
|
if (a.timestamp.logical !== b.timestamp.logical)
|
|
678
710
|
return a.timestamp.logical - b.timestamp.logical;
|
|
679
|
-
return a.
|
|
711
|
+
return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
|
|
680
712
|
});
|
|
681
713
|
const parsedOps = recordOps.map((op) => ({
|
|
682
714
|
type: op.type,
|
|
683
|
-
data: op.data
|
|
715
|
+
data: op.data,
|
|
716
|
+
atomicOps: op.atomicOps ?? null
|
|
684
717
|
}));
|
|
685
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
718
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
686
719
|
if (recordData) {
|
|
687
720
|
const createdAt = recordOps.length > 0 ? recordOps[0].timestamp.wallTime : Date.now();
|
|
688
721
|
const updatedAt = recordOps.length > 0 ? recordOps[recordOps.length - 1].timestamp.wallTime : Date.now();
|
|
@@ -805,6 +838,8 @@ var pgOperations = (0, import_pg_core.pgTable)(
|
|
|
805
838
|
// JSON-serialized, null for deletes
|
|
806
839
|
previousData: (0, import_pg_core.text)("previous_data"),
|
|
807
840
|
// JSON-serialized, null for insert/delete
|
|
841
|
+
atomicOps: (0, import_pg_core.text)("atomic_ops"),
|
|
842
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
808
843
|
wallTime: (0, import_pg_core.bigint)("wall_time", { mode: "number" }).notNull(),
|
|
809
844
|
logical: (0, import_pg_core.integer)("logical").notNull(),
|
|
810
845
|
timestampNodeId: (0, import_pg_core.text)("timestamp_node_id").notNull(),
|
|
@@ -812,12 +847,18 @@ var pgOperations = (0, import_pg_core.pgTable)(
|
|
|
812
847
|
causalDeps: (0, import_pg_core.text)("causal_deps").notNull().default("[]"),
|
|
813
848
|
// JSON array of op IDs
|
|
814
849
|
schemaVersion: (0, import_pg_core.integer)("schema_version").notNull(),
|
|
815
|
-
receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull()
|
|
850
|
+
receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull(),
|
|
851
|
+
// Server-assigned monotonic delivery sequence, ordered by commit (assigned from
|
|
852
|
+
// a counter row locked inside the append transaction, so delivery order equals
|
|
853
|
+
// visibility order even across instances). Drives the gap-free server->client
|
|
854
|
+
// delivery watermark. Nullable only for pre-column rows, backfilled on startup.
|
|
855
|
+
deliverySeq: (0, import_pg_core.bigint)("delivery_seq", { mode: "number" })
|
|
816
856
|
},
|
|
817
857
|
(table) => ({
|
|
818
858
|
nodeSeqIdx: (0, import_pg_core.index)("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
819
859
|
collectionIdx: (0, import_pg_core.index)("idx_pg_collection").on(table.collection),
|
|
820
|
-
receivedIdx: (0, import_pg_core.index)("idx_pg_received").on(table.receivedAt)
|
|
860
|
+
receivedIdx: (0, import_pg_core.index)("idx_pg_received").on(table.receivedAt),
|
|
861
|
+
deliveryIdx: (0, import_pg_core.index)("idx_pg_delivery_seq").on(table.deliverySeq)
|
|
821
862
|
})
|
|
822
863
|
);
|
|
823
864
|
var pgSyncState = (0, import_pg_core.pgTable)("sync_state", {
|
|
@@ -834,15 +875,43 @@ var PostgresServerStore = class {
|
|
|
834
875
|
ready;
|
|
835
876
|
schema = null;
|
|
836
877
|
closed = false;
|
|
837
|
-
|
|
878
|
+
/**
|
|
879
|
+
* Monotonic counter for this node's server-originated sequence numbers. Lazily
|
|
880
|
+
* seeded from the persisted version vector on first use, then advanced in memory
|
|
881
|
+
* (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
|
|
882
|
+
* applies never receive the same number. Null means "not yet seeded"; reset to
|
|
883
|
+
* null after a backup import so it re-seeds from the restored version vector.
|
|
884
|
+
*/
|
|
885
|
+
sequenceCounter = null;
|
|
886
|
+
/**
|
|
887
|
+
* Time source for HLC timestamps on server-originated operations. Injectable so a
|
|
888
|
+
* test can freeze wall-clock time and prove the conditional-apply ordering holds
|
|
889
|
+
* under same-millisecond commits (the case the advisory lock and clock advance
|
|
890
|
+
* exist to make correct). Defaults to the system clock in production.
|
|
891
|
+
*/
|
|
892
|
+
timeSource;
|
|
893
|
+
constructor(db, nodeId, timeSource) {
|
|
838
894
|
this.db = db;
|
|
839
895
|
this.nodeId = nodeId ?? (0, import_core3.generateUUIDv7)();
|
|
896
|
+
this.timeSource = timeSource ?? { now: () => Date.now() };
|
|
840
897
|
this.ready = this.initialize();
|
|
841
898
|
}
|
|
842
899
|
getVersionVector() {
|
|
843
900
|
this.assertOpen();
|
|
844
901
|
return new Map(this.versionVector);
|
|
845
902
|
}
|
|
903
|
+
/**
|
|
904
|
+
* Atomically reserve this node's next server-operation sequence number. The
|
|
905
|
+
* synchronous increment cannot interleave on the single JS thread, so two
|
|
906
|
+
* concurrent conditional applies are always handed distinct numbers.
|
|
907
|
+
*/
|
|
908
|
+
reserveSequenceNumber() {
|
|
909
|
+
if (this.sequenceCounter === null) {
|
|
910
|
+
this.sequenceCounter = this.versionVector.get(this.nodeId) ?? 0;
|
|
911
|
+
}
|
|
912
|
+
this.sequenceCounter += 1;
|
|
913
|
+
return this.sequenceCounter;
|
|
914
|
+
}
|
|
846
915
|
getNodeId() {
|
|
847
916
|
return this.nodeId;
|
|
848
917
|
}
|
|
@@ -880,8 +949,9 @@ var PostgresServerStore = class {
|
|
|
880
949
|
return "duplicate";
|
|
881
950
|
}
|
|
882
951
|
const now = Date.now();
|
|
883
|
-
const row = this.serializeOperation(op, now);
|
|
884
952
|
await this.db.transaction(async (tx) => {
|
|
953
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
954
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
885
955
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
886
956
|
await tx.insert(pgSyncState).values({
|
|
887
957
|
nodeId: op.nodeId,
|
|
@@ -904,6 +974,73 @@ var PostgresServerStore = class {
|
|
|
904
974
|
}
|
|
905
975
|
return "applied";
|
|
906
976
|
}
|
|
977
|
+
/**
|
|
978
|
+
* Conditionally apply operations atomically, serialized across server instances
|
|
979
|
+
* on the target record via a transaction-scoped advisory lock. The lock makes
|
|
980
|
+
* the read-decide-write cycle atomic against concurrent instances, so a cap
|
|
981
|
+
* check like `responseCount < max` cannot be passed by two admissions at once.
|
|
982
|
+
*/
|
|
983
|
+
async applyConditional(input) {
|
|
984
|
+
this.assertOpen();
|
|
985
|
+
await this.ready;
|
|
986
|
+
this.assertSchema();
|
|
987
|
+
this.assertCollection(input.target.collection);
|
|
988
|
+
const schema = this.schema;
|
|
989
|
+
const collectionDef = schema.collections[input.target.collection];
|
|
990
|
+
const lockKey = `kora:${input.target.collection}:${input.target.id}`;
|
|
991
|
+
const result = await this.db.transaction(async (tx) => {
|
|
992
|
+
await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
|
993
|
+
if (input.idempotencyKey) {
|
|
994
|
+
const existing = await tx.execute(
|
|
995
|
+
import_drizzle_orm.sql`SELECT 1 FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(input.idempotencyKey.collection))} WHERE id = ${input.idempotencyKey.id} AND _deleted = 0 LIMIT 1`
|
|
996
|
+
);
|
|
997
|
+
if (existing.length > 0) {
|
|
998
|
+
return { admitted: true, idempotent: true, applied: [] };
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const rows = await tx.execute(
|
|
1002
|
+
import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(input.target.collection))} WHERE id = ${input.target.id} AND _deleted = 0`
|
|
1003
|
+
);
|
|
1004
|
+
const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
|
|
1005
|
+
if (!input.admit(current)) {
|
|
1006
|
+
return { admitted: false, idempotent: false, applied: [] };
|
|
1007
|
+
}
|
|
1008
|
+
const clock = new import_core3.HybridLogicalClock(this.nodeId, this.timeSource);
|
|
1009
|
+
const latest = await this.latestOperationTimestamp(
|
|
1010
|
+
tx,
|
|
1011
|
+
input.target.collection,
|
|
1012
|
+
input.target.id
|
|
1013
|
+
);
|
|
1014
|
+
if (latest) {
|
|
1015
|
+
clock.advanceTo(latest);
|
|
1016
|
+
}
|
|
1017
|
+
const ops = await input.buildOperations(current, { clock });
|
|
1018
|
+
const now = Date.now();
|
|
1019
|
+
for (const op of ops) {
|
|
1020
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
1021
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
1022
|
+
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
1023
|
+
await tx.insert(pgSyncState).values({ nodeId: op.nodeId, maxSequenceNumber: op.sequenceNumber, lastSeenAt: now }).onConflictDoUpdate({
|
|
1024
|
+
target: pgSyncState.nodeId,
|
|
1025
|
+
set: {
|
|
1026
|
+
maxSequenceNumber: import_drizzle_orm.sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
|
|
1027
|
+
lastSeenAt: import_drizzle_orm.sql`${now}`
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
if (this.schema?.collections[op.collection]) {
|
|
1031
|
+
await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return { admitted: true, idempotent: false, applied: ops };
|
|
1035
|
+
});
|
|
1036
|
+
for (const op of result.applied) {
|
|
1037
|
+
const currentMax = this.versionVector.get(op.nodeId) ?? 0;
|
|
1038
|
+
if (op.sequenceNumber > currentMax) {
|
|
1039
|
+
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return result;
|
|
1043
|
+
}
|
|
907
1044
|
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
908
1045
|
this.assertOpen();
|
|
909
1046
|
await this.ready;
|
|
@@ -918,6 +1055,23 @@ var PostgresServerStore = class {
|
|
|
918
1055
|
const result = await this.db.select({ value: (0, import_drizzle_orm.count)() }).from(pgOperations);
|
|
919
1056
|
return result[0]?.value ?? 0;
|
|
920
1057
|
}
|
|
1058
|
+
async getMaxDeliverySequence() {
|
|
1059
|
+
this.assertOpen();
|
|
1060
|
+
await this.ready;
|
|
1061
|
+
const rows = await this.db.execute(
|
|
1062
|
+
import_drizzle_orm.sql`SELECT COALESCE(MAX(delivery_seq), 0) AS m FROM operations`
|
|
1063
|
+
);
|
|
1064
|
+
return Number(rows[0]?.m ?? 0);
|
|
1065
|
+
}
|
|
1066
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
1067
|
+
this.assertOpen();
|
|
1068
|
+
await this.ready;
|
|
1069
|
+
const rows = await this.db.select().from(pgOperations).where((0, import_drizzle_orm.gt)(pgOperations.deliverySeq, afterDeliverySequence)).orderBy((0, import_drizzle_orm.asc)(pgOperations.deliverySeq)).limit(limit);
|
|
1070
|
+
return rows.map((row) => ({
|
|
1071
|
+
operation: this.deserializeOperation(row),
|
|
1072
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
1073
|
+
}));
|
|
1074
|
+
}
|
|
921
1075
|
async materializeCollection(collection) {
|
|
922
1076
|
this.assertOpen();
|
|
923
1077
|
await this.ready;
|
|
@@ -952,7 +1106,7 @@ var PostgresServerStore = class {
|
|
|
952
1106
|
this.assertCollection(collection);
|
|
953
1107
|
const schema = this.schema;
|
|
954
1108
|
const collectionDef = schema.collections[collection];
|
|
955
|
-
const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
|
|
1109
|
+
const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
|
|
956
1110
|
const rows = await this.db.execute(query);
|
|
957
1111
|
if (rows.length === 0) return null;
|
|
958
1112
|
return this.deserializeRow(rows[0], collectionDef);
|
|
@@ -969,7 +1123,7 @@ var PostgresServerStore = class {
|
|
|
969
1123
|
}
|
|
970
1124
|
}
|
|
971
1125
|
const whereClause = this.buildWhereClause(where ?? {}, false);
|
|
972
|
-
const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`;
|
|
1126
|
+
const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`;
|
|
973
1127
|
const rows = await this.db.execute(query);
|
|
974
1128
|
const cnt = rows[0]?.cnt;
|
|
975
1129
|
return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
|
|
@@ -981,7 +1135,7 @@ var PostgresServerStore = class {
|
|
|
981
1135
|
this.assertOpen();
|
|
982
1136
|
await this.ready;
|
|
983
1137
|
const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
984
|
-
const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.
|
|
1138
|
+
const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.deliverySeq));
|
|
985
1139
|
const operations2 = rows.map((row) => this.deserializeOperation(row));
|
|
986
1140
|
return buildServerBackup2(this.nodeId, operations2, this.versionVector);
|
|
987
1141
|
}
|
|
@@ -996,6 +1150,7 @@ var PostgresServerStore = class {
|
|
|
996
1150
|
const result = await this.applyRemoteOperation(op);
|
|
997
1151
|
if (result === "applied") restored++;
|
|
998
1152
|
}
|
|
1153
|
+
this.sequenceCounter = null;
|
|
999
1154
|
return { operationsRestored: restored, success: true };
|
|
1000
1155
|
}
|
|
1001
1156
|
const now = Date.now();
|
|
@@ -1005,20 +1160,49 @@ var PostgresServerStore = class {
|
|
|
1005
1160
|
for (const [nid, seq] of versionVector) {
|
|
1006
1161
|
await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
|
|
1007
1162
|
}
|
|
1163
|
+
let deliverySeq = 0;
|
|
1008
1164
|
for (const op of operations2) {
|
|
1009
|
-
|
|
1165
|
+
deliverySeq += 1;
|
|
1166
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
1010
1167
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
1011
1168
|
}
|
|
1169
|
+
await tx.execute(
|
|
1170
|
+
import_drizzle_orm.sql`INSERT INTO delivery_counter (id, value) VALUES (1, ${deliverySeq})
|
|
1171
|
+
ON CONFLICT (id) DO UPDATE SET value = ${deliverySeq}`
|
|
1172
|
+
);
|
|
1012
1173
|
});
|
|
1013
1174
|
this.versionVector.clear();
|
|
1014
1175
|
for (const [nid, seq] of versionVector) {
|
|
1015
1176
|
this.versionVector.set(nid, seq);
|
|
1016
1177
|
}
|
|
1178
|
+
this.sequenceCounter = null;
|
|
1017
1179
|
return { operationsRestored: operations2.length, success: true };
|
|
1018
1180
|
}
|
|
1019
1181
|
// ---------------------------------------------------------------------------
|
|
1020
1182
|
// Materialization internals
|
|
1021
1183
|
// ---------------------------------------------------------------------------
|
|
1184
|
+
/**
|
|
1185
|
+
* The HLC timestamp of the most recent operation on a record, by total order
|
|
1186
|
+
* (wallTime, logical, nodeId), or null when the record has no operations. Used
|
|
1187
|
+
* by {@link applyConditional} to advance its clock past the record's latest
|
|
1188
|
+
* write so newly built operations sort strictly after it.
|
|
1189
|
+
*/
|
|
1190
|
+
async latestOperationTimestamp(txOrDb, collection, recordId) {
|
|
1191
|
+
const rows = await txOrDb.select({
|
|
1192
|
+
wallTime: pgOperations.wallTime,
|
|
1193
|
+
logical: pgOperations.logical,
|
|
1194
|
+
timestampNodeId: pgOperations.timestampNodeId
|
|
1195
|
+
}).from(pgOperations).where((0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(pgOperations.collection, collection), (0, import_drizzle_orm.eq)(pgOperations.recordId, recordId))).orderBy(
|
|
1196
|
+
(0, import_drizzle_orm.desc)(pgOperations.wallTime),
|
|
1197
|
+
(0, import_drizzle_orm.desc)(pgOperations.logical),
|
|
1198
|
+
(0, import_drizzle_orm.desc)(pgOperations.timestampNodeId)
|
|
1199
|
+
).limit(1);
|
|
1200
|
+
const row = rows[0];
|
|
1201
|
+
if (!row) {
|
|
1202
|
+
return null;
|
|
1203
|
+
}
|
|
1204
|
+
return { wallTime: row.wallTime, logical: row.logical, nodeId: row.timestampNodeId };
|
|
1205
|
+
}
|
|
1022
1206
|
/**
|
|
1023
1207
|
* Rebuild a single record in the materialized collection table by replaying
|
|
1024
1208
|
* all operations for that record.
|
|
@@ -1029,17 +1213,19 @@ var PostgresServerStore = class {
|
|
|
1029
1213
|
const ops = await txOrDb.select({
|
|
1030
1214
|
type: pgOperations.type,
|
|
1031
1215
|
data: pgOperations.data,
|
|
1216
|
+
atomicOps: pgOperations.atomicOps,
|
|
1032
1217
|
wallTime: pgOperations.wallTime
|
|
1033
1218
|
}).from(pgOperations).where((0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(pgOperations.collection, collection), (0, import_drizzle_orm.eq)(pgOperations.recordId, recordId))).orderBy(
|
|
1034
1219
|
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
1035
1220
|
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
1036
|
-
(0, import_drizzle_orm.asc)(pgOperations.
|
|
1221
|
+
(0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
|
|
1037
1222
|
);
|
|
1038
1223
|
const parsedOps = ops.map((op) => ({
|
|
1039
1224
|
type: op.type,
|
|
1040
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1225
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1226
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1041
1227
|
}));
|
|
1042
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1228
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1043
1229
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
1044
1230
|
if (recordData) {
|
|
1045
1231
|
const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
|
|
@@ -1056,7 +1242,7 @@ var PostgresServerStore = class {
|
|
|
1056
1242
|
);
|
|
1057
1243
|
} else {
|
|
1058
1244
|
await txOrDb.execute(
|
|
1059
|
-
import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1245
|
+
import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1060
1246
|
);
|
|
1061
1247
|
}
|
|
1062
1248
|
}
|
|
@@ -1075,16 +1261,16 @@ var PostgresServerStore = class {
|
|
|
1075
1261
|
updatedAt,
|
|
1076
1262
|
0
|
|
1077
1263
|
];
|
|
1078
|
-
const columnsSql = import_drizzle_orm.sql.raw(allColumns.join(", "));
|
|
1264
|
+
const columnsSql = import_drizzle_orm.sql.raw(allColumns.map((c) => (0, import_core3.quoteIdent)(c)).join(", "));
|
|
1079
1265
|
const valuesSql = import_drizzle_orm.sql.join(
|
|
1080
1266
|
values.map((v) => import_drizzle_orm.sql`${v}`),
|
|
1081
1267
|
import_drizzle_orm.sql.raw(", ")
|
|
1082
1268
|
);
|
|
1083
1269
|
const updateSet = import_drizzle_orm.sql.raw(
|
|
1084
|
-
allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
|
|
1270
|
+
allColumns.slice(1).map((c) => `${(0, import_core3.quoteIdent)(c)} = excluded.${(0, import_core3.quoteIdent)(c)}`).join(", ")
|
|
1085
1271
|
);
|
|
1086
1272
|
await txOrDb.execute(
|
|
1087
|
-
import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1273
|
+
import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1088
1274
|
);
|
|
1089
1275
|
}
|
|
1090
1276
|
/**
|
|
@@ -1106,11 +1292,12 @@ var PostgresServerStore = class {
|
|
|
1106
1292
|
recordId: pgOperations.recordId,
|
|
1107
1293
|
type: pgOperations.type,
|
|
1108
1294
|
data: pgOperations.data,
|
|
1295
|
+
atomicOps: pgOperations.atomicOps,
|
|
1109
1296
|
wallTime: pgOperations.wallTime
|
|
1110
1297
|
}).from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collectionName)).orderBy(
|
|
1111
1298
|
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
1112
1299
|
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
1113
|
-
(0, import_drizzle_orm.asc)(pgOperations.
|
|
1300
|
+
(0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
|
|
1114
1301
|
);
|
|
1115
1302
|
if (allOps.length === 0) return;
|
|
1116
1303
|
const grouped = /* @__PURE__ */ new Map();
|
|
@@ -1126,9 +1313,10 @@ var PostgresServerStore = class {
|
|
|
1126
1313
|
for (const [recordId, recordOps] of grouped) {
|
|
1127
1314
|
const parsedOps = recordOps.map((op) => ({
|
|
1128
1315
|
type: op.type,
|
|
1129
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1316
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1317
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1130
1318
|
}));
|
|
1131
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1319
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1132
1320
|
if (recordData) {
|
|
1133
1321
|
const createdAt = recordOps[0].wallTime;
|
|
1134
1322
|
const updatedAt = recordOps[recordOps.length - 1].wallTime;
|
|
@@ -1144,7 +1332,7 @@ var PostgresServerStore = class {
|
|
|
1144
1332
|
);
|
|
1145
1333
|
} else {
|
|
1146
1334
|
await this.db.execute(
|
|
1147
|
-
import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.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()}`
|
|
1335
|
+
import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(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()}`
|
|
1148
1336
|
);
|
|
1149
1337
|
}
|
|
1150
1338
|
}
|
|
@@ -1157,10 +1345,12 @@ var PostgresServerStore = class {
|
|
|
1157
1345
|
options?.where ?? {},
|
|
1158
1346
|
options?.includeDeleted ?? false
|
|
1159
1347
|
);
|
|
1160
|
-
const parts = [
|
|
1348
|
+
const parts = [
|
|
1349
|
+
import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`
|
|
1350
|
+
];
|
|
1161
1351
|
if (options?.orderBy) {
|
|
1162
1352
|
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
1163
|
-
parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
1353
|
+
parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${(0, import_core3.quoteIdent)(options.orderBy)} ${dir}`));
|
|
1164
1354
|
}
|
|
1165
1355
|
if (options?.limit !== void 0) {
|
|
1166
1356
|
parts.push(import_drizzle_orm.sql` LIMIT ${options.limit}`);
|
|
@@ -1176,7 +1366,7 @@ var PostgresServerStore = class {
|
|
|
1176
1366
|
conditions.push(import_drizzle_orm.sql.raw("_deleted = 0"));
|
|
1177
1367
|
}
|
|
1178
1368
|
for (const [key, value] of Object.entries(where)) {
|
|
1179
|
-
conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw(key)} = ${value}`);
|
|
1369
|
+
conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(key))} = ${value}`);
|
|
1180
1370
|
}
|
|
1181
1371
|
if (conditions.length === 0) {
|
|
1182
1372
|
return import_drizzle_orm.sql.raw("1 = 1");
|
|
@@ -1249,44 +1439,92 @@ var PostgresServerStore = class {
|
|
|
1249
1439
|
}
|
|
1250
1440
|
}
|
|
1251
1441
|
async ensureTables() {
|
|
1252
|
-
await this.db.
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1442
|
+
await this.db.transaction(async (tx) => {
|
|
1443
|
+
await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended('kora:ensure-tables', 0))`);
|
|
1444
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1445
|
+
CREATE TABLE IF NOT EXISTS operations (
|
|
1446
|
+
id TEXT PRIMARY KEY,
|
|
1447
|
+
node_id TEXT NOT NULL,
|
|
1448
|
+
type TEXT NOT NULL,
|
|
1449
|
+
collection TEXT NOT NULL,
|
|
1450
|
+
record_id TEXT NOT NULL,
|
|
1451
|
+
data TEXT,
|
|
1452
|
+
previous_data TEXT,
|
|
1453
|
+
atomic_ops TEXT,
|
|
1454
|
+
wall_time BIGINT NOT NULL,
|
|
1455
|
+
logical INTEGER NOT NULL,
|
|
1456
|
+
timestamp_node_id TEXT NOT NULL,
|
|
1457
|
+
sequence_number INTEGER NOT NULL,
|
|
1458
|
+
causal_deps TEXT NOT NULL DEFAULT '[]',
|
|
1459
|
+
schema_version INTEGER NOT NULL,
|
|
1460
|
+
received_at BIGINT NOT NULL
|
|
1461
|
+
)
|
|
1462
|
+
`);
|
|
1463
|
+
await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS atomic_ops TEXT`);
|
|
1464
|
+
await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS delivery_seq BIGINT`);
|
|
1465
|
+
await tx.execute(
|
|
1466
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
|
|
1467
|
+
);
|
|
1468
|
+
await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
|
|
1469
|
+
await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
|
|
1470
|
+
await tx.execute(
|
|
1471
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)`
|
|
1472
|
+
);
|
|
1473
|
+
await tx.execute(
|
|
1474
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
|
|
1475
|
+
);
|
|
1476
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1477
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
1478
|
+
node_id TEXT PRIMARY KEY,
|
|
1479
|
+
max_sequence_number INTEGER NOT NULL,
|
|
1480
|
+
last_seen_at BIGINT NOT NULL
|
|
1481
|
+
)
|
|
1482
|
+
`);
|
|
1483
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1484
|
+
CREATE TABLE IF NOT EXISTS delivery_counter (
|
|
1485
|
+
id INTEGER PRIMARY KEY,
|
|
1486
|
+
value BIGINT NOT NULL
|
|
1487
|
+
)
|
|
1488
|
+
`);
|
|
1489
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1490
|
+
WITH ordered AS (
|
|
1491
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
1492
|
+
ORDER BY received_at ASC, sequence_number ASC, id ASC
|
|
1493
|
+
) + COALESCE((SELECT MAX(delivery_seq) FROM operations), 0) AS rn
|
|
1494
|
+
FROM operations WHERE delivery_seq IS NULL
|
|
1495
|
+
)
|
|
1496
|
+
UPDATE operations o SET delivery_seq = ordered.rn
|
|
1497
|
+
FROM ordered WHERE o.id = ordered.id
|
|
1498
|
+
`);
|
|
1499
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1500
|
+
INSERT INTO delivery_counter (id, value)
|
|
1501
|
+
VALUES (1, COALESCE((SELECT MAX(delivery_seq) FROM operations), 0))
|
|
1502
|
+
ON CONFLICT (id) DO UPDATE
|
|
1503
|
+
SET value = GREATEST(delivery_counter.value, EXCLUDED.value)
|
|
1504
|
+
`);
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Reserve the next delivery sequence inside an open append transaction. The
|
|
1509
|
+
* `UPDATE ... RETURNING` locks the counter row until this transaction commits,
|
|
1510
|
+
* serializing assignment into commit order (see the counter table comment).
|
|
1511
|
+
*/
|
|
1512
|
+
async nextDeliverySeq(tx) {
|
|
1513
|
+
const rows = await tx.execute(
|
|
1514
|
+
import_drizzle_orm.sql`UPDATE delivery_counter SET value = value + 1 WHERE id = 1 RETURNING value`
|
|
1277
1515
|
);
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1516
|
+
const value = rows[0]?.value;
|
|
1517
|
+
if (value === void 0 || value === null) {
|
|
1518
|
+
throw new Error(
|
|
1519
|
+
"delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
return Number(value);
|
|
1285
1523
|
}
|
|
1286
1524
|
// ---------------------------------------------------------------------------
|
|
1287
1525
|
// Operation serialization
|
|
1288
1526
|
// ---------------------------------------------------------------------------
|
|
1289
|
-
serializeOperation(op, receivedAt) {
|
|
1527
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1290
1528
|
return {
|
|
1291
1529
|
id: op.id,
|
|
1292
1530
|
nodeId: op.nodeId,
|
|
@@ -1295,16 +1533,19 @@ var PostgresServerStore = class {
|
|
|
1295
1533
|
recordId: op.recordId,
|
|
1296
1534
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1297
1535
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
1536
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1298
1537
|
wallTime: op.timestamp.wallTime,
|
|
1299
1538
|
logical: op.timestamp.logical,
|
|
1300
1539
|
timestampNodeId: op.timestamp.nodeId,
|
|
1301
1540
|
sequenceNumber: op.sequenceNumber,
|
|
1302
1541
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1303
1542
|
schemaVersion: op.schemaVersion,
|
|
1304
|
-
receivedAt
|
|
1543
|
+
receivedAt,
|
|
1544
|
+
deliverySeq
|
|
1305
1545
|
};
|
|
1306
1546
|
}
|
|
1307
1547
|
deserializeOperation(row) {
|
|
1548
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1308
1549
|
return {
|
|
1309
1550
|
id: row.id,
|
|
1310
1551
|
nodeId: row.nodeId,
|
|
@@ -1320,7 +1561,8 @@ var PostgresServerStore = class {
|
|
|
1320
1561
|
},
|
|
1321
1562
|
sequenceNumber: row.sequenceNumber,
|
|
1322
1563
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1323
|
-
schemaVersion: row.schemaVersion
|
|
1564
|
+
schemaVersion: row.schemaVersion,
|
|
1565
|
+
...atomicOps ? { atomicOps } : {}
|
|
1324
1566
|
};
|
|
1325
1567
|
}
|
|
1326
1568
|
// ---------------------------------------------------------------------------
|
|
@@ -1390,6 +1632,8 @@ var operations = (0, import_sqlite_core.sqliteTable)(
|
|
|
1390
1632
|
// JSON-serialized, null for deletes
|
|
1391
1633
|
previousData: (0, import_sqlite_core.text)("previous_data"),
|
|
1392
1634
|
// JSON-serialized, null for insert/delete
|
|
1635
|
+
atomicOps: (0, import_sqlite_core.text)("atomic_ops"),
|
|
1636
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
1393
1637
|
wallTime: (0, import_sqlite_core.integer)("wall_time").notNull(),
|
|
1394
1638
|
logical: (0, import_sqlite_core.integer)("logical").notNull(),
|
|
1395
1639
|
timestampNodeId: (0, import_sqlite_core.text)("timestamp_node_id").notNull(),
|
|
@@ -1397,12 +1641,17 @@ var operations = (0, import_sqlite_core.sqliteTable)(
|
|
|
1397
1641
|
causalDeps: (0, import_sqlite_core.text)("causal_deps").notNull().default("[]"),
|
|
1398
1642
|
// JSON array of op IDs
|
|
1399
1643
|
schemaVersion: (0, import_sqlite_core.integer)("schema_version").notNull(),
|
|
1400
|
-
receivedAt: (0, import_sqlite_core.integer)("received_at").notNull()
|
|
1644
|
+
receivedAt: (0, import_sqlite_core.integer)("received_at").notNull(),
|
|
1645
|
+
// Server-assigned monotonic delivery sequence, ordered by commit. Drives the
|
|
1646
|
+
// gap-free server->client delivery watermark. Nullable only for rows written
|
|
1647
|
+
// before the column existed; those are backfilled on startup.
|
|
1648
|
+
deliverySeq: (0, import_sqlite_core.integer)("delivery_seq")
|
|
1401
1649
|
},
|
|
1402
1650
|
(table) => ({
|
|
1403
1651
|
nodeSeqIdx: (0, import_sqlite_core.index)("idx_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
1404
1652
|
collectionIdx: (0, import_sqlite_core.index)("idx_collection").on(table.collection),
|
|
1405
|
-
receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt)
|
|
1653
|
+
receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt),
|
|
1654
|
+
deliveryIdx: (0, import_sqlite_core.index)("idx_delivery_seq").on(table.deliverySeq)
|
|
1406
1655
|
})
|
|
1407
1656
|
);
|
|
1408
1657
|
var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
|
|
@@ -1410,6 +1659,10 @@ var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
|
|
|
1410
1659
|
maxSequenceNumber: (0, import_sqlite_core.integer)("max_sequence_number").notNull(),
|
|
1411
1660
|
lastSeenAt: (0, import_sqlite_core.integer)("last_seen_at").notNull()
|
|
1412
1661
|
});
|
|
1662
|
+
var deliveryCounter = (0, import_sqlite_core.sqliteTable)("delivery_counter", {
|
|
1663
|
+
id: (0, import_sqlite_core.integer)("id").primaryKey(),
|
|
1664
|
+
value: (0, import_sqlite_core.integer)("value").notNull()
|
|
1665
|
+
});
|
|
1413
1666
|
|
|
1414
1667
|
// src/store/sqlite-server-store.ts
|
|
1415
1668
|
var esmRequire = (0, import_node_module.createRequire)(importMetaUrl);
|
|
@@ -1463,12 +1716,15 @@ var SqliteServerStore = class {
|
|
|
1463
1716
|
async applyRemoteOperation(op) {
|
|
1464
1717
|
this.assertOpen();
|
|
1465
1718
|
const now = Date.now();
|
|
1466
|
-
const row = this.serializeOperation(op, now);
|
|
1467
1719
|
const result = this.db.transaction((tx) => {
|
|
1468
|
-
const
|
|
1469
|
-
|
|
1720
|
+
const existing = tx.all(
|
|
1721
|
+
import_drizzle_orm2.sql`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
|
|
1722
|
+
);
|
|
1723
|
+
if (existing.length > 0) {
|
|
1470
1724
|
return "duplicate";
|
|
1471
1725
|
}
|
|
1726
|
+
const row = this.serializeOperation(op, now, this.nextDeliverySeq(tx));
|
|
1727
|
+
tx.insert(operations).values(row).run();
|
|
1472
1728
|
tx.insert(syncState).values({
|
|
1473
1729
|
nodeId: op.nodeId,
|
|
1474
1730
|
maxSequenceNumber: op.sequenceNumber,
|
|
@@ -1497,6 +1753,21 @@ var SqliteServerStore = class {
|
|
|
1497
1753
|
const result = this.db.select({ value: (0, import_drizzle_orm2.count)() }).from(operations).all();
|
|
1498
1754
|
return result[0]?.value ?? 0;
|
|
1499
1755
|
}
|
|
1756
|
+
async getMaxDeliverySequence() {
|
|
1757
|
+
this.assertOpen();
|
|
1758
|
+
const rows = this.db.all(
|
|
1759
|
+
import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
1760
|
+
);
|
|
1761
|
+
return rows[0]?.m ?? 0;
|
|
1762
|
+
}
|
|
1763
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
1764
|
+
this.assertOpen();
|
|
1765
|
+
const rows = this.db.select().from(operations).where((0, import_drizzle_orm2.gt)(operations.deliverySeq, afterDeliverySequence)).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).limit(limit).all();
|
|
1766
|
+
return rows.map((row) => ({
|
|
1767
|
+
operation: this.deserializeOperation(row),
|
|
1768
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
1769
|
+
}));
|
|
1770
|
+
}
|
|
1500
1771
|
async materializeCollection(collection) {
|
|
1501
1772
|
this.assertOpen();
|
|
1502
1773
|
if (this.schema?.collections[collection]) {
|
|
@@ -1528,7 +1799,7 @@ var SqliteServerStore = class {
|
|
|
1528
1799
|
this.assertCollection(collection);
|
|
1529
1800
|
const schema = this.schema;
|
|
1530
1801
|
const collectionDef = schema.collections[collection];
|
|
1531
|
-
const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
|
|
1802
|
+
const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
|
|
1532
1803
|
const rows = this.db.all(query);
|
|
1533
1804
|
if (rows.length === 0) return null;
|
|
1534
1805
|
return this.deserializeRow(rows[0], collectionDef);
|
|
@@ -1544,7 +1815,7 @@ var SqliteServerStore = class {
|
|
|
1544
1815
|
}
|
|
1545
1816
|
}
|
|
1546
1817
|
const whereClause = this.buildWhereClause(where ?? {}, false);
|
|
1547
|
-
const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`;
|
|
1818
|
+
const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`;
|
|
1548
1819
|
const rows = this.db.all(query);
|
|
1549
1820
|
return rows[0]?.cnt ?? 0;
|
|
1550
1821
|
}
|
|
@@ -1554,7 +1825,7 @@ var SqliteServerStore = class {
|
|
|
1554
1825
|
async exportBackup() {
|
|
1555
1826
|
this.assertOpen();
|
|
1556
1827
|
const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
1557
|
-
const rows = this.db.select().from(operations).all();
|
|
1828
|
+
const rows = this.db.select().from(operations).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).all();
|
|
1558
1829
|
const deserialized = rows.map((row) => this.deserializeOperation(row));
|
|
1559
1830
|
const vv = this.getVersionVector();
|
|
1560
1831
|
return buildServerBackup2(this.nodeId, deserialized, vv);
|
|
@@ -1574,13 +1845,17 @@ var SqliteServerStore = class {
|
|
|
1574
1845
|
this.db.transaction((tx) => {
|
|
1575
1846
|
tx.run(import_drizzle_orm2.sql.raw("DELETE FROM operations"));
|
|
1576
1847
|
tx.run(import_drizzle_orm2.sql.raw("DELETE FROM sync_state"));
|
|
1848
|
+
tx.update(deliveryCounter).set({ value: 0 }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).run();
|
|
1577
1849
|
for (const [nid, seq] of versionVector) {
|
|
1578
1850
|
tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
|
|
1579
1851
|
}
|
|
1852
|
+
let deliverySeq = 0;
|
|
1580
1853
|
for (const op of ops) {
|
|
1581
|
-
|
|
1854
|
+
deliverySeq += 1;
|
|
1855
|
+
const row = this.serializeOperation(op, Date.now(), deliverySeq);
|
|
1582
1856
|
tx.insert(operations).values(row).run();
|
|
1583
1857
|
}
|
|
1858
|
+
tx.update(deliveryCounter).set({ value: deliverySeq }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).run();
|
|
1584
1859
|
});
|
|
1585
1860
|
return { operationsRestored: ops.length, success: true };
|
|
1586
1861
|
}
|
|
@@ -1598,13 +1873,15 @@ var SqliteServerStore = class {
|
|
|
1598
1873
|
const ops = txOrDb.select({
|
|
1599
1874
|
type: operations.type,
|
|
1600
1875
|
data: operations.data,
|
|
1876
|
+
atomicOps: operations.atomicOps,
|
|
1601
1877
|
wallTime: operations.wallTime
|
|
1602
|
-
}).from(operations).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(operations.collection, collection), (0, import_drizzle_orm2.eq)(operations.recordId, recordId))).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.
|
|
1878
|
+
}).from(operations).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(operations.collection, collection), (0, import_drizzle_orm2.eq)(operations.recordId, recordId))).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.timestampNodeId)).all();
|
|
1603
1879
|
const parsedOps = ops.map((op) => ({
|
|
1604
1880
|
type: op.type,
|
|
1605
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1881
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1882
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1606
1883
|
}));
|
|
1607
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1884
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1608
1885
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
1609
1886
|
if (recordData) {
|
|
1610
1887
|
const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
|
|
@@ -1621,7 +1898,7 @@ var SqliteServerStore = class {
|
|
|
1621
1898
|
);
|
|
1622
1899
|
} else {
|
|
1623
1900
|
txOrDb.run(
|
|
1624
|
-
import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1901
|
+
import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1625
1902
|
);
|
|
1626
1903
|
}
|
|
1627
1904
|
}
|
|
@@ -1642,16 +1919,16 @@ var SqliteServerStore = class {
|
|
|
1642
1919
|
0
|
|
1643
1920
|
// _deleted = false
|
|
1644
1921
|
];
|
|
1645
|
-
const columnsSql = import_drizzle_orm2.sql.raw(allColumns.join(", "));
|
|
1922
|
+
const columnsSql = import_drizzle_orm2.sql.raw(allColumns.map((c) => (0, import_core4.quoteIdent)(c)).join(", "));
|
|
1646
1923
|
const valuesSql = import_drizzle_orm2.sql.join(
|
|
1647
1924
|
values.map((v) => import_drizzle_orm2.sql`${v}`),
|
|
1648
1925
|
import_drizzle_orm2.sql.raw(", ")
|
|
1649
1926
|
);
|
|
1650
1927
|
const updateSet = import_drizzle_orm2.sql.raw(
|
|
1651
|
-
allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
|
|
1928
|
+
allColumns.slice(1).map((c) => `${(0, import_core4.quoteIdent)(c)} = excluded.${(0, import_core4.quoteIdent)(c)}`).join(", ")
|
|
1652
1929
|
);
|
|
1653
1930
|
txOrDb.run(
|
|
1654
|
-
import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1931
|
+
import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1655
1932
|
);
|
|
1656
1933
|
}
|
|
1657
1934
|
/**
|
|
@@ -1674,8 +1951,9 @@ var SqliteServerStore = class {
|
|
|
1674
1951
|
recordId: operations.recordId,
|
|
1675
1952
|
type: operations.type,
|
|
1676
1953
|
data: operations.data,
|
|
1954
|
+
atomicOps: operations.atomicOps,
|
|
1677
1955
|
wallTime: operations.wallTime
|
|
1678
|
-
}).from(operations).where((0, import_drizzle_orm2.eq)(operations.collection, collectionName)).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.
|
|
1956
|
+
}).from(operations).where((0, import_drizzle_orm2.eq)(operations.collection, collectionName)).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.timestampNodeId)).all();
|
|
1679
1957
|
if (allOps.length === 0) return;
|
|
1680
1958
|
const grouped = /* @__PURE__ */ new Map();
|
|
1681
1959
|
for (const op of allOps) {
|
|
@@ -1691,9 +1969,10 @@ var SqliteServerStore = class {
|
|
|
1691
1969
|
for (const [recordId, recordOps] of grouped) {
|
|
1692
1970
|
const parsedOps = recordOps.map((op) => ({
|
|
1693
1971
|
type: op.type,
|
|
1694
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1972
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1973
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1695
1974
|
}));
|
|
1696
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1975
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1697
1976
|
if (recordData) {
|
|
1698
1977
|
const createdAt = recordOps[0].wallTime;
|
|
1699
1978
|
const updatedAt = recordOps[recordOps.length - 1].wallTime;
|
|
@@ -1709,7 +1988,7 @@ var SqliteServerStore = class {
|
|
|
1709
1988
|
);
|
|
1710
1989
|
} else {
|
|
1711
1990
|
tx.run(
|
|
1712
|
-
import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.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()}`
|
|
1991
|
+
import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(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()}`
|
|
1713
1992
|
);
|
|
1714
1993
|
}
|
|
1715
1994
|
}
|
|
@@ -1723,10 +2002,12 @@ var SqliteServerStore = class {
|
|
|
1723
2002
|
options?.where ?? {},
|
|
1724
2003
|
options?.includeDeleted ?? false
|
|
1725
2004
|
);
|
|
1726
|
-
const parts = [
|
|
2005
|
+
const parts = [
|
|
2006
|
+
import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`
|
|
2007
|
+
];
|
|
1727
2008
|
if (options?.orderBy) {
|
|
1728
2009
|
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
1729
|
-
parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
2010
|
+
parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${(0, import_core4.quoteIdent)(options.orderBy)} ${dir}`));
|
|
1730
2011
|
}
|
|
1731
2012
|
if (options?.limit !== void 0) {
|
|
1732
2013
|
parts.push(import_drizzle_orm2.sql` LIMIT ${options.limit}`);
|
|
@@ -1742,7 +2023,7 @@ var SqliteServerStore = class {
|
|
|
1742
2023
|
conditions.push(import_drizzle_orm2.sql.raw("_deleted = 0"));
|
|
1743
2024
|
}
|
|
1744
2025
|
for (const [key, value] of Object.entries(where)) {
|
|
1745
|
-
conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw(key)} = ${value}`);
|
|
2026
|
+
conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(key))} = ${value}`);
|
|
1746
2027
|
}
|
|
1747
2028
|
if (conditions.length === 0) {
|
|
1748
2029
|
return import_drizzle_orm2.sql.raw("1 = 1");
|
|
@@ -1813,6 +2094,7 @@ var SqliteServerStore = class {
|
|
|
1813
2094
|
record_id TEXT NOT NULL,
|
|
1814
2095
|
data TEXT,
|
|
1815
2096
|
previous_data TEXT,
|
|
2097
|
+
atomic_ops TEXT,
|
|
1816
2098
|
wall_time INTEGER NOT NULL,
|
|
1817
2099
|
logical INTEGER NOT NULL,
|
|
1818
2100
|
timestamp_node_id TEXT NOT NULL,
|
|
@@ -1822,9 +2104,35 @@ var SqliteServerStore = class {
|
|
|
1822
2104
|
received_at INTEGER NOT NULL
|
|
1823
2105
|
)
|
|
1824
2106
|
`);
|
|
2107
|
+
try {
|
|
2108
|
+
this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
|
|
2109
|
+
} catch (e) {
|
|
2110
|
+
const msg = e instanceof Error ? e.message : "";
|
|
2111
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
2112
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
2113
|
+
throw e;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
try {
|
|
2117
|
+
this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
|
|
2118
|
+
} catch (e) {
|
|
2119
|
+
const msg = e instanceof Error ? e.message : "";
|
|
2120
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
2121
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
2122
|
+
throw e;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
1825
2125
|
this.db.run(import_drizzle_orm2.sql`
|
|
1826
2126
|
CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
|
|
1827
2127
|
`);
|
|
2128
|
+
this.db.run(import_drizzle_orm2.sql`
|
|
2129
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
|
|
2130
|
+
`);
|
|
2131
|
+
this.db.run(import_drizzle_orm2.sql`
|
|
2132
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_seq_unique
|
|
2133
|
+
ON operations (delivery_seq)
|
|
2134
|
+
WHERE delivery_seq IS NOT NULL
|
|
2135
|
+
`);
|
|
1828
2136
|
this.db.run(import_drizzle_orm2.sql`
|
|
1829
2137
|
CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
|
|
1830
2138
|
`);
|
|
@@ -1841,11 +2149,60 @@ var SqliteServerStore = class {
|
|
|
1841
2149
|
last_seen_at INTEGER NOT NULL
|
|
1842
2150
|
)
|
|
1843
2151
|
`);
|
|
2152
|
+
this.db.run(import_drizzle_orm2.sql`
|
|
2153
|
+
CREATE TABLE IF NOT EXISTS delivery_counter (
|
|
2154
|
+
id INTEGER PRIMARY KEY,
|
|
2155
|
+
value INTEGER NOT NULL
|
|
2156
|
+
)
|
|
2157
|
+
`);
|
|
2158
|
+
this.backfillDeliverySequence();
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Assign delivery sequences to any operations written before the column existed,
|
|
2162
|
+
* then seed the durable counter from the current max. Ordered by
|
|
2163
|
+
* (received_at, sequence_number, id) so the backfill is deterministic and stable
|
|
2164
|
+
* across restarts. A one-time migration: after it runs, all rows have a value.
|
|
2165
|
+
*/
|
|
2166
|
+
backfillDeliverySequence() {
|
|
2167
|
+
this.db.transaction((tx) => {
|
|
2168
|
+
const maxRow = tx.all(
|
|
2169
|
+
import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
2170
|
+
);
|
|
2171
|
+
let next = maxRow[0]?.m ?? 0;
|
|
2172
|
+
const nullRows = tx.all(
|
|
2173
|
+
import_drizzle_orm2.sql`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
|
|
2174
|
+
);
|
|
2175
|
+
for (const row of nullRows) {
|
|
2176
|
+
next += 1;
|
|
2177
|
+
tx.run(import_drizzle_orm2.sql`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
|
|
2178
|
+
}
|
|
2179
|
+
tx.insert(deliveryCounter).values({ id: 1, value: next }).onConflictDoUpdate({
|
|
2180
|
+
target: deliveryCounter.id,
|
|
2181
|
+
set: {
|
|
2182
|
+
value: import_drizzle_orm2.sql`MAX(${deliveryCounter.value}, ${next})`
|
|
2183
|
+
}
|
|
2184
|
+
}).run();
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Reserve the next delivery sequence inside the append transaction. SQLite locks
|
|
2189
|
+
* the database for the write transaction, and this counter row keeps sequence
|
|
2190
|
+
* allocation shared by every SqliteServerStore instance using the same file.
|
|
2191
|
+
*/
|
|
2192
|
+
nextDeliverySeq(tx) {
|
|
2193
|
+
const rows = tx.update(deliveryCounter).set({ value: import_drizzle_orm2.sql`${deliveryCounter.value} + 1` }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).returning({ value: deliveryCounter.value }).all();
|
|
2194
|
+
const value = rows[0]?.value;
|
|
2195
|
+
if (value === void 0 || value === null) {
|
|
2196
|
+
throw new Error(
|
|
2197
|
+
"delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2200
|
+
return value;
|
|
1844
2201
|
}
|
|
1845
2202
|
// ---------------------------------------------------------------------------
|
|
1846
2203
|
// Operation serialization
|
|
1847
2204
|
// ---------------------------------------------------------------------------
|
|
1848
|
-
serializeOperation(op, receivedAt) {
|
|
2205
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1849
2206
|
return {
|
|
1850
2207
|
id: op.id,
|
|
1851
2208
|
nodeId: op.nodeId,
|
|
@@ -1854,16 +2211,19 @@ var SqliteServerStore = class {
|
|
|
1854
2211
|
recordId: op.recordId,
|
|
1855
2212
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1856
2213
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
2214
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1857
2215
|
wallTime: op.timestamp.wallTime,
|
|
1858
2216
|
logical: op.timestamp.logical,
|
|
1859
2217
|
timestampNodeId: op.timestamp.nodeId,
|
|
1860
2218
|
sequenceNumber: op.sequenceNumber,
|
|
1861
2219
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1862
2220
|
schemaVersion: op.schemaVersion,
|
|
1863
|
-
receivedAt
|
|
2221
|
+
receivedAt,
|
|
2222
|
+
deliverySeq
|
|
1864
2223
|
};
|
|
1865
2224
|
}
|
|
1866
2225
|
deserializeOperation(row) {
|
|
2226
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1867
2227
|
return {
|
|
1868
2228
|
id: row.id,
|
|
1869
2229
|
nodeId: row.nodeId,
|
|
@@ -1879,7 +2239,8 @@ var SqliteServerStore = class {
|
|
|
1879
2239
|
},
|
|
1880
2240
|
sequenceNumber: row.sequenceNumber,
|
|
1881
2241
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1882
|
-
schemaVersion: row.schemaVersion
|
|
2242
|
+
schemaVersion: row.schemaVersion,
|
|
2243
|
+
...atomicOps ? { atomicOps } : {}
|
|
1883
2244
|
};
|
|
1884
2245
|
}
|
|
1885
2246
|
// ---------------------------------------------------------------------------
|
|
@@ -2065,8 +2426,10 @@ var WsServerTransport = class {
|
|
|
2065
2426
|
// src/session/client-session.ts
|
|
2066
2427
|
init_cjs_shims();
|
|
2067
2428
|
var import_core10 = require("@korajs/core");
|
|
2429
|
+
var import_core11 = require("@korajs/core");
|
|
2068
2430
|
var import_internal = require("@korajs/core/internal");
|
|
2069
2431
|
var import_sync2 = require("@korajs/sync");
|
|
2432
|
+
var import_sync3 = require("@korajs/sync");
|
|
2070
2433
|
|
|
2071
2434
|
// src/apply/apply-server-operation.ts
|
|
2072
2435
|
init_cjs_shims();
|
|
@@ -2177,6 +2540,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
|
|
|
2177
2540
|
);
|
|
2178
2541
|
}
|
|
2179
2542
|
function nextServerSequenceNumber(store) {
|
|
2543
|
+
if (store.reserveSequenceNumber) {
|
|
2544
|
+
return store.reserveSequenceNumber();
|
|
2545
|
+
}
|
|
2180
2546
|
const nodeId = store.getNodeId();
|
|
2181
2547
|
const current = store.getVersionVector().get(nodeId) ?? 0;
|
|
2182
2548
|
return current + 1;
|
|
@@ -2186,14 +2552,28 @@ function nextServerSequenceNumber(store) {
|
|
|
2186
2552
|
async function applyServerOperation(store, op, relationLookup) {
|
|
2187
2553
|
const schema = store.getSchema();
|
|
2188
2554
|
const lookup = relationLookup ?? (schema ? (0, import_merge2.buildMergeRelationLookup)(schema) : /* @__PURE__ */ new Map());
|
|
2555
|
+
const shapeCheck = validateOperationShape(op, schema);
|
|
2556
|
+
if (!shapeCheck.valid) {
|
|
2557
|
+
return {
|
|
2558
|
+
result: "skipped",
|
|
2559
|
+
appliedOperations: [],
|
|
2560
|
+
rejection: {
|
|
2561
|
+
code: shapeCheck.code ?? "SCHEMA_VALIDATION_ERROR",
|
|
2562
|
+
message: shapeCheck.message ?? `Operation "${op.id}" does not match the server schema`,
|
|
2563
|
+
retriable: false
|
|
2564
|
+
}
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2189
2567
|
const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
|
|
2190
2568
|
if (!constraintCheck.valid) {
|
|
2569
|
+
const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
|
|
2191
2570
|
return {
|
|
2192
2571
|
result: "skipped",
|
|
2193
2572
|
appliedOperations: [],
|
|
2194
2573
|
rejection: {
|
|
2195
|
-
code
|
|
2196
|
-
message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint
|
|
2574
|
+
code,
|
|
2575
|
+
message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`,
|
|
2576
|
+
retriable: isRetriableRejection(code)
|
|
2197
2577
|
}
|
|
2198
2578
|
};
|
|
2199
2579
|
}
|
|
@@ -2206,7 +2586,8 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2206
2586
|
appliedOperations: [],
|
|
2207
2587
|
rejection: {
|
|
2208
2588
|
code: "REFERENTIAL_INTEGRITY",
|
|
2209
|
-
message: `Operation "${op.id}" violates referential integrity on "${op.collection}"
|
|
2589
|
+
message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`,
|
|
2590
|
+
retriable: isRetriableRejection("REFERENTIAL_INTEGRITY")
|
|
2210
2591
|
}
|
|
2211
2592
|
};
|
|
2212
2593
|
}
|
|
@@ -2215,16 +2596,14 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2215
2596
|
return { result: primaryResult, appliedOperations: [] };
|
|
2216
2597
|
}
|
|
2217
2598
|
const appliedOperations = [op];
|
|
2218
|
-
let serverSeq = nextServerSequenceNumber(store);
|
|
2219
2599
|
for (const effect of referential.sideEffectOps) {
|
|
2220
2600
|
const sideOp = await createServerSideEffectOperation(
|
|
2221
2601
|
store,
|
|
2222
2602
|
op,
|
|
2223
2603
|
effect,
|
|
2224
2604
|
op.schemaVersion,
|
|
2225
|
-
|
|
2605
|
+
nextServerSequenceNumber(store)
|
|
2226
2606
|
);
|
|
2227
|
-
serverSeq += 1;
|
|
2228
2607
|
const sideResult = await store.applyRemoteOperation(sideOp);
|
|
2229
2608
|
if (sideResult === "applied") {
|
|
2230
2609
|
appliedOperations.push(sideOp);
|
|
@@ -2238,6 +2617,75 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2238
2617
|
appliedOperations: result === "applied" ? [op] : []
|
|
2239
2618
|
};
|
|
2240
2619
|
}
|
|
2620
|
+
function validateOperationShape(op, schema) {
|
|
2621
|
+
if (!schema) {
|
|
2622
|
+
return { valid: true };
|
|
2623
|
+
}
|
|
2624
|
+
const collection = schema.collections[op.collection];
|
|
2625
|
+
if (!collection) {
|
|
2626
|
+
return {
|
|
2627
|
+
valid: false,
|
|
2628
|
+
code: "UNKNOWN_COLLECTION",
|
|
2629
|
+
message: `Operation "${op.id}" targets unknown collection "${op.collection}".`
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
const declaredFields = new Set(Object.keys(collection.fields));
|
|
2633
|
+
const systemPreviousFields = /* @__PURE__ */ new Set(["id", "_created_at", "_updated_at", "_deleted"]);
|
|
2634
|
+
const invalidDataField = firstInvalidField(op.data, declaredFields);
|
|
2635
|
+
if (invalidDataField) {
|
|
2636
|
+
return {
|
|
2637
|
+
valid: false,
|
|
2638
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2639
|
+
message: `Operation "${op.id}" contains undeclared field "${invalidDataField}" in data for collection "${op.collection}".`
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
const invalidPreviousField = firstInvalidField(
|
|
2643
|
+
op.previousData,
|
|
2644
|
+
declaredFields,
|
|
2645
|
+
systemPreviousFields
|
|
2646
|
+
);
|
|
2647
|
+
if (invalidPreviousField) {
|
|
2648
|
+
return {
|
|
2649
|
+
valid: false,
|
|
2650
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2651
|
+
message: `Operation "${op.id}" contains undeclared field "${invalidPreviousField}" in previousData for collection "${op.collection}".`
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
if (op.atomicOps) {
|
|
2655
|
+
for (const field of Object.keys(op.atomicOps)) {
|
|
2656
|
+
if (!declaredFields.has(field)) {
|
|
2657
|
+
return {
|
|
2658
|
+
valid: false,
|
|
2659
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2660
|
+
message: `Operation "${op.id}" contains undeclared atomic field "${field}" for collection "${op.collection}".`
|
|
2661
|
+
};
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
return { valid: true };
|
|
2666
|
+
}
|
|
2667
|
+
function firstInvalidField(value, declaredFields, extraAllowedFields) {
|
|
2668
|
+
if (value === null || value === void 0) {
|
|
2669
|
+
return null;
|
|
2670
|
+
}
|
|
2671
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
2672
|
+
return null;
|
|
2673
|
+
}
|
|
2674
|
+
for (const field of Object.keys(value)) {
|
|
2675
|
+
if (!declaredFields.has(field) && !extraAllowedFields?.has(field)) {
|
|
2676
|
+
return field;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
return null;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
// src/auth/no-auth.ts
|
|
2683
|
+
init_cjs_shims();
|
|
2684
|
+
var NoAuthProvider = class {
|
|
2685
|
+
async authenticate(_token) {
|
|
2686
|
+
return { userId: "anonymous" };
|
|
2687
|
+
}
|
|
2688
|
+
};
|
|
2241
2689
|
|
|
2242
2690
|
// src/scopes/resolve-session-scopes.ts
|
|
2243
2691
|
init_cjs_shims();
|
|
@@ -2277,11 +2725,12 @@ function mergeScopeMaps(base, overlay, source) {
|
|
|
2277
2725
|
|
|
2278
2726
|
// src/scopes/server-scope-filter.ts
|
|
2279
2727
|
init_cjs_shims();
|
|
2280
|
-
function operationMatchesScopes(op, scopes) {
|
|
2728
|
+
function operationMatchesScopes(op, scopes, fullRecord) {
|
|
2281
2729
|
if (!scopes) return true;
|
|
2282
2730
|
const collectionScope = scopes[op.collection];
|
|
2283
2731
|
if (!collectionScope) return false;
|
|
2284
|
-
|
|
2732
|
+
if (Object.keys(collectionScope).length === 0) return true;
|
|
2733
|
+
const snapshot = buildSnapshot(op, fullRecord);
|
|
2285
2734
|
if (!snapshot) return false;
|
|
2286
2735
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
2287
2736
|
if (snapshot[field] !== expected) {
|
|
@@ -2290,11 +2739,20 @@ function operationMatchesScopes(op, scopes) {
|
|
|
2290
2739
|
}
|
|
2291
2740
|
return true;
|
|
2292
2741
|
}
|
|
2293
|
-
function
|
|
2742
|
+
function missingScopeFields(op, scopes) {
|
|
2743
|
+
if (!scopes) return [];
|
|
2744
|
+
const collectionScope = scopes[op.collection];
|
|
2745
|
+
if (!collectionScope) return [];
|
|
2746
|
+
if (Object.keys(collectionScope).length === 0) return [];
|
|
2747
|
+
const snapshot = buildSnapshot(op);
|
|
2748
|
+
return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
|
|
2749
|
+
}
|
|
2750
|
+
function buildSnapshot(op, fullRecord) {
|
|
2294
2751
|
const previous = asRecord(op.previousData);
|
|
2295
2752
|
const next = asRecord(op.data);
|
|
2296
|
-
if (!previous && !next) return null;
|
|
2753
|
+
if (!previous && !next && !fullRecord) return null;
|
|
2297
2754
|
return {
|
|
2755
|
+
...fullRecord ?? {},
|
|
2298
2756
|
...previous ?? {},
|
|
2299
2757
|
...next ?? {}
|
|
2300
2758
|
};
|
|
@@ -2368,35 +2826,114 @@ var SessionRateLimiter = class {
|
|
|
2368
2826
|
// src/session/client-session.ts
|
|
2369
2827
|
var DEFAULT_BATCH_SIZE = 100;
|
|
2370
2828
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
2371
|
-
var
|
|
2829
|
+
var warnedUnscopedProviders = /* @__PURE__ */ new WeakSet();
|
|
2830
|
+
function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
|
|
2831
|
+
if (!auth || auth instanceof NoAuthProvider) {
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
if (resolvedScopes) {
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
if (!schema || Object.keys(schema.collections).length === 0) {
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2840
|
+
if (warnedUnscopedProviders.has(auth)) {
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
warnedUnscopedProviders.add(auth);
|
|
2844
|
+
console.warn(
|
|
2845
|
+
"[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.)"
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2848
|
+
function stableStringify(value) {
|
|
2849
|
+
if (Array.isArray(value)) {
|
|
2850
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
2851
|
+
}
|
|
2852
|
+
if (value && typeof value === "object") {
|
|
2853
|
+
const record = value;
|
|
2854
|
+
const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
|
|
2855
|
+
return `{${entries.join(",")}}`;
|
|
2856
|
+
}
|
|
2857
|
+
return JSON.stringify(value);
|
|
2858
|
+
}
|
|
2859
|
+
function sameScopeMap(a, b) {
|
|
2860
|
+
return stableStringify(a ?? null) === stableStringify(b ?? null);
|
|
2861
|
+
}
|
|
2862
|
+
var ClientSession = class _ClientSession {
|
|
2372
2863
|
state = "connected";
|
|
2373
2864
|
clientNodeId = null;
|
|
2374
2865
|
authContext = null;
|
|
2375
2866
|
syncQuerySubsets = [];
|
|
2376
2867
|
resumeDeltaCursor = null;
|
|
2868
|
+
/**
|
|
2869
|
+
* The delivery watermark the client reported at handshake, or null when the client
|
|
2870
|
+
* does not use the delivery watermark (old client, or no watermark yet). When set,
|
|
2871
|
+
* the server drives the gap-free server->client stream from delivery sequences
|
|
2872
|
+
* instead of the version-vector delta.
|
|
2873
|
+
*/
|
|
2874
|
+
clientDeliveryWatermark = null;
|
|
2875
|
+
/**
|
|
2876
|
+
* The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
|
|
2877
|
+
* watermark, as reported in acks). Incremental streaming pushes resume from here, not
|
|
2878
|
+
* from the highest sequence sent, so a dropped or unapplied batch is always
|
|
2879
|
+
* re-included by the next push (which re-scans from the acknowledged position). This
|
|
2880
|
+
* is what makes streaming recovery independent of any bounded retransmit buffer: there
|
|
2881
|
+
* is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
|
|
2882
|
+
* client's reported watermark at handshake.
|
|
2883
|
+
*/
|
|
2884
|
+
lastAckedDeliverySeq = 0;
|
|
2885
|
+
/**
|
|
2886
|
+
* Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
|
|
2887
|
+
* this as its stale window so a slow client does not receive duplicate full-stream
|
|
2888
|
+
* batches every server tick while the first batch is still being applied.
|
|
2889
|
+
*/
|
|
2890
|
+
lastDeliveryPushAttemptAtMs = 0;
|
|
2891
|
+
deliveryStallRepeatCount = 0;
|
|
2892
|
+
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
2893
|
+
deliveryPushChain = Promise.resolve();
|
|
2894
|
+
/**
|
|
2895
|
+
* Relay batches sent to this client but not yet acknowledged, keyed by messageId.
|
|
2896
|
+
* The client acks every operation-batch it applies; on ack the entry is cleared.
|
|
2897
|
+
* {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
|
|
2898
|
+
* dropped by a lossy transport is redelivered instead of leaving the client with a
|
|
2899
|
+
* permanent version-vector gap (a lost operation) that delta sync cannot recover.
|
|
2900
|
+
* Bounded so a silent client cannot grow it without limit.
|
|
2901
|
+
*/
|
|
2902
|
+
pendingRelays = /* @__PURE__ */ new Map();
|
|
2903
|
+
static MAX_PENDING_RELAYS = 1e3;
|
|
2377
2904
|
sessionId;
|
|
2378
2905
|
transport;
|
|
2379
2906
|
store;
|
|
2380
2907
|
auth;
|
|
2381
2908
|
serializer;
|
|
2382
2909
|
emitter;
|
|
2910
|
+
logger;
|
|
2383
2911
|
batchSize;
|
|
2384
2912
|
schemaVersion;
|
|
2385
2913
|
supportedSchemaVersions;
|
|
2914
|
+
operationTransforms;
|
|
2386
2915
|
onRelay;
|
|
2387
2916
|
onAwarenessUpdate;
|
|
2388
2917
|
onYjsDocUpdate;
|
|
2918
|
+
onBlobChunkRequest;
|
|
2919
|
+
onBlobChunkResponse;
|
|
2920
|
+
persistBlobChunk;
|
|
2389
2921
|
onClose;
|
|
2922
|
+
onOrphanedRelays;
|
|
2923
|
+
takeOrphanedRelays;
|
|
2390
2924
|
maxOperationBytes;
|
|
2391
2925
|
maxOpsPerMinute;
|
|
2392
2926
|
rateLimiter;
|
|
2927
|
+
validateOperation;
|
|
2928
|
+
koraContext;
|
|
2393
2929
|
constructor(options) {
|
|
2394
2930
|
this.sessionId = options.sessionId;
|
|
2395
2931
|
this.transport = options.transport;
|
|
2396
2932
|
this.store = options.store;
|
|
2397
2933
|
this.auth = options.auth ?? null;
|
|
2398
|
-
this.serializer = options.serializer ?? new
|
|
2934
|
+
this.serializer = options.serializer ?? new import_sync3.NegotiatedMessageSerializer("json");
|
|
2399
2935
|
this.emitter = options.emitter ?? null;
|
|
2936
|
+
this.logger = options.logger ?? null;
|
|
2400
2937
|
this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2401
2938
|
this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
2402
2939
|
const supported = options.supportedSchemaVersions;
|
|
@@ -2404,13 +2941,21 @@ var ClientSession = class {
|
|
|
2404
2941
|
min: this.schemaVersion,
|
|
2405
2942
|
max: this.schemaVersion
|
|
2406
2943
|
};
|
|
2944
|
+
this.operationTransforms = options.operationTransforms ?? [];
|
|
2407
2945
|
this.onRelay = options.onRelay ?? null;
|
|
2408
2946
|
this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
|
|
2409
2947
|
this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
|
|
2948
|
+
this.onBlobChunkRequest = options.onBlobChunkRequest ?? null;
|
|
2949
|
+
this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
|
|
2950
|
+
this.persistBlobChunk = options.persistBlobChunk ?? null;
|
|
2410
2951
|
this.onClose = options.onClose ?? null;
|
|
2952
|
+
this.onOrphanedRelays = options.onOrphanedRelays ?? null;
|
|
2953
|
+
this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
|
|
2411
2954
|
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2412
2955
|
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
2413
2956
|
this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
|
|
2957
|
+
this.validateOperation = options.validateOperation ?? null;
|
|
2958
|
+
this.koraContext = options.koraContext ?? null;
|
|
2414
2959
|
}
|
|
2415
2960
|
/**
|
|
2416
2961
|
* Start handling messages from the client transport.
|
|
@@ -2430,39 +2975,143 @@ var ClientSession = class {
|
|
|
2430
2975
|
*/
|
|
2431
2976
|
relayOperations(operations2) {
|
|
2432
2977
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2978
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
2979
|
+
this.pushDeliveryStream();
|
|
2980
|
+
return;
|
|
2981
|
+
}
|
|
2433
2982
|
if (operations2.length === 0) return;
|
|
2434
|
-
|
|
2983
|
+
void this.relayVisibleOperations(operations2);
|
|
2984
|
+
}
|
|
2985
|
+
/**
|
|
2986
|
+
* Push newly-available in-scope operations to a delivery-watermark client, resuming
|
|
2987
|
+
* from the highest delivery sequence already sent. Pushes are serialized so their
|
|
2988
|
+
* batches never interleave (which would break the base/max chain).
|
|
2989
|
+
*/
|
|
2990
|
+
pushDeliveryStream() {
|
|
2991
|
+
this.deliveryPushChain = this.deliveryPushChain.then(async () => {
|
|
2992
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2993
|
+
try {
|
|
2994
|
+
this.lastDeliveryPushAttemptAtMs = Date.now();
|
|
2995
|
+
await this.sendDeliveryStream(
|
|
2996
|
+
this.lastAckedDeliverySeq,
|
|
2997
|
+
false,
|
|
2998
|
+
this.clientNodeId ?? void 0
|
|
2999
|
+
);
|
|
3000
|
+
} catch {
|
|
3001
|
+
}
|
|
3002
|
+
});
|
|
3003
|
+
}
|
|
3004
|
+
async relayVisibleOperations(operations2) {
|
|
3005
|
+
const visibleOperations = [];
|
|
3006
|
+
for (const op of operations2) {
|
|
3007
|
+
if (await this.operationVisibleToClient(op)) {
|
|
3008
|
+
visibleOperations.push(op);
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
2435
3011
|
if (visibleOperations.length === 0) return;
|
|
3012
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2436
3013
|
const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
|
|
2437
3014
|
const msg = {
|
|
2438
3015
|
type: "operation-batch",
|
|
2439
|
-
messageId: (0,
|
|
3016
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2440
3017
|
operations: serializedOps,
|
|
2441
3018
|
isFinal: true,
|
|
2442
3019
|
batchIndex: 0
|
|
2443
3020
|
};
|
|
3021
|
+
this.trackPendingRelay(msg, visibleOperations);
|
|
2444
3022
|
this.sendToClient(msg);
|
|
2445
3023
|
}
|
|
3024
|
+
/** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
|
|
3025
|
+
trackPendingRelay(msg, ops) {
|
|
3026
|
+
if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
|
|
3027
|
+
const oldest = this.pendingRelays.keys().next().value;
|
|
3028
|
+
if (oldest !== void 0) {
|
|
3029
|
+
this.pendingRelays.delete(oldest);
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
|
|
3033
|
+
}
|
|
2446
3034
|
/**
|
|
2447
|
-
*
|
|
3035
|
+
* Retransmit relay batches this client has not acknowledged within `staleMs`.
|
|
3036
|
+
* Called on a periodic tick by the server (and directly by tests). Redelivering an
|
|
3037
|
+
* already-applied op is harmless: the client dedups by content-addressed id.
|
|
2448
3038
|
*/
|
|
2449
|
-
|
|
2450
|
-
if (this.state
|
|
2451
|
-
this.
|
|
2452
|
-
|
|
2453
|
-
|
|
3039
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
3040
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
3041
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
if (this.pendingRelays.size === 0) return;
|
|
3045
|
+
const cutoff = Date.now() - staleMs;
|
|
3046
|
+
for (const { message, sentAtMs } of this.pendingRelays.values()) {
|
|
3047
|
+
if (sentAtMs <= cutoff) {
|
|
3048
|
+
this.sendToClient(message);
|
|
3049
|
+
}
|
|
2454
3050
|
}
|
|
2455
|
-
this.onClose?.(this.sessionId);
|
|
2456
|
-
}
|
|
2457
|
-
// --- Getters ---
|
|
2458
|
-
getState() {
|
|
2459
|
-
return this.state;
|
|
2460
|
-
}
|
|
2461
|
-
getSessionId() {
|
|
2462
|
-
return this.sessionId;
|
|
2463
3051
|
}
|
|
2464
|
-
|
|
2465
|
-
|
|
3052
|
+
/**
|
|
3053
|
+
* Wake the durable delivery stream for clients that negotiated delivery
|
|
3054
|
+
* watermarks. This is used both for dropped watermark batches and for operations
|
|
3055
|
+
* appended by another server/store instance: the session always scans from its
|
|
3056
|
+
* own acknowledged cursor and applies its visibility filter before sending.
|
|
3057
|
+
*/
|
|
3058
|
+
pushDeliveryStreamIfSupported(staleMs = 0, options = {}) {
|
|
3059
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
3060
|
+
if (this.clientDeliveryWatermark === null) return;
|
|
3061
|
+
if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
if (options.trackStall) {
|
|
3065
|
+
this.deliveryStallRepeatCount += 1;
|
|
3066
|
+
if (this.deliveryStallRepeatCount >= 3) {
|
|
3067
|
+
this.emitter?.emit({
|
|
3068
|
+
type: "sync:delivery-stalled",
|
|
3069
|
+
sessionId: this.sessionId,
|
|
3070
|
+
watermark: this.lastAckedDeliverySeq,
|
|
3071
|
+
repeatCount: this.deliveryStallRepeatCount
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
this.pushDeliveryStream();
|
|
3076
|
+
}
|
|
3077
|
+
/**
|
|
3078
|
+
* Close this session.
|
|
3079
|
+
*/
|
|
3080
|
+
/**
|
|
3081
|
+
* Hand any unacknowledged relays to the server so they survive this session and are
|
|
3082
|
+
* redelivered when this client reconnects (per-node buffer), then clear them. Called
|
|
3083
|
+
* from every session-teardown path (explicit close and transport close).
|
|
3084
|
+
*/
|
|
3085
|
+
flushOrphanedRelays() {
|
|
3086
|
+
if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
|
|
3087
|
+
const ops = [];
|
|
3088
|
+
for (const entry of this.pendingRelays.values()) {
|
|
3089
|
+
ops.push(...entry.ops);
|
|
3090
|
+
}
|
|
3091
|
+
if (ops.length > 0) {
|
|
3092
|
+
this.onOrphanedRelays(this.clientNodeId, ops);
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
this.pendingRelays.clear();
|
|
3096
|
+
}
|
|
3097
|
+
close(reason) {
|
|
3098
|
+
if (this.state === "closed") return;
|
|
3099
|
+
this.state = "closed";
|
|
3100
|
+
this.flushOrphanedRelays();
|
|
3101
|
+
if (this.transport.isConnected()) {
|
|
3102
|
+
this.transport.close(1e3, reason ?? "session closed");
|
|
3103
|
+
}
|
|
3104
|
+
this.onClose?.(this.sessionId);
|
|
3105
|
+
}
|
|
3106
|
+
// --- Getters ---
|
|
3107
|
+
getState() {
|
|
3108
|
+
return this.state;
|
|
3109
|
+
}
|
|
3110
|
+
getSessionId() {
|
|
3111
|
+
return this.sessionId;
|
|
3112
|
+
}
|
|
3113
|
+
getClientNodeId() {
|
|
3114
|
+
return this.clientNodeId;
|
|
2466
3115
|
}
|
|
2467
3116
|
getAuthContext() {
|
|
2468
3117
|
return this.authContext;
|
|
@@ -2503,6 +3152,11 @@ var ClientSession = class {
|
|
|
2503
3152
|
await this.handleOperationBatch(message);
|
|
2504
3153
|
break;
|
|
2505
3154
|
case "acknowledgment":
|
|
3155
|
+
this.pendingRelays.delete(message.acknowledgedMessageId);
|
|
3156
|
+
if (message.deliverySequence !== void 0) {
|
|
3157
|
+
this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
|
|
3158
|
+
this.deliveryStallRepeatCount = 0;
|
|
3159
|
+
}
|
|
2506
3160
|
break;
|
|
2507
3161
|
case "error":
|
|
2508
3162
|
break;
|
|
@@ -2512,10 +3166,47 @@ var ClientSession = class {
|
|
|
2512
3166
|
case "yjs-doc-update":
|
|
2513
3167
|
this.handleYjsDocUpdate(message);
|
|
2514
3168
|
break;
|
|
3169
|
+
case "blob-chunk-request":
|
|
3170
|
+
this.onBlobChunkRequest?.(this.sessionId, message);
|
|
3171
|
+
break;
|
|
3172
|
+
case "blob-chunk-response":
|
|
3173
|
+
this.onBlobChunkResponse?.(this.sessionId, message);
|
|
3174
|
+
break;
|
|
3175
|
+
case "blob-chunk-push":
|
|
3176
|
+
await this.handleBlobChunkPush(message);
|
|
3177
|
+
break;
|
|
2515
3178
|
}
|
|
2516
3179
|
}
|
|
3180
|
+
/**
|
|
3181
|
+
* Persist a client-uploaded blob chunk (or manifest) centrally. The bytes are
|
|
3182
|
+
* verified to hash to the declared hash before storing, so a corrupt or
|
|
3183
|
+
* mislabeled upload is rejected rather than served later as trusted content.
|
|
3184
|
+
*/
|
|
3185
|
+
async handleBlobChunkPush(message) {
|
|
3186
|
+
if (!this.persistBlobChunk) {
|
|
3187
|
+
return;
|
|
3188
|
+
}
|
|
3189
|
+
const bytes = (0, import_sync2.decodeBlobChunkBytes)(message.bytes);
|
|
3190
|
+
const actual = await (0, import_core11.hashBlob)(bytes);
|
|
3191
|
+
if (actual !== message.hash) {
|
|
3192
|
+
return;
|
|
3193
|
+
}
|
|
3194
|
+
await this.persistBlobChunk(message.hash, bytes);
|
|
3195
|
+
}
|
|
2517
3196
|
handleMessageFailure(error) {
|
|
2518
3197
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
3198
|
+
this.logger?.log({
|
|
3199
|
+
timestamp: Date.now(),
|
|
3200
|
+
level: "error",
|
|
3201
|
+
event: "session.message_failed",
|
|
3202
|
+
sessionId: this.sessionId,
|
|
3203
|
+
nodeId: this.clientNodeId ?? void 0,
|
|
3204
|
+
error: reason,
|
|
3205
|
+
details: {
|
|
3206
|
+
state: this.state,
|
|
3207
|
+
errorName: error instanceof Error ? error.name : typeof error
|
|
3208
|
+
}
|
|
3209
|
+
});
|
|
2519
3210
|
this.sendError("SYNC_ERROR", reason, true);
|
|
2520
3211
|
this.close(reason);
|
|
2521
3212
|
}
|
|
@@ -2547,25 +3238,46 @@ var ClientSession = class {
|
|
|
2547
3238
|
this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
|
|
2548
3239
|
}
|
|
2549
3240
|
}
|
|
3241
|
+
warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
|
|
2550
3242
|
if (msg.syncQueries && msg.syncQueries.length > 0) {
|
|
2551
|
-
this.syncQuerySubsets = (0,
|
|
3243
|
+
this.syncQuerySubsets = (0, import_sync3.dedupeQuerySubsets)(msg.syncQueries);
|
|
2552
3244
|
} else {
|
|
2553
3245
|
this.syncQuerySubsets = [];
|
|
2554
3246
|
}
|
|
2555
|
-
this.resumeDeltaCursor = msg.deltaCursor ? (0,
|
|
3247
|
+
this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
|
|
3248
|
+
this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
|
|
3249
|
+
if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
|
|
3250
|
+
this.clientDeliveryWatermark = 0;
|
|
3251
|
+
}
|
|
3252
|
+
const clientUsesWatermark = this.clientDeliveryWatermark !== null;
|
|
3253
|
+
let serverMaxDelivery = 0;
|
|
3254
|
+
if (clientUsesWatermark) {
|
|
3255
|
+
serverMaxDelivery = await this.store.getMaxDeliverySequence();
|
|
3256
|
+
if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
|
|
3257
|
+
this.clientDeliveryWatermark = 0;
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
2556
3260
|
const serverVector = this.store.getVersionVector();
|
|
2557
3261
|
const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
|
|
2558
3262
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
2559
|
-
if (!(0,
|
|
3263
|
+
if (!(0, import_sync3.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
|
|
2560
3264
|
const { min, max } = this.supportedSchemaVersions;
|
|
3265
|
+
this.emitter?.emit({
|
|
3266
|
+
type: "sync:schema-mismatch",
|
|
3267
|
+
clientSchemaVersion: msg.schemaVersion,
|
|
3268
|
+
serverSchemaVersion: this.schemaVersion,
|
|
3269
|
+
supportedMin: min,
|
|
3270
|
+
supportedMax: max,
|
|
3271
|
+
reason: `${import_sync3.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`
|
|
3272
|
+
});
|
|
2561
3273
|
const response2 = {
|
|
2562
3274
|
type: "handshake-response",
|
|
2563
|
-
messageId: (0,
|
|
3275
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2564
3276
|
nodeId: this.store.getNodeId(),
|
|
2565
|
-
versionVector: (0,
|
|
3277
|
+
versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
|
|
2566
3278
|
schemaVersion: this.schemaVersion,
|
|
2567
3279
|
accepted: false,
|
|
2568
|
-
rejectReason: `${
|
|
3280
|
+
rejectReason: `${import_sync3.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`,
|
|
2569
3281
|
supportedSchemaMin: min,
|
|
2570
3282
|
supportedSchemaMax: max,
|
|
2571
3283
|
serverTime: Date.now()
|
|
@@ -2576,13 +3288,20 @@ var ClientSession = class {
|
|
|
2576
3288
|
}
|
|
2577
3289
|
const response = {
|
|
2578
3290
|
type: "handshake-response",
|
|
2579
|
-
messageId: (0,
|
|
3291
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2580
3292
|
nodeId: this.store.getNodeId(),
|
|
2581
|
-
versionVector: (0,
|
|
3293
|
+
versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
|
|
2582
3294
|
schemaVersion: this.schemaVersion,
|
|
2583
3295
|
accepted: true,
|
|
2584
3296
|
selectedWireFormat,
|
|
2585
3297
|
serverTime: Date.now(),
|
|
3298
|
+
// The server's highest delivery sequence, so a client whose persisted watermark
|
|
3299
|
+
// is ahead of it (server rolled back) can reset to a full resync. Sent only to
|
|
3300
|
+
// watermark-using clients.
|
|
3301
|
+
...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
|
|
3302
|
+
// Advertise central blob storage so the client uploads the bytes behind
|
|
3303
|
+
// its blob fields, keeping them available after the author goes offline.
|
|
3304
|
+
...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
|
|
2586
3305
|
// Confirm the accepted scope so the client knows what data will be synced.
|
|
2587
3306
|
// This may differ from what the client requested if auth scopes are narrower.
|
|
2588
3307
|
...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
|
|
@@ -2590,17 +3309,40 @@ var ClientSession = class {
|
|
|
2590
3309
|
this.sendToClient(response);
|
|
2591
3310
|
this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
|
|
2592
3311
|
this.state = "syncing";
|
|
2593
|
-
|
|
2594
|
-
|
|
3312
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
3313
|
+
this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
|
|
3314
|
+
const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
|
|
3315
|
+
this.lastDeliveryPushAttemptAtMs = Date.now();
|
|
3316
|
+
await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
|
|
3317
|
+
} else {
|
|
3318
|
+
const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
|
|
3319
|
+
await this.sendDelta(clientVector);
|
|
3320
|
+
}
|
|
2595
3321
|
this.state = "streaming";
|
|
3322
|
+
if (this.takeOrphanedRelays && this.clientNodeId) {
|
|
3323
|
+
const buffered = this.takeOrphanedRelays(this.clientNodeId);
|
|
3324
|
+
if (buffered.length > 0) {
|
|
3325
|
+
this.relayOperations(buffered);
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
2596
3328
|
}
|
|
2597
3329
|
async handleOperationBatch(msg) {
|
|
2598
3330
|
const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2599
3331
|
const applied = [];
|
|
2600
|
-
|
|
3332
|
+
let acknowledgedThrough = 0;
|
|
3333
|
+
let canAdvanceAck = true;
|
|
2601
3334
|
for (const op of operations2) {
|
|
2602
|
-
if (!
|
|
2603
|
-
|
|
3335
|
+
if (!canAdvanceAck) {
|
|
3336
|
+
continue;
|
|
3337
|
+
}
|
|
3338
|
+
if (!await this.operationVisibleToClient(op)) {
|
|
3339
|
+
this.sendOperationRejected(
|
|
3340
|
+
op,
|
|
3341
|
+
"SCOPE_VIOLATION",
|
|
3342
|
+
`Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
|
|
3343
|
+
true
|
|
3344
|
+
);
|
|
3345
|
+
canAdvanceAck = false;
|
|
2604
3346
|
continue;
|
|
2605
3347
|
}
|
|
2606
3348
|
if (!isOperationTimestampValid(op)) {
|
|
@@ -2609,6 +3351,7 @@ var ClientSession = class {
|
|
|
2609
3351
|
`Operation "${op.id}" timestamp is too far in the future`,
|
|
2610
3352
|
false
|
|
2611
3353
|
);
|
|
3354
|
+
canAdvanceAck = false;
|
|
2612
3355
|
continue;
|
|
2613
3356
|
}
|
|
2614
3357
|
if (!this.rateLimiter.allow(1)) {
|
|
@@ -2617,6 +3360,7 @@ var ClientSession = class {
|
|
|
2617
3360
|
`Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
|
|
2618
3361
|
true
|
|
2619
3362
|
);
|
|
3363
|
+
canAdvanceAck = false;
|
|
2620
3364
|
continue;
|
|
2621
3365
|
}
|
|
2622
3366
|
const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
|
|
@@ -2626,27 +3370,73 @@ var ClientSession = class {
|
|
|
2626
3370
|
sizeCheck.message ?? `Operation "${op.id}" is too large`,
|
|
2627
3371
|
false
|
|
2628
3372
|
);
|
|
3373
|
+
canAdvanceAck = false;
|
|
2629
3374
|
continue;
|
|
2630
3375
|
}
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
3376
|
+
const serverOp = this.transformForServerSchema(op);
|
|
3377
|
+
if (serverOp === null) {
|
|
3378
|
+
this.sendOperationRejected(
|
|
3379
|
+
op,
|
|
3380
|
+
"SCHEMA_TRANSFORM_UNAVAILABLE",
|
|
3381
|
+
`Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
|
|
3382
|
+
false
|
|
3383
|
+
);
|
|
3384
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3385
|
+
continue;
|
|
3386
|
+
}
|
|
3387
|
+
if (this.validateOperation && this.koraContext) {
|
|
3388
|
+
let decision;
|
|
3389
|
+
try {
|
|
3390
|
+
decision = await this.validateOperation(serverOp, {
|
|
3391
|
+
auth: this.authContext,
|
|
3392
|
+
kora: this.koraContext
|
|
3393
|
+
});
|
|
3394
|
+
} catch (error) {
|
|
3395
|
+
const message = error instanceof Error ? error.message : "validator error";
|
|
3396
|
+
this.sendOperationRejected(
|
|
3397
|
+
serverOp,
|
|
3398
|
+
"VALIDATION_ERROR",
|
|
3399
|
+
`Validator threw: ${message}`,
|
|
3400
|
+
true
|
|
3401
|
+
);
|
|
3402
|
+
canAdvanceAck = false;
|
|
2635
3403
|
continue;
|
|
2636
3404
|
}
|
|
2637
|
-
if (
|
|
2638
|
-
|
|
3405
|
+
if (decision.action === "reject") {
|
|
3406
|
+
const retriable = decision.retriable ?? isRetriableRejection(decision.code);
|
|
3407
|
+
this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
|
|
3408
|
+
if (retriable) {
|
|
3409
|
+
canAdvanceAck = false;
|
|
3410
|
+
} else {
|
|
3411
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3412
|
+
}
|
|
3413
|
+
continue;
|
|
3414
|
+
}
|
|
3415
|
+
if (decision.action === "ignore") {
|
|
3416
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3417
|
+
continue;
|
|
2639
3418
|
}
|
|
2640
|
-
} catch {
|
|
2641
3419
|
}
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
3420
|
+
const applyResult = await applyServerOperation(this.store, serverOp);
|
|
3421
|
+
if (applyResult.rejection) {
|
|
3422
|
+
this.sendOperationRejected(
|
|
3423
|
+
serverOp,
|
|
3424
|
+
applyResult.rejection.code,
|
|
3425
|
+
applyResult.rejection.message,
|
|
3426
|
+
applyResult.rejection.retriable
|
|
2649
3427
|
);
|
|
3428
|
+
if (applyResult.rejection.retriable) {
|
|
3429
|
+
canAdvanceAck = false;
|
|
3430
|
+
} else {
|
|
3431
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3432
|
+
}
|
|
3433
|
+
continue;
|
|
3434
|
+
}
|
|
3435
|
+
if (applyResult.result === "applied") {
|
|
3436
|
+
applied.push(...applyResult.appliedOperations);
|
|
3437
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3438
|
+
} else {
|
|
3439
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
2650
3440
|
}
|
|
2651
3441
|
}
|
|
2652
3442
|
if (operations2.length > 0) {
|
|
@@ -2659,15 +3449,21 @@ var ClientSession = class {
|
|
|
2659
3449
|
const lastOp = operations2[operations2.length - 1];
|
|
2660
3450
|
const ack = {
|
|
2661
3451
|
type: "acknowledgment",
|
|
2662
|
-
messageId: (0,
|
|
3452
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2663
3453
|
acknowledgedMessageId: msg.messageId,
|
|
2664
|
-
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3454
|
+
lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
|
|
2665
3455
|
};
|
|
2666
3456
|
this.sendToClient(ack);
|
|
2667
3457
|
if (applied.length > 0) {
|
|
2668
3458
|
this.onRelay?.(this.sessionId, applied);
|
|
2669
3459
|
}
|
|
2670
3460
|
}
|
|
3461
|
+
transformForServerSchema(op) {
|
|
3462
|
+
if (op.schemaVersion === this.schemaVersion) {
|
|
3463
|
+
return op;
|
|
3464
|
+
}
|
|
3465
|
+
return (0, import_core10.applyOperationTransforms)(op, this.schemaVersion, this.operationTransforms);
|
|
3466
|
+
}
|
|
2671
3467
|
async sendDelta(clientVector) {
|
|
2672
3468
|
const serverVector = this.store.getVersionVector();
|
|
2673
3469
|
const missing = [];
|
|
@@ -2675,14 +3471,17 @@ var ClientSession = class {
|
|
|
2675
3471
|
const clientSeq = clientVector.get(nodeId) ?? 0;
|
|
2676
3472
|
if (serverSeq > clientSeq) {
|
|
2677
3473
|
const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
|
|
2678
|
-
const
|
|
2679
|
-
|
|
3474
|
+
for (const op of ops) {
|
|
3475
|
+
if (await this.operationVisibleToClient(op)) {
|
|
3476
|
+
missing.push(op);
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
2680
3479
|
}
|
|
2681
3480
|
}
|
|
2682
3481
|
if (missing.length === 0) {
|
|
2683
3482
|
const emptyBatch = {
|
|
2684
3483
|
type: "operation-batch",
|
|
2685
|
-
messageId: (0,
|
|
3484
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2686
3485
|
operations: [],
|
|
2687
3486
|
isFinal: true,
|
|
2688
3487
|
batchIndex: 0,
|
|
@@ -2692,12 +3491,12 @@ var ClientSession = class {
|
|
|
2692
3491
|
return;
|
|
2693
3492
|
}
|
|
2694
3493
|
const sorted = (0, import_internal.topologicalSort)(missing);
|
|
2695
|
-
const afterCursor = (0,
|
|
3494
|
+
const afterCursor = (0, import_sync3.sliceOperationsAfterCursor)(sorted, this.resumeDeltaCursor);
|
|
2696
3495
|
const totalBatches = Math.ceil(afterCursor.length / this.batchSize);
|
|
2697
3496
|
if (afterCursor.length === 0) {
|
|
2698
3497
|
const emptyBatch = {
|
|
2699
3498
|
type: "operation-batch",
|
|
2700
|
-
messageId: (0,
|
|
3499
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2701
3500
|
operations: [],
|
|
2702
3501
|
isFinal: true,
|
|
2703
3502
|
batchIndex: this.resumeDeltaCursor?.batchIndex ?? 0,
|
|
@@ -2710,15 +3509,15 @@ var ClientSession = class {
|
|
|
2710
3509
|
const start = i * this.batchSize;
|
|
2711
3510
|
const batchOps = afterCursor.slice(start, start + this.batchSize);
|
|
2712
3511
|
const serializedOps = batchOps.map((op) => this.serializer.encodeOperation(op));
|
|
2713
|
-
const batchCursor = (0,
|
|
3512
|
+
const batchCursor = (0, import_sync3.createDeltaCursorFromBatch)(batchOps, i);
|
|
2714
3513
|
const batchMsg = {
|
|
2715
3514
|
type: "operation-batch",
|
|
2716
|
-
messageId: (0,
|
|
3515
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2717
3516
|
operations: serializedOps,
|
|
2718
3517
|
isFinal: i === totalBatches - 1,
|
|
2719
3518
|
batchIndex: i,
|
|
2720
3519
|
totalBatches,
|
|
2721
|
-
...batchCursor ? { cursor: (0,
|
|
3520
|
+
...batchCursor ? { cursor: (0, import_sync3.encodeDeltaCursor)(batchCursor) } : {}
|
|
2722
3521
|
};
|
|
2723
3522
|
this.sendToClient(batchMsg);
|
|
2724
3523
|
this.emitter?.emit({
|
|
@@ -2728,11 +3527,111 @@ var ClientSession = class {
|
|
|
2728
3527
|
});
|
|
2729
3528
|
}
|
|
2730
3529
|
}
|
|
2731
|
-
|
|
2732
|
-
|
|
3530
|
+
/**
|
|
3531
|
+
* Send the gap-free server->client delivery stream, resuming just after
|
|
3532
|
+
* `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
|
|
3533
|
+
* (commit order), scope-filtered for this session, and sent in batches that chain:
|
|
3534
|
+
* each batch's `baseDeliverySequence` equals the previous batch's
|
|
3535
|
+
* `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
|
|
3536
|
+
* applies a batch only when its watermark equals the base and advances the
|
|
3537
|
+
* watermark to the max, so a dropped batch stalls the watermark and is recovered by
|
|
3538
|
+
* the next handshake resend. Because delivery-sequence order respects causal order
|
|
3539
|
+
* (a dependency is always committed, and thus sequenced, before its dependent), no
|
|
3540
|
+
* topological sort is needed and the client never defers on a missing dependency
|
|
3541
|
+
* that is itself in this stream.
|
|
3542
|
+
*
|
|
3543
|
+
* The final batch advances the watermark to the highest delivery sequence scanned,
|
|
3544
|
+
* not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
|
|
3545
|
+
* next reconnect. Returns the number of operations actually sent.
|
|
3546
|
+
*/
|
|
3547
|
+
async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
|
|
3548
|
+
const scanChunk = Math.max(this.batchSize, 1) * 5;
|
|
3549
|
+
let scanCursor = fromDeliverySeq;
|
|
3550
|
+
let maxScanned = fromDeliverySeq;
|
|
3551
|
+
const visible = [];
|
|
3552
|
+
while (true) {
|
|
3553
|
+
const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
|
|
3554
|
+
const last = chunk[chunk.length - 1];
|
|
3555
|
+
if (last === void 0) break;
|
|
3556
|
+
scanCursor = last.deliverySequence;
|
|
3557
|
+
maxScanned = scanCursor;
|
|
3558
|
+
for (const delivered of chunk) {
|
|
3559
|
+
if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
|
|
3560
|
+
continue;
|
|
3561
|
+
}
|
|
3562
|
+
if (await this.operationVisibleToClient(delivered.operation)) {
|
|
3563
|
+
visible.push(delivered);
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
if (chunk.length < scanChunk) break;
|
|
3567
|
+
}
|
|
3568
|
+
if (visible.length === 0) {
|
|
3569
|
+
if (finalizeWhenEmpty) {
|
|
3570
|
+
this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
|
|
3571
|
+
}
|
|
3572
|
+
return 0;
|
|
3573
|
+
}
|
|
3574
|
+
const totalBatches = Math.ceil(visible.length / this.batchSize);
|
|
3575
|
+
let base = fromDeliverySeq;
|
|
3576
|
+
for (let i = 0; i < totalBatches; i++) {
|
|
3577
|
+
const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
|
|
3578
|
+
const lastInSlice = slice[slice.length - 1];
|
|
3579
|
+
if (lastInSlice === void 0) continue;
|
|
3580
|
+
const isFinal = i === totalBatches - 1;
|
|
3581
|
+
const lastSeq = lastInSlice.deliverySequence;
|
|
3582
|
+
const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
|
|
3583
|
+
this.sendDeliveryBatch(slice, base, max, i, isFinal);
|
|
3584
|
+
base = max;
|
|
3585
|
+
}
|
|
3586
|
+
return visible.length;
|
|
3587
|
+
}
|
|
3588
|
+
/** Build, track, and send one chained delivery-stream batch. */
|
|
3589
|
+
sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
|
|
3590
|
+
const batchMsg = {
|
|
3591
|
+
type: "operation-batch",
|
|
3592
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
3593
|
+
operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
|
|
3594
|
+
isFinal,
|
|
3595
|
+
batchIndex,
|
|
3596
|
+
baseDeliverySequence: base,
|
|
3597
|
+
maxDeliverySequence: max
|
|
3598
|
+
};
|
|
3599
|
+
this.sendToClient(batchMsg);
|
|
3600
|
+
if (slice.length > 0) {
|
|
3601
|
+
this.emitter?.emit({
|
|
3602
|
+
type: "sync:sent",
|
|
3603
|
+
operations: slice.map((delivered) => delivered.operation),
|
|
3604
|
+
batchSize: slice.length
|
|
3605
|
+
});
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
async operationVisibleToClient(op) {
|
|
3609
|
+
const scopes = this.authContext?.scopes;
|
|
3610
|
+
const subsets = this.syncQuerySubsets;
|
|
3611
|
+
const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
|
|
3612
|
+
const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
|
|
3613
|
+
if (!operationMatchesScopes(op, scopes, fullRecord)) {
|
|
2733
3614
|
return false;
|
|
2734
3615
|
}
|
|
2735
|
-
return (0,
|
|
3616
|
+
return (0, import_sync3.operationMatchesQuerySubsets)(op, subsets, fullRecord);
|
|
3617
|
+
}
|
|
3618
|
+
/**
|
|
3619
|
+
* Read a record's current field values from the materialized store for scope /
|
|
3620
|
+
* query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
|
|
3621
|
+
* carries no fields) is still judged against the record's actual scope. Returns
|
|
3622
|
+
* undefined when the record cannot be read (never materialized, or no schema).
|
|
3623
|
+
*/
|
|
3624
|
+
async lookupRecordFields(collection, recordId) {
|
|
3625
|
+
try {
|
|
3626
|
+
const rows = await this.store.queryCollection(collection, {
|
|
3627
|
+
where: { id: recordId },
|
|
3628
|
+
includeDeleted: true,
|
|
3629
|
+
limit: 1
|
|
3630
|
+
});
|
|
3631
|
+
return rows[0];
|
|
3632
|
+
} catch {
|
|
3633
|
+
return void 0;
|
|
3634
|
+
}
|
|
2736
3635
|
}
|
|
2737
3636
|
handleAwarenessUpdate(msg) {
|
|
2738
3637
|
this.onAwarenessUpdate?.(this.sessionId, msg);
|
|
@@ -2743,13 +3642,32 @@ var ClientSession = class {
|
|
|
2743
3642
|
sendError(code, message, retriable) {
|
|
2744
3643
|
const errorMsg = {
|
|
2745
3644
|
type: "error",
|
|
2746
|
-
messageId: (0,
|
|
3645
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2747
3646
|
code,
|
|
2748
3647
|
message,
|
|
2749
3648
|
retriable
|
|
2750
3649
|
};
|
|
2751
3650
|
this.sendToClient(errorMsg);
|
|
2752
3651
|
}
|
|
3652
|
+
/**
|
|
3653
|
+
* Reject one specific client operation, tied to its id, so the submitter can
|
|
3654
|
+
* divert it out of its pending queue into a durable rejected store rather than
|
|
3655
|
+
* losing it or retrying forever. The op is NOT materialized, so no other
|
|
3656
|
+
* replica ever sees it.
|
|
3657
|
+
*/
|
|
3658
|
+
sendOperationRejected(op, code, message, retriable) {
|
|
3659
|
+
const rejectedMsg = {
|
|
3660
|
+
type: "operation-rejected",
|
|
3661
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
3662
|
+
operationId: op.id,
|
|
3663
|
+
collection: op.collection,
|
|
3664
|
+
recordId: op.recordId,
|
|
3665
|
+
code,
|
|
3666
|
+
message,
|
|
3667
|
+
retriable
|
|
3668
|
+
};
|
|
3669
|
+
this.sendToClient(rejectedMsg);
|
|
3670
|
+
}
|
|
2753
3671
|
setSerializerWireFormat(format) {
|
|
2754
3672
|
if (typeof this.serializer.setWireFormat === "function") {
|
|
2755
3673
|
this.serializer.setWireFormat(format);
|
|
@@ -2758,6 +3676,7 @@ var ClientSession = class {
|
|
|
2758
3676
|
handleTransportClose() {
|
|
2759
3677
|
if (this.state === "closed") return;
|
|
2760
3678
|
this.state = "closed";
|
|
3679
|
+
this.flushOrphanedRelays();
|
|
2761
3680
|
this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
|
|
2762
3681
|
this.onClose?.(this.sessionId);
|
|
2763
3682
|
}
|
|
@@ -2771,13 +3690,13 @@ function selectWireFormat(supportedWireFormats) {
|
|
|
2771
3690
|
|
|
2772
3691
|
// src/server/kora-sync-server.ts
|
|
2773
3692
|
init_cjs_shims();
|
|
2774
|
-
var
|
|
3693
|
+
var import_core14 = require("@korajs/core");
|
|
2775
3694
|
var import_internal2 = require("@korajs/core/internal");
|
|
2776
|
-
var
|
|
3695
|
+
var import_sync5 = require("@korajs/sync");
|
|
2777
3696
|
|
|
2778
3697
|
// src/awareness/awareness-relay.ts
|
|
2779
3698
|
init_cjs_shims();
|
|
2780
|
-
var
|
|
3699
|
+
var import_core12 = require("@korajs/core");
|
|
2781
3700
|
var AwarenessRelay = class {
|
|
2782
3701
|
clients = /* @__PURE__ */ new Map();
|
|
2783
3702
|
/**
|
|
@@ -2806,7 +3725,7 @@ var AwarenessRelay = class {
|
|
|
2806
3725
|
if (hasStates) {
|
|
2807
3726
|
const catchUpMsg = {
|
|
2808
3727
|
type: "awareness-update",
|
|
2809
|
-
messageId: (0,
|
|
3728
|
+
messageId: (0, import_core12.generateUUIDv7)(),
|
|
2810
3729
|
clientId: 0,
|
|
2811
3730
|
// Server-sourced
|
|
2812
3731
|
states: existingStates
|
|
@@ -2829,7 +3748,7 @@ var AwarenessRelay = class {
|
|
|
2829
3748
|
};
|
|
2830
3749
|
const msg = {
|
|
2831
3750
|
type: "awareness-update",
|
|
2832
|
-
messageId: (0,
|
|
3751
|
+
messageId: (0, import_core12.generateUUIDv7)(),
|
|
2833
3752
|
clientId: client.clientId,
|
|
2834
3753
|
states: removalStates
|
|
2835
3754
|
};
|
|
@@ -2990,6 +3909,114 @@ var ServerMetricsCollector = class {
|
|
|
2990
3909
|
}
|
|
2991
3910
|
};
|
|
2992
3911
|
|
|
3912
|
+
// src/richtext/blob-chunk-relay.ts
|
|
3913
|
+
init_cjs_shims();
|
|
3914
|
+
var import_sync4 = require("@korajs/sync");
|
|
3915
|
+
var BlobChunkRelay = class {
|
|
3916
|
+
clients = /* @__PURE__ */ new Map();
|
|
3917
|
+
/** requestId -> the session that originated the request (to route the answer back). */
|
|
3918
|
+
pending = /* @__PURE__ */ new Map();
|
|
3919
|
+
resolveBlobChunk;
|
|
3920
|
+
constructor(resolveBlobChunk) {
|
|
3921
|
+
this.resolveBlobChunk = resolveBlobChunk ?? null;
|
|
3922
|
+
}
|
|
3923
|
+
addClient(sessionId, transport) {
|
|
3924
|
+
this.clients.set(sessionId, { sessionId, transport });
|
|
3925
|
+
}
|
|
3926
|
+
removeClient(sessionId) {
|
|
3927
|
+
this.clients.delete(sessionId);
|
|
3928
|
+
for (const [requestId, originSessionId] of this.pending) {
|
|
3929
|
+
if (originSessionId === sessionId) {
|
|
3930
|
+
this.pending.delete(requestId);
|
|
3931
|
+
}
|
|
3932
|
+
}
|
|
3933
|
+
}
|
|
3934
|
+
getClientCount() {
|
|
3935
|
+
return this.clients.size;
|
|
3936
|
+
}
|
|
3937
|
+
getPendingCount() {
|
|
3938
|
+
return this.pending.size;
|
|
3939
|
+
}
|
|
3940
|
+
clear() {
|
|
3941
|
+
this.clients.clear();
|
|
3942
|
+
this.pending.clear();
|
|
3943
|
+
}
|
|
3944
|
+
/**
|
|
3945
|
+
* Handle an inbound chunk request from a session. Tries the server's own
|
|
3946
|
+
* store first (if configured), otherwise forwards the request to peer
|
|
3947
|
+
* sessions and remembers who to route the answer back to.
|
|
3948
|
+
*/
|
|
3949
|
+
handleRequest(sourceSessionId, message) {
|
|
3950
|
+
if (!this.clients.has(sourceSessionId)) {
|
|
3951
|
+
return;
|
|
3952
|
+
}
|
|
3953
|
+
if (this.resolveBlobChunk) {
|
|
3954
|
+
void this.resolveBlobChunk(message.hash).then(
|
|
3955
|
+
(bytes) => {
|
|
3956
|
+
if (bytes !== null) {
|
|
3957
|
+
this.sendResponseTo(sourceSessionId, message.requestId, (0, import_sync4.encodeBlobChunkBytes)(bytes));
|
|
3958
|
+
return;
|
|
3959
|
+
}
|
|
3960
|
+
this.forwardRequestToPeers(sourceSessionId, message);
|
|
3961
|
+
},
|
|
3962
|
+
() => {
|
|
3963
|
+
this.forwardRequestToPeers(sourceSessionId, message);
|
|
3964
|
+
}
|
|
3965
|
+
);
|
|
3966
|
+
return;
|
|
3967
|
+
}
|
|
3968
|
+
this.forwardRequestToPeers(sourceSessionId, message);
|
|
3969
|
+
}
|
|
3970
|
+
/**
|
|
3971
|
+
* Handle an inbound chunk response from a peer. Routes it back to the session
|
|
3972
|
+
* that originally requested it. A "not held" answer (bytes === null) is
|
|
3973
|
+
* ignored so a peer without the chunk does not preempt one that has it; the
|
|
3974
|
+
* requester's own per-request timeout bounds the wait.
|
|
3975
|
+
*/
|
|
3976
|
+
handleResponse(sourceSessionId, message) {
|
|
3977
|
+
if (!this.clients.has(sourceSessionId)) {
|
|
3978
|
+
return;
|
|
3979
|
+
}
|
|
3980
|
+
if (message.bytes === null) {
|
|
3981
|
+
return;
|
|
3982
|
+
}
|
|
3983
|
+
const originSessionId = this.pending.get(message.requestId);
|
|
3984
|
+
if (!originSessionId) {
|
|
3985
|
+
return;
|
|
3986
|
+
}
|
|
3987
|
+
this.pending.delete(message.requestId);
|
|
3988
|
+
this.sendResponseTo(originSessionId, message.requestId, message.bytes);
|
|
3989
|
+
}
|
|
3990
|
+
forwardRequestToPeers(sourceSessionId, message) {
|
|
3991
|
+
this.pending.set(message.requestId, sourceSessionId);
|
|
3992
|
+
for (const [, client] of this.clients) {
|
|
3993
|
+
if (client.sessionId === sourceSessionId) {
|
|
3994
|
+
continue;
|
|
3995
|
+
}
|
|
3996
|
+
if (!client.transport.isConnected()) {
|
|
3997
|
+
continue;
|
|
3998
|
+
}
|
|
3999
|
+
client.transport.send(message);
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
sendResponseTo(sessionId, requestId, bytes) {
|
|
4003
|
+
const client = this.clients.get(sessionId);
|
|
4004
|
+
if (!client || !client.transport.isConnected()) {
|
|
4005
|
+
return;
|
|
4006
|
+
}
|
|
4007
|
+
const response = {
|
|
4008
|
+
type: "blob-chunk-response",
|
|
4009
|
+
messageId: generateResponseId(requestId),
|
|
4010
|
+
requestId,
|
|
4011
|
+
bytes
|
|
4012
|
+
};
|
|
4013
|
+
client.transport.send(response);
|
|
4014
|
+
}
|
|
4015
|
+
};
|
|
4016
|
+
function generateResponseId(requestId) {
|
|
4017
|
+
return `blob-resp-${requestId}`;
|
|
4018
|
+
}
|
|
4019
|
+
|
|
2993
4020
|
// src/richtext/yjs-doc-relay.ts
|
|
2994
4021
|
init_cjs_shims();
|
|
2995
4022
|
var YjsDocRelay = class {
|
|
@@ -3025,21 +4052,416 @@ var YjsDocRelay = class {
|
|
|
3025
4052
|
}
|
|
3026
4053
|
};
|
|
3027
4054
|
|
|
4055
|
+
// src/server/route-context.ts
|
|
4056
|
+
init_cjs_shims();
|
|
4057
|
+
var import_core13 = require("@korajs/core");
|
|
4058
|
+
|
|
4059
|
+
// src/apply/route-predicate.ts
|
|
4060
|
+
init_cjs_shims();
|
|
4061
|
+
function evaluateField(actual, operators) {
|
|
4062
|
+
if ("$eq" in operators && !Object.is(actual, operators.$eq)) {
|
|
4063
|
+
return false;
|
|
4064
|
+
}
|
|
4065
|
+
if ("$ne" in operators && Object.is(actual, operators.$ne)) {
|
|
4066
|
+
return false;
|
|
4067
|
+
}
|
|
4068
|
+
if (operators.$in !== void 0 && !operators.$in.some((candidate) => Object.is(actual, candidate))) {
|
|
4069
|
+
return false;
|
|
4070
|
+
}
|
|
4071
|
+
const numericChecks = [
|
|
4072
|
+
["$lt", (a, b) => a < b],
|
|
4073
|
+
["$lte", (a, b) => a <= b],
|
|
4074
|
+
["$gt", (a, b) => a > b],
|
|
4075
|
+
["$gte", (a, b) => a >= b]
|
|
4076
|
+
];
|
|
4077
|
+
for (const [operator, compare] of numericChecks) {
|
|
4078
|
+
const bound = operators[operator];
|
|
4079
|
+
if (bound === void 0) {
|
|
4080
|
+
continue;
|
|
4081
|
+
}
|
|
4082
|
+
if (typeof actual !== "number" || typeof bound !== "number") {
|
|
4083
|
+
return false;
|
|
4084
|
+
}
|
|
4085
|
+
if (!compare(actual, bound)) {
|
|
4086
|
+
return false;
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
return true;
|
|
4090
|
+
}
|
|
4091
|
+
function evaluateRoutePredicate(record, predicate) {
|
|
4092
|
+
const fields = Object.keys(predicate);
|
|
4093
|
+
if (fields.length === 0) {
|
|
4094
|
+
return true;
|
|
4095
|
+
}
|
|
4096
|
+
if (record === null) {
|
|
4097
|
+
return false;
|
|
4098
|
+
}
|
|
4099
|
+
for (const field of fields) {
|
|
4100
|
+
const operators = predicate[field];
|
|
4101
|
+
if (operators && !evaluateField(record[field], operators)) {
|
|
4102
|
+
return false;
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
return true;
|
|
4106
|
+
}
|
|
4107
|
+
|
|
4108
|
+
// src/server/route-context.ts
|
|
4109
|
+
function recordMatchesScope(collection, record, scope) {
|
|
4110
|
+
const collectionScope = scope[collection];
|
|
4111
|
+
if (!collectionScope) {
|
|
4112
|
+
return false;
|
|
4113
|
+
}
|
|
4114
|
+
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
4115
|
+
if (record[field] !== expected) {
|
|
4116
|
+
return false;
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
return true;
|
|
4120
|
+
}
|
|
4121
|
+
function pickFields(record, keys) {
|
|
4122
|
+
const picked = {};
|
|
4123
|
+
for (const key of keys) {
|
|
4124
|
+
if (key in record) {
|
|
4125
|
+
picked[key] = record[key];
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
4128
|
+
return picked;
|
|
4129
|
+
}
|
|
4130
|
+
async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
|
|
4131
|
+
const nodeId = store.getNodeId();
|
|
4132
|
+
const clock = clockOverride ?? new import_core13.HybridLogicalClock(nodeId);
|
|
4133
|
+
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
4134
|
+
const sequenceNumber = nextServerSequenceNumber(store);
|
|
4135
|
+
if (mutation.type === "insert") {
|
|
4136
|
+
return (0, import_core13.createOperation)(
|
|
4137
|
+
{
|
|
4138
|
+
nodeId,
|
|
4139
|
+
type: "insert",
|
|
4140
|
+
collection: mutation.collection,
|
|
4141
|
+
recordId: mutation.recordId ?? (0, import_core13.generateUUIDv7)(),
|
|
4142
|
+
data: mutation.data ?? {},
|
|
4143
|
+
previousData: null,
|
|
4144
|
+
sequenceNumber,
|
|
4145
|
+
causalDeps: [],
|
|
4146
|
+
schemaVersion
|
|
4147
|
+
},
|
|
4148
|
+
clock
|
|
4149
|
+
);
|
|
4150
|
+
}
|
|
4151
|
+
if (!mutation.recordId) {
|
|
4152
|
+
throw new RouteMutationError(
|
|
4153
|
+
"MISSING_RECORD_ID",
|
|
4154
|
+
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
4155
|
+
);
|
|
4156
|
+
}
|
|
4157
|
+
const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
|
|
4158
|
+
if (mutation.type === "update") {
|
|
4159
|
+
const rawData = mutation.data ?? {};
|
|
4160
|
+
const data = {};
|
|
4161
|
+
const atomicOps = {};
|
|
4162
|
+
for (const key of Object.keys(rawData)) {
|
|
4163
|
+
const value = rawData[key];
|
|
4164
|
+
if ((0, import_core13.isAtomicOp)(value)) {
|
|
4165
|
+
data[key] = (0, import_core13.resolveAtomicOp)(current ? current[key] : void 0, value);
|
|
4166
|
+
atomicOps[key] = (0, import_core13.toAtomicOp)(value);
|
|
4167
|
+
} else {
|
|
4168
|
+
data[key] = value;
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
return (0, import_core13.createOperation)(
|
|
4172
|
+
{
|
|
4173
|
+
nodeId,
|
|
4174
|
+
type: "update",
|
|
4175
|
+
collection: mutation.collection,
|
|
4176
|
+
recordId: mutation.recordId,
|
|
4177
|
+
data,
|
|
4178
|
+
previousData: current ? pickFields(current, Object.keys(rawData)) : {},
|
|
4179
|
+
sequenceNumber,
|
|
4180
|
+
causalDeps: [],
|
|
4181
|
+
schemaVersion,
|
|
4182
|
+
...Object.keys(atomicOps).length > 0 ? { atomicOps } : {}
|
|
4183
|
+
},
|
|
4184
|
+
clock
|
|
4185
|
+
);
|
|
4186
|
+
}
|
|
4187
|
+
return (0, import_core13.createOperation)(
|
|
4188
|
+
{
|
|
4189
|
+
nodeId,
|
|
4190
|
+
type: "delete",
|
|
4191
|
+
collection: mutation.collection,
|
|
4192
|
+
recordId: mutation.recordId,
|
|
4193
|
+
data: null,
|
|
4194
|
+
previousData: current ? { ...current } : null,
|
|
4195
|
+
sequenceNumber,
|
|
4196
|
+
causalDeps: [],
|
|
4197
|
+
schemaVersion
|
|
4198
|
+
},
|
|
4199
|
+
clock
|
|
4200
|
+
);
|
|
4201
|
+
}
|
|
4202
|
+
var RouteMutationError = class extends Error {
|
|
4203
|
+
constructor(code, message) {
|
|
4204
|
+
super(message);
|
|
4205
|
+
this.code = code;
|
|
4206
|
+
this.name = "RouteMutationError";
|
|
4207
|
+
}
|
|
4208
|
+
code;
|
|
4209
|
+
};
|
|
4210
|
+
function createRouteContext(server, store) {
|
|
4211
|
+
let mutationTail = Promise.resolve();
|
|
4212
|
+
function serialize(work) {
|
|
4213
|
+
const run = mutationTail.then(work, work);
|
|
4214
|
+
mutationTail = run.then(
|
|
4215
|
+
() => void 0,
|
|
4216
|
+
() => void 0
|
|
4217
|
+
);
|
|
4218
|
+
return run;
|
|
4219
|
+
}
|
|
4220
|
+
async function prepareMutation(mutation, scope) {
|
|
4221
|
+
let op;
|
|
4222
|
+
try {
|
|
4223
|
+
op = await buildRouteOperation(store, mutation);
|
|
4224
|
+
} catch (error) {
|
|
4225
|
+
if (error instanceof RouteMutationError) {
|
|
4226
|
+
return {
|
|
4227
|
+
ok: false,
|
|
4228
|
+
rejection: {
|
|
4229
|
+
ok: false,
|
|
4230
|
+
code: error.code,
|
|
4231
|
+
message: error.message,
|
|
4232
|
+
retriable: isRetriableRejection(error.code)
|
|
4233
|
+
}
|
|
4234
|
+
};
|
|
4235
|
+
}
|
|
4236
|
+
throw error;
|
|
4237
|
+
}
|
|
4238
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
4239
|
+
return {
|
|
4240
|
+
ok: false,
|
|
4241
|
+
rejection: {
|
|
4242
|
+
ok: false,
|
|
4243
|
+
code: "SCOPE_VIOLATION",
|
|
4244
|
+
message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
|
|
4245
|
+
retriable: isRetriableRejection("SCOPE_VIOLATION")
|
|
4246
|
+
}
|
|
4247
|
+
};
|
|
4248
|
+
}
|
|
4249
|
+
return { ok: true, op };
|
|
4250
|
+
}
|
|
4251
|
+
async function commitOperation(op, collection) {
|
|
4252
|
+
const result = await server.applyLocalOperation(op);
|
|
4253
|
+
if (result.result !== "applied") {
|
|
4254
|
+
const code = result.rejection?.code ?? "NOT_APPLIED";
|
|
4255
|
+
return {
|
|
4256
|
+
ok: false,
|
|
4257
|
+
code,
|
|
4258
|
+
message: result.rejection?.message ?? `Operation on "${collection}" was not applied (${result.result}).`,
|
|
4259
|
+
// Prefer the pipeline's own classification when present; fall back
|
|
4260
|
+
// to classifying the code so this stays correct if new codes appear.
|
|
4261
|
+
retriable: result.rejection?.retriable ?? isRetriableRejection(code)
|
|
4262
|
+
};
|
|
4263
|
+
}
|
|
4264
|
+
const record = await store.findRecord(collection, op.recordId);
|
|
4265
|
+
return { ok: true, operation: op, record };
|
|
4266
|
+
}
|
|
4267
|
+
async function rawApply(mutation, scope) {
|
|
4268
|
+
const prepared = await prepareMutation(mutation, scope);
|
|
4269
|
+
if (!prepared.ok) {
|
|
4270
|
+
return prepared.rejection;
|
|
4271
|
+
}
|
|
4272
|
+
return commitOperation(prepared.op, mutation.collection);
|
|
4273
|
+
}
|
|
4274
|
+
async function runConditionalViaStore(storeApplyConditional, spec, options) {
|
|
4275
|
+
const scope = options?.scope;
|
|
4276
|
+
const mutations = [];
|
|
4277
|
+
const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
|
|
4278
|
+
if (hasTargetUpdate) {
|
|
4279
|
+
mutations.push({
|
|
4280
|
+
collection: spec.collection,
|
|
4281
|
+
type: "update",
|
|
4282
|
+
recordId: spec.id,
|
|
4283
|
+
data: spec.update
|
|
4284
|
+
});
|
|
4285
|
+
}
|
|
4286
|
+
if (spec.also) {
|
|
4287
|
+
mutations.push(...spec.also);
|
|
4288
|
+
}
|
|
4289
|
+
let result;
|
|
4290
|
+
try {
|
|
4291
|
+
result = await storeApplyConditional({
|
|
4292
|
+
target: { collection: spec.collection, id: spec.id },
|
|
4293
|
+
...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
|
|
4294
|
+
admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
|
|
4295
|
+
buildOperations: async (current, context) => {
|
|
4296
|
+
const ops = [];
|
|
4297
|
+
for (let i = 0; i < mutations.length; i++) {
|
|
4298
|
+
const mutation = mutations[i];
|
|
4299
|
+
const useLockedCurrent = i === 0 && hasTargetUpdate;
|
|
4300
|
+
const op = await buildRouteOperation(
|
|
4301
|
+
store,
|
|
4302
|
+
mutation,
|
|
4303
|
+
useLockedCurrent ? { record: current } : void 0,
|
|
4304
|
+
context.clock
|
|
4305
|
+
);
|
|
4306
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
4307
|
+
throw new RouteMutationError(
|
|
4308
|
+
"SCOPE_VIOLATION",
|
|
4309
|
+
`Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
4310
|
+
);
|
|
4311
|
+
}
|
|
4312
|
+
ops.push(op);
|
|
4313
|
+
}
|
|
4314
|
+
return ops;
|
|
4315
|
+
}
|
|
4316
|
+
});
|
|
4317
|
+
} catch (error) {
|
|
4318
|
+
if (error instanceof RouteMutationError) {
|
|
4319
|
+
return {
|
|
4320
|
+
ok: false,
|
|
4321
|
+
code: error.code,
|
|
4322
|
+
message: error.message,
|
|
4323
|
+
retriable: isRetriableRejection(error.code)
|
|
4324
|
+
};
|
|
4325
|
+
}
|
|
4326
|
+
throw error;
|
|
4327
|
+
}
|
|
4328
|
+
if (!result.admitted) {
|
|
4329
|
+
return {
|
|
4330
|
+
ok: false,
|
|
4331
|
+
code: spec.reject?.code ?? "CONDITION_NOT_MET",
|
|
4332
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
4333
|
+
retriable: false
|
|
4334
|
+
};
|
|
4335
|
+
}
|
|
4336
|
+
if (result.idempotent) {
|
|
4337
|
+
const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
|
|
4338
|
+
return { ok: true, idempotent: true, operations: [], records: records2 };
|
|
4339
|
+
}
|
|
4340
|
+
server.relayServerOperations(result.applied);
|
|
4341
|
+
const records = [];
|
|
4342
|
+
for (const op of result.applied) {
|
|
4343
|
+
records.push(await store.findRecord(op.collection, op.recordId));
|
|
4344
|
+
}
|
|
4345
|
+
return { ok: true, idempotent: false, operations: result.applied, records };
|
|
4346
|
+
}
|
|
4347
|
+
return {
|
|
4348
|
+
apply(mutation, options) {
|
|
4349
|
+
return serialize(() => rawApply(mutation, options?.scope));
|
|
4350
|
+
},
|
|
4351
|
+
applyConditional(spec, options) {
|
|
4352
|
+
const storeApplyConditional = store.applyConditional;
|
|
4353
|
+
if (storeApplyConditional) {
|
|
4354
|
+
return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
|
|
4355
|
+
}
|
|
4356
|
+
return serialize(async () => {
|
|
4357
|
+
if (spec.idempotencyKey) {
|
|
4358
|
+
const existing = await store.findRecord(
|
|
4359
|
+
spec.idempotencyKey.collection,
|
|
4360
|
+
spec.idempotencyKey.id
|
|
4361
|
+
);
|
|
4362
|
+
if (existing) {
|
|
4363
|
+
return { ok: true, idempotent: true, operations: [], records: [existing] };
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
if (spec.if && Object.keys(spec.if).length > 0) {
|
|
4367
|
+
const target = await store.findRecord(spec.collection, spec.id);
|
|
4368
|
+
if (!evaluateRoutePredicate(target, spec.if)) {
|
|
4369
|
+
const code = spec.reject?.code ?? "CONDITION_NOT_MET";
|
|
4370
|
+
return {
|
|
4371
|
+
ok: false,
|
|
4372
|
+
code,
|
|
4373
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
4374
|
+
retriable: false
|
|
4375
|
+
};
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4378
|
+
const mutations = [];
|
|
4379
|
+
if (spec.update !== void 0 && spec.update !== null) {
|
|
4380
|
+
mutations.push({
|
|
4381
|
+
collection: spec.collection,
|
|
4382
|
+
type: "update",
|
|
4383
|
+
recordId: spec.id,
|
|
4384
|
+
data: spec.update
|
|
4385
|
+
});
|
|
4386
|
+
}
|
|
4387
|
+
if (spec.also) {
|
|
4388
|
+
mutations.push(...spec.also);
|
|
4389
|
+
}
|
|
4390
|
+
const prepared = [];
|
|
4391
|
+
for (const mutation of mutations) {
|
|
4392
|
+
const p = await prepareMutation(mutation, options?.scope);
|
|
4393
|
+
if (!p.ok) {
|
|
4394
|
+
return p.rejection;
|
|
4395
|
+
}
|
|
4396
|
+
prepared.push({ op: p.op, collection: mutation.collection });
|
|
4397
|
+
}
|
|
4398
|
+
const operations2 = [];
|
|
4399
|
+
const records = [];
|
|
4400
|
+
for (const { op, collection } of prepared) {
|
|
4401
|
+
const result = await commitOperation(op, collection);
|
|
4402
|
+
if (!result.ok) {
|
|
4403
|
+
return {
|
|
4404
|
+
ok: false,
|
|
4405
|
+
code: result.code,
|
|
4406
|
+
message: result.message,
|
|
4407
|
+
retriable: result.retriable
|
|
4408
|
+
};
|
|
4409
|
+
}
|
|
4410
|
+
operations2.push(result.operation);
|
|
4411
|
+
records.push(result.record);
|
|
4412
|
+
}
|
|
4413
|
+
return { ok: true, idempotent: false, operations: operations2, records };
|
|
4414
|
+
});
|
|
4415
|
+
},
|
|
4416
|
+
async query(collection, options) {
|
|
4417
|
+
const { scope, ...queryOptions } = options ?? {};
|
|
4418
|
+
const records = await store.queryCollection(collection, queryOptions);
|
|
4419
|
+
if (!scope) {
|
|
4420
|
+
return records;
|
|
4421
|
+
}
|
|
4422
|
+
return records.filter((record) => recordMatchesScope(collection, record, scope));
|
|
4423
|
+
},
|
|
4424
|
+
async findById(collection, id, options) {
|
|
4425
|
+
const record = await store.findRecord(collection, id);
|
|
4426
|
+
if (!record) {
|
|
4427
|
+
return null;
|
|
4428
|
+
}
|
|
4429
|
+
if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
|
|
4430
|
+
return null;
|
|
4431
|
+
}
|
|
4432
|
+
return record;
|
|
4433
|
+
}
|
|
4434
|
+
};
|
|
4435
|
+
}
|
|
4436
|
+
|
|
3028
4437
|
// src/server/kora-sync-server.ts
|
|
3029
4438
|
var DEFAULT_MAX_CONNECTIONS = 0;
|
|
3030
4439
|
var DEFAULT_BATCH_SIZE2 = 100;
|
|
3031
4440
|
var DEFAULT_SCHEMA_VERSION2 = 1;
|
|
3032
4441
|
var DEFAULT_HOST = "0.0.0.0";
|
|
3033
4442
|
var DEFAULT_PATH = "/";
|
|
3034
|
-
var
|
|
4443
|
+
var DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
|
|
4444
|
+
var DEFAULT_DELIVERY_POLL_INTERVAL_MS = 2e3;
|
|
4445
|
+
function validateIntervalOption(name, value) {
|
|
4446
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
4447
|
+
throw new import_core14.SyncError(`${name} must be a non-negative integer number of milliseconds`, {
|
|
4448
|
+
[name]: value
|
|
4449
|
+
});
|
|
4450
|
+
}
|
|
4451
|
+
return value;
|
|
4452
|
+
}
|
|
4453
|
+
var KoraSyncServer = class _KoraSyncServer {
|
|
3035
4454
|
store;
|
|
3036
4455
|
auth;
|
|
3037
4456
|
serializer;
|
|
3038
4457
|
emitter;
|
|
3039
4458
|
maxConnections;
|
|
3040
4459
|
batchSize;
|
|
4460
|
+
relayRetransmitIntervalMs;
|
|
4461
|
+
deliveryPollIntervalMs;
|
|
3041
4462
|
schemaVersion;
|
|
3042
4463
|
supportedSchemaVersions;
|
|
4464
|
+
operationTransforms;
|
|
3043
4465
|
port;
|
|
3044
4466
|
host;
|
|
3045
4467
|
path;
|
|
@@ -3047,34 +4469,183 @@ var KoraSyncServer = class {
|
|
|
3047
4469
|
metrics;
|
|
3048
4470
|
awarenessRelay = new AwarenessRelay();
|
|
3049
4471
|
yjsDocRelay = new YjsDocRelay();
|
|
4472
|
+
blobChunkRelay;
|
|
4473
|
+
persistBlobChunk;
|
|
4474
|
+
maxOperationBytes;
|
|
4475
|
+
maxOpsPerMinute;
|
|
4476
|
+
validateOperation;
|
|
4477
|
+
koraContext;
|
|
3050
4478
|
sessions = /* @__PURE__ */ new Map();
|
|
3051
4479
|
httpClients = /* @__PURE__ */ new Map();
|
|
3052
4480
|
httpSessionToClient = /* @__PURE__ */ new Map();
|
|
3053
|
-
|
|
4481
|
+
// Informational value reported by getStatus(). Keep in sync with the
|
|
4482
|
+
// @korajs/server package version on release; it is not used for protocol negotiation.
|
|
4483
|
+
serverVersion = "1.0.0-beta.0";
|
|
3054
4484
|
wsServer = null;
|
|
3055
4485
|
running = false;
|
|
4486
|
+
/**
|
|
4487
|
+
* Periodic tick that retransmits relay batches connected clients have not
|
|
4488
|
+
* acknowledged, so a relay dropped by a lossy transport is redelivered instead of
|
|
4489
|
+
* leaving that client with a permanent version-vector gap (a lost operation).
|
|
4490
|
+
*/
|
|
4491
|
+
relayRetransmitTimer = null;
|
|
4492
|
+
deliveryPollTimer = null;
|
|
4493
|
+
lastObservedDeliverySequence = 0;
|
|
4494
|
+
deliveryPollInFlight = false;
|
|
4495
|
+
/**
|
|
4496
|
+
* Relay operations a client never acknowledged before it disconnected, buffered by
|
|
4497
|
+
* client node id so they can be redelivered on its next connection. Bounded per
|
|
4498
|
+
* node and expired by age so a client that never returns cannot grow this forever.
|
|
4499
|
+
*/
|
|
4500
|
+
orphanedRelaysByNode = /* @__PURE__ */ new Map();
|
|
4501
|
+
static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
|
|
4502
|
+
static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
|
|
3056
4503
|
constructor(config) {
|
|
3057
4504
|
this.store = config.store;
|
|
4505
|
+
const storeSchemaVersion = this.store.getSchema()?.version;
|
|
4506
|
+
if (config.schemaVersion !== void 0 && storeSchemaVersion !== void 0 && config.schemaVersion !== storeSchemaVersion) {
|
|
4507
|
+
throw new import_core14.SyncError("Sync schema version does not match the configured store schema.", {
|
|
4508
|
+
syncSchemaVersion: config.schemaVersion,
|
|
4509
|
+
storeSchemaVersion
|
|
4510
|
+
});
|
|
4511
|
+
}
|
|
3058
4512
|
this.auth = config.auth ?? null;
|
|
3059
|
-
this.serializer = config.serializer ?? new
|
|
4513
|
+
this.serializer = config.serializer ?? new import_sync5.JsonMessageSerializer();
|
|
3060
4514
|
this.emitter = config.emitter ?? null;
|
|
3061
4515
|
this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
3062
4516
|
this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
|
|
3063
|
-
this.
|
|
4517
|
+
this.relayRetransmitIntervalMs = validateIntervalOption(
|
|
4518
|
+
"relayRetransmitIntervalMs",
|
|
4519
|
+
config.relayRetransmitIntervalMs ?? DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS
|
|
4520
|
+
);
|
|
4521
|
+
this.deliveryPollIntervalMs = validateIntervalOption(
|
|
4522
|
+
"deliveryPollIntervalMs",
|
|
4523
|
+
config.deliveryPollIntervalMs ?? DEFAULT_DELIVERY_POLL_INTERVAL_MS
|
|
4524
|
+
);
|
|
4525
|
+
this.schemaVersion = config.schemaVersion ?? storeSchemaVersion ?? DEFAULT_SCHEMA_VERSION2;
|
|
3064
4526
|
this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
|
|
3065
4527
|
min: this.schemaVersion,
|
|
3066
4528
|
max: this.schemaVersion
|
|
3067
4529
|
};
|
|
4530
|
+
this.operationTransforms = config.operationTransforms ?? [];
|
|
3068
4531
|
this.port = config.port;
|
|
3069
4532
|
this.host = config.host ?? DEFAULT_HOST;
|
|
3070
4533
|
this.path = config.path ?? DEFAULT_PATH;
|
|
3071
4534
|
this.logger = config.logger ?? createDefaultLogger();
|
|
3072
4535
|
this.metrics = config.metricsCollector ?? new ServerMetricsCollector();
|
|
3073
4536
|
this.metrics.setSchemaVersion(this.schemaVersion);
|
|
4537
|
+
this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
|
|
4538
|
+
this.persistBlobChunk = config.persistBlobChunk ?? null;
|
|
4539
|
+
this.maxOperationBytes = config.maxOperationBytes;
|
|
4540
|
+
this.maxOpsPerMinute = config.maxOpsPerMinute;
|
|
4541
|
+
this.validateOperation = config.validateOperation;
|
|
4542
|
+
this.koraContext = createRouteContext(this, this.store);
|
|
3074
4543
|
if (!this.emitter) {
|
|
3075
4544
|
this.emitter = new import_internal2.SimpleEventEmitter();
|
|
3076
4545
|
}
|
|
3077
4546
|
}
|
|
4547
|
+
ensureBackgroundTimersStarted() {
|
|
4548
|
+
if (this.relayRetransmitIntervalMs > 0 && !this.relayRetransmitTimer) {
|
|
4549
|
+
this.relayRetransmitTimer = setInterval(() => {
|
|
4550
|
+
this.retransmitPendingRelays(this.relayRetransmitIntervalMs);
|
|
4551
|
+
}, this.relayRetransmitIntervalMs);
|
|
4552
|
+
this.relayRetransmitTimer.unref?.();
|
|
4553
|
+
}
|
|
4554
|
+
if (this.deliveryPollIntervalMs > 0 && !this.deliveryPollTimer) {
|
|
4555
|
+
this.deliveryPollTimer = setInterval(() => {
|
|
4556
|
+
void this.pollDeliveryLog();
|
|
4557
|
+
}, this.deliveryPollIntervalMs);
|
|
4558
|
+
this.deliveryPollTimer.unref?.();
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4561
|
+
/**
|
|
4562
|
+
* Retransmit relay batches that connected clients have not acknowledged within
|
|
4563
|
+
* `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
|
|
4564
|
+
* already-applied operation is harmless (clients dedup by content-addressed id).
|
|
4565
|
+
*/
|
|
4566
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
4567
|
+
for (const session of this.sessions.values()) {
|
|
4568
|
+
session.retransmitPendingRelays(staleMs);
|
|
4569
|
+
}
|
|
4570
|
+
this.expireOrphanedRelays();
|
|
4571
|
+
}
|
|
4572
|
+
/**
|
|
4573
|
+
* Check whether the authoritative delivery log advanced without passing through
|
|
4574
|
+
* this server instance, then wake delivery-watermark sessions to scan from their
|
|
4575
|
+
* own acknowledged cursors. Session-level filtering remains the only fan-out path.
|
|
4576
|
+
*/
|
|
4577
|
+
async pollDeliveryLog() {
|
|
4578
|
+
if (this.deliveryPollInFlight) return;
|
|
4579
|
+
this.deliveryPollInFlight = true;
|
|
4580
|
+
try {
|
|
4581
|
+
const maxDeliverySequence = await this.store.getMaxDeliverySequence();
|
|
4582
|
+
if (maxDeliverySequence <= this.lastObservedDeliverySequence) {
|
|
4583
|
+
for (const session of this.sessions.values()) {
|
|
4584
|
+
session.pushDeliveryStreamIfSupported(this.deliveryPollIntervalMs, {
|
|
4585
|
+
trackStall: true
|
|
4586
|
+
});
|
|
4587
|
+
}
|
|
4588
|
+
return;
|
|
4589
|
+
}
|
|
4590
|
+
this.lastObservedDeliverySequence = maxDeliverySequence;
|
|
4591
|
+
for (const session of this.sessions.values()) {
|
|
4592
|
+
session.pushDeliveryStreamIfSupported(0);
|
|
4593
|
+
}
|
|
4594
|
+
} catch (error) {
|
|
4595
|
+
this.logger.log({
|
|
4596
|
+
timestamp: Date.now(),
|
|
4597
|
+
level: "warn",
|
|
4598
|
+
event: "delivery_poll.failed",
|
|
4599
|
+
details: { error: error instanceof Error ? error.message : String(error) }
|
|
4600
|
+
});
|
|
4601
|
+
} finally {
|
|
4602
|
+
this.deliveryPollInFlight = false;
|
|
4603
|
+
}
|
|
4604
|
+
}
|
|
4605
|
+
/**
|
|
4606
|
+
* Buffer relay operations a disconnected client never acknowledged, keyed by its
|
|
4607
|
+
* node id, deduped by operation id and bounded by count. Redelivered when the
|
|
4608
|
+
* client reconnects (see {@link takeOrphanedRelays}).
|
|
4609
|
+
*/
|
|
4610
|
+
bufferOrphanedRelays(nodeId, ops) {
|
|
4611
|
+
if (ops.length === 0) return;
|
|
4612
|
+
const existing = this.orphanedRelaysByNode.get(nodeId);
|
|
4613
|
+
const merged = existing ? existing.ops : [];
|
|
4614
|
+
const seen = new Set(merged.map((op) => op.id));
|
|
4615
|
+
for (const op of ops) {
|
|
4616
|
+
if (!seen.has(op.id)) {
|
|
4617
|
+
merged.push(op);
|
|
4618
|
+
seen.add(op.id);
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
|
|
4622
|
+
this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
|
|
4623
|
+
}
|
|
4624
|
+
/** Remove and return a node's buffered orphaned relay operations, if any. */
|
|
4625
|
+
takeOrphanedRelays(nodeId) {
|
|
4626
|
+
const entry = this.orphanedRelaysByNode.get(nodeId);
|
|
4627
|
+
if (!entry) return [];
|
|
4628
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4629
|
+
return entry.ops;
|
|
4630
|
+
}
|
|
4631
|
+
/** Drop buffered orphaned relays older than the TTL (client never returned). */
|
|
4632
|
+
expireOrphanedRelays() {
|
|
4633
|
+
if (this.orphanedRelaysByNode.size === 0) return;
|
|
4634
|
+
const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
|
|
4635
|
+
for (const [nodeId, entry] of this.orphanedRelaysByNode) {
|
|
4636
|
+
if (entry.bufferedAtMs <= cutoff) {
|
|
4637
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4638
|
+
}
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
/**
|
|
4642
|
+
* The trusted, scoped data-plane context (`apply` / `query` / `findById`).
|
|
4643
|
+
* Shared with the production server so HTTP routes, the operation validator,
|
|
4644
|
+
* and `server.kora` are all the same object over the same pipeline.
|
|
4645
|
+
*/
|
|
4646
|
+
getKoraContext() {
|
|
4647
|
+
return this.koraContext;
|
|
4648
|
+
}
|
|
3078
4649
|
/**
|
|
3079
4650
|
* Subscribe to session-level events for metrics collection and logging.
|
|
3080
4651
|
* Called when a new session is created.
|
|
@@ -3123,6 +4694,21 @@ var KoraSyncServer = class {
|
|
|
3123
4694
|
details: { reason: event.reason }
|
|
3124
4695
|
});
|
|
3125
4696
|
});
|
|
4697
|
+
sessionEmitter.on("sync:schema-mismatch", (event) => {
|
|
4698
|
+
this.logger.log({
|
|
4699
|
+
timestamp: Date.now(),
|
|
4700
|
+
level: "warn",
|
|
4701
|
+
event: "session.schema_mismatch",
|
|
4702
|
+
sessionId,
|
|
4703
|
+
details: {
|
|
4704
|
+
clientSchemaVersion: event.clientSchemaVersion,
|
|
4705
|
+
serverSchemaVersion: event.serverSchemaVersion,
|
|
4706
|
+
supportedMin: event.supportedMin,
|
|
4707
|
+
supportedMax: event.supportedMax,
|
|
4708
|
+
reason: event.reason
|
|
4709
|
+
}
|
|
4710
|
+
});
|
|
4711
|
+
});
|
|
3126
4712
|
}
|
|
3127
4713
|
/**
|
|
3128
4714
|
* Get the metrics collector for external access (e.g., HTTP endpoints).
|
|
@@ -3143,10 +4729,10 @@ var KoraSyncServer = class {
|
|
|
3143
4729
|
*/
|
|
3144
4730
|
async start(wsServerImpl) {
|
|
3145
4731
|
if (this.running) {
|
|
3146
|
-
throw new
|
|
4732
|
+
throw new import_core14.SyncError("Server is already running", { port: this.port });
|
|
3147
4733
|
}
|
|
3148
4734
|
if (!wsServerImpl && this.port === void 0) {
|
|
3149
|
-
throw new
|
|
4735
|
+
throw new import_core14.SyncError(
|
|
3150
4736
|
"Port is required for standalone mode. Provide port in config or use handleConnection() for attach mode.",
|
|
3151
4737
|
{}
|
|
3152
4738
|
);
|
|
@@ -3192,8 +4778,18 @@ var KoraSyncServer = class {
|
|
|
3192
4778
|
event: "server.stopping",
|
|
3193
4779
|
details: { connectedClients: this.sessions.size }
|
|
3194
4780
|
});
|
|
4781
|
+
if (this.relayRetransmitTimer) {
|
|
4782
|
+
clearInterval(this.relayRetransmitTimer);
|
|
4783
|
+
this.relayRetransmitTimer = null;
|
|
4784
|
+
}
|
|
4785
|
+
if (this.deliveryPollTimer) {
|
|
4786
|
+
clearInterval(this.deliveryPollTimer);
|
|
4787
|
+
this.deliveryPollTimer = null;
|
|
4788
|
+
}
|
|
4789
|
+
this.orphanedRelaysByNode.clear();
|
|
3195
4790
|
this.awarenessRelay.clear();
|
|
3196
4791
|
this.yjsDocRelay.clear();
|
|
4792
|
+
this.blobChunkRelay.clear();
|
|
3197
4793
|
for (const session of this.sessions.values()) {
|
|
3198
4794
|
session.close("server shutting down");
|
|
3199
4795
|
}
|
|
@@ -3255,7 +4851,7 @@ var KoraSyncServer = class {
|
|
|
3255
4851
|
if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
|
|
3256
4852
|
transport.send({
|
|
3257
4853
|
type: "error",
|
|
3258
|
-
messageId: (0,
|
|
4854
|
+
messageId: (0, import_core14.generateUUIDv7)(),
|
|
3259
4855
|
code: "MAX_CONNECTIONS",
|
|
3260
4856
|
message: `Server has reached maximum connections (${this.maxConnections})`,
|
|
3261
4857
|
retriable: true
|
|
@@ -3268,12 +4864,13 @@ var KoraSyncServer = class {
|
|
|
3268
4864
|
event: "connection.rejected",
|
|
3269
4865
|
details: { reason: "max_connections", max: this.maxConnections }
|
|
3270
4866
|
});
|
|
3271
|
-
throw new
|
|
4867
|
+
throw new import_core14.SyncError("Maximum connections reached", {
|
|
3272
4868
|
current: this.sessions.size,
|
|
3273
4869
|
max: this.maxConnections
|
|
3274
4870
|
});
|
|
3275
4871
|
}
|
|
3276
|
-
|
|
4872
|
+
this.ensureBackgroundTimersStarted();
|
|
4873
|
+
const sessionId = (0, import_core14.generateUUIDv7)();
|
|
3277
4874
|
this.metrics.recordConnection(sessionId);
|
|
3278
4875
|
const sessionEmitter = new import_internal2.SimpleEventEmitter();
|
|
3279
4876
|
sessionEmitter.on("sync:connected", (event) => {
|
|
@@ -3319,6 +4916,33 @@ var KoraSyncServer = class {
|
|
|
3319
4916
|
sessionId
|
|
3320
4917
|
});
|
|
3321
4918
|
});
|
|
4919
|
+
sessionEmitter.on("sync:schema-mismatch", (event) => {
|
|
4920
|
+
this.logger.log({
|
|
4921
|
+
timestamp: Date.now(),
|
|
4922
|
+
level: "warn",
|
|
4923
|
+
event: "session.schema_mismatch",
|
|
4924
|
+
sessionId,
|
|
4925
|
+
details: {
|
|
4926
|
+
clientSchemaVersion: event.clientSchemaVersion,
|
|
4927
|
+
serverSchemaVersion: event.serverSchemaVersion,
|
|
4928
|
+
supportedMin: event.supportedMin,
|
|
4929
|
+
supportedMax: event.supportedMax,
|
|
4930
|
+
reason: event.reason
|
|
4931
|
+
}
|
|
4932
|
+
});
|
|
4933
|
+
});
|
|
4934
|
+
sessionEmitter.on("sync:delivery-stalled", (event) => {
|
|
4935
|
+
this.logger.log({
|
|
4936
|
+
timestamp: Date.now(),
|
|
4937
|
+
level: "warn",
|
|
4938
|
+
event: "session.delivery_stalled",
|
|
4939
|
+
sessionId,
|
|
4940
|
+
details: {
|
|
4941
|
+
watermark: event.watermark,
|
|
4942
|
+
repeatCount: event.repeatCount
|
|
4943
|
+
}
|
|
4944
|
+
});
|
|
4945
|
+
});
|
|
3322
4946
|
const session = new ClientSession({
|
|
3323
4947
|
sessionId,
|
|
3324
4948
|
transport,
|
|
@@ -3326,9 +4950,11 @@ var KoraSyncServer = class {
|
|
|
3326
4950
|
auth: this.auth ?? void 0,
|
|
3327
4951
|
serializer: this.serializer,
|
|
3328
4952
|
emitter: sessionEmitter,
|
|
4953
|
+
logger: this.logger,
|
|
3329
4954
|
batchSize: this.batchSize,
|
|
3330
4955
|
schemaVersion: this.schemaVersion,
|
|
3331
4956
|
supportedSchemaVersions: this.supportedSchemaVersions,
|
|
4957
|
+
operationTransforms: this.operationTransforms,
|
|
3332
4958
|
onRelay: (sourceSessionId, operations2) => {
|
|
3333
4959
|
this.handleRelay(sourceSessionId, operations2);
|
|
3334
4960
|
},
|
|
@@ -3338,12 +4964,29 @@ var KoraSyncServer = class {
|
|
|
3338
4964
|
onYjsDocUpdate: (sourceSessionId, message) => {
|
|
3339
4965
|
this.handleYjsDocRelay(sourceSessionId, message);
|
|
3340
4966
|
},
|
|
4967
|
+
onBlobChunkRequest: (sourceSessionId, message) => {
|
|
4968
|
+
this.blobChunkRelay.handleRequest(sourceSessionId, message);
|
|
4969
|
+
},
|
|
4970
|
+
onBlobChunkResponse: (sourceSessionId, message) => {
|
|
4971
|
+
this.blobChunkRelay.handleResponse(sourceSessionId, message);
|
|
4972
|
+
},
|
|
4973
|
+
...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
|
|
4974
|
+
// Only forward when configured, so an unset server value leaves the
|
|
4975
|
+
// session on its own documented default rather than `undefined`.
|
|
4976
|
+
...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
|
|
4977
|
+
...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
|
|
4978
|
+
...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
|
|
3341
4979
|
onClose: (sid) => {
|
|
3342
4980
|
this.handleSessionClose(sid);
|
|
3343
|
-
}
|
|
4981
|
+
},
|
|
4982
|
+
onOrphanedRelays: (nodeId, ops) => {
|
|
4983
|
+
this.bufferOrphanedRelays(nodeId, ops);
|
|
4984
|
+
},
|
|
4985
|
+
takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
|
|
3344
4986
|
});
|
|
3345
4987
|
this.sessions.set(sessionId, session);
|
|
3346
4988
|
this.yjsDocRelay.addClient(sessionId, transport);
|
|
4989
|
+
this.blobChunkRelay.addClient(sessionId, transport);
|
|
3347
4990
|
session.start();
|
|
3348
4991
|
this.logger.log({
|
|
3349
4992
|
timestamp: Date.now(),
|
|
@@ -3376,6 +5019,77 @@ var KoraSyncServer = class {
|
|
|
3376
5019
|
errorCount: snapshot.errorCount
|
|
3377
5020
|
};
|
|
3378
5021
|
}
|
|
5022
|
+
/**
|
|
5023
|
+
* Apply a server-originated operation (for example one created by a custom
|
|
5024
|
+
* HTTP route) through the same validated pipeline that incoming client
|
|
5025
|
+
* operations use — Tier 2 constraints, referential integrity, and cascade
|
|
5026
|
+
* side effects — then relay every applied operation to connected clients.
|
|
5027
|
+
*
|
|
5028
|
+
* Because the operation did not come from a client session, it is relayed to
|
|
5029
|
+
* ALL sessions (there is no source session to exclude). Each session still
|
|
5030
|
+
* applies its own per-scope visibility filter in `relayOperations`, so a
|
|
5031
|
+
* client only receives the operation if it falls within that client's scope.
|
|
5032
|
+
*
|
|
5033
|
+
* @param op - A fully-formed, server-originated operation to apply
|
|
5034
|
+
* @returns The apply result, including any server-generated side-effect ops
|
|
5035
|
+
*/
|
|
5036
|
+
async applyLocalOperation(op) {
|
|
5037
|
+
const result = await applyServerOperation(this.store, op);
|
|
5038
|
+
if (result.result === "applied" && result.appliedOperations.length > 0) {
|
|
5039
|
+
for (const session of this.sessions.values()) {
|
|
5040
|
+
session.relayOperations(result.appliedOperations);
|
|
5041
|
+
}
|
|
5042
|
+
}
|
|
5043
|
+
return result;
|
|
5044
|
+
}
|
|
5045
|
+
/**
|
|
5046
|
+
* Relay already-applied, server-originated operations to every connected client.
|
|
5047
|
+
* Used by the conditional route apply, which commits its operations atomically
|
|
5048
|
+
* through the store (bypassing {@link applyLocalOperation}) and then fans them
|
|
5049
|
+
* out. Each session still enforces its own per-scope visibility filter.
|
|
5050
|
+
*
|
|
5051
|
+
* @param operations - Operations that have already been committed to the store
|
|
5052
|
+
*/
|
|
5053
|
+
relayServerOperations(operations2) {
|
|
5054
|
+
if (operations2.length === 0) {
|
|
5055
|
+
return;
|
|
5056
|
+
}
|
|
5057
|
+
for (const session of this.sessions.values()) {
|
|
5058
|
+
session.relayOperations(operations2);
|
|
5059
|
+
}
|
|
5060
|
+
}
|
|
5061
|
+
/**
|
|
5062
|
+
* Collect every blob reference still reachable from live records on the server.
|
|
5063
|
+
*
|
|
5064
|
+
* This is the live set for garbage-collecting the server's central blob store:
|
|
5065
|
+
* pass the result to `collectBlobGarbage(blobStore, liveRefs)` from
|
|
5066
|
+
* `@korajs/store` to reclaim bytes no record references any more. Only
|
|
5067
|
+
* collections that declare a `blob` field are scanned.
|
|
5068
|
+
*
|
|
5069
|
+
* @returns Every live blob reference across all collections
|
|
5070
|
+
*/
|
|
5071
|
+
async getLiveBlobRefs() {
|
|
5072
|
+
const schema = this.store.getSchema();
|
|
5073
|
+
if (!schema) {
|
|
5074
|
+
return [];
|
|
5075
|
+
}
|
|
5076
|
+
const refs = [];
|
|
5077
|
+
for (const [name, collection] of Object.entries(schema.collections)) {
|
|
5078
|
+
const hasBlobField = Object.values(collection.fields).some((field) => field.kind === "blob");
|
|
5079
|
+
if (!hasBlobField) {
|
|
5080
|
+
continue;
|
|
5081
|
+
}
|
|
5082
|
+
const records = await this.store.materializeCollection(name);
|
|
5083
|
+
for (const record of records) {
|
|
5084
|
+
for (const value of Object.values(record)) {
|
|
5085
|
+
if ((0, import_core14.isBlobRef)(value)) {
|
|
5086
|
+
refs.push(value);
|
|
5087
|
+
}
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
}
|
|
5091
|
+
return refs;
|
|
5092
|
+
}
|
|
3379
5093
|
/**
|
|
3380
5094
|
* Get the number of currently connected clients.
|
|
3381
5095
|
*/
|
|
@@ -3409,6 +5123,7 @@ var KoraSyncServer = class {
|
|
|
3409
5123
|
this.metrics.recordDisconnection(sessionId);
|
|
3410
5124
|
this.awarenessRelay.removeClient(sessionId);
|
|
3411
5125
|
this.yjsDocRelay.removeClient(sessionId);
|
|
5126
|
+
this.blobChunkRelay.removeClient(sessionId);
|
|
3412
5127
|
this.sessions.delete(sessionId);
|
|
3413
5128
|
const clientId = this.httpSessionToClient.get(sessionId);
|
|
3414
5129
|
if (clientId) {
|
|
@@ -3461,14 +5176,6 @@ function normalizeHttpBody(body, contentType) {
|
|
|
3461
5176
|
return body;
|
|
3462
5177
|
}
|
|
3463
5178
|
|
|
3464
|
-
// src/auth/no-auth.ts
|
|
3465
|
-
init_cjs_shims();
|
|
3466
|
-
var NoAuthProvider = class {
|
|
3467
|
-
async authenticate(_token) {
|
|
3468
|
-
return { userId: "anonymous" };
|
|
3469
|
-
}
|
|
3470
|
-
};
|
|
3471
|
-
|
|
3472
5179
|
// src/auth/token-auth.ts
|
|
3473
5180
|
init_cjs_shims();
|
|
3474
5181
|
var TokenAuthProvider = class {
|
|
@@ -3573,11 +5280,13 @@ function createProductionServer(config) {
|
|
|
3573
5280
|
const port = config.port ?? (Number(process.env.PORT) || 3001);
|
|
3574
5281
|
const staticDir = config.staticDir ?? "./dist";
|
|
3575
5282
|
const syncPath = config.syncPath ?? "/kora-sync";
|
|
5283
|
+
const crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy ?? "credentialless";
|
|
3576
5284
|
const syncServer = new KoraSyncServer({
|
|
3577
5285
|
store: config.store,
|
|
3578
5286
|
enableDashboard: true,
|
|
3579
5287
|
...config.syncOptions
|
|
3580
5288
|
});
|
|
5289
|
+
const routeContext = syncServer.getKoraContext();
|
|
3581
5290
|
let httpServer = null;
|
|
3582
5291
|
function getOperationalToken(kind) {
|
|
3583
5292
|
const auth = config.operationalAuth;
|
|
@@ -3664,6 +5373,10 @@ function createProductionServer(config) {
|
|
|
3664
5373
|
const chunks = [];
|
|
3665
5374
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
3666
5375
|
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
5376
|
+
if (!req.readableFlowing) {
|
|
5377
|
+
req.resume();
|
|
5378
|
+
}
|
|
5379
|
+
req.on("error", () => resolve(Buffer.concat(chunks)));
|
|
3667
5380
|
});
|
|
3668
5381
|
}
|
|
3669
5382
|
async function readJsonBody(req) {
|
|
@@ -3713,6 +5426,10 @@ function createProductionServer(config) {
|
|
|
3713
5426
|
res.end(JSON.stringify(result.body ?? null));
|
|
3714
5427
|
}
|
|
3715
5428
|
return {
|
|
5429
|
+
kora: routeContext,
|
|
5430
|
+
getLiveBlobRefs() {
|
|
5431
|
+
return syncServer.getLiveBlobRefs();
|
|
5432
|
+
},
|
|
3716
5433
|
async start() {
|
|
3717
5434
|
const { createServer } = await import("http");
|
|
3718
5435
|
const { createReadStream, existsSync, statSync } = await import("fs");
|
|
@@ -3720,8 +5437,21 @@ function createProductionServer(config) {
|
|
|
3720
5437
|
const { WebSocketServer } = await import("ws");
|
|
3721
5438
|
const distDir = resolve(staticDir);
|
|
3722
5439
|
httpServer = createServer(async (req, res) => {
|
|
5440
|
+
try {
|
|
5441
|
+
await handleRequest(req, res);
|
|
5442
|
+
} catch (error) {
|
|
5443
|
+
console.error("[kora] Unhandled error in HTTP request handler:", error);
|
|
5444
|
+
if (!res.headersSent) {
|
|
5445
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
5446
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
5447
|
+
} else if (!res.writableEnded) {
|
|
5448
|
+
res.end();
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5451
|
+
});
|
|
5452
|
+
async function handleRequest(req, res) {
|
|
3723
5453
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
3724
|
-
res.setHeader("Cross-Origin-Embedder-Policy",
|
|
5454
|
+
res.setHeader("Cross-Origin-Embedder-Policy", crossOriginEmbedderPolicy);
|
|
3725
5455
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
3726
5456
|
if (url.pathname === "/health") {
|
|
3727
5457
|
const status = await syncServer.getStatus();
|
|
@@ -3847,7 +5577,8 @@ data: ${JSON.stringify(s)}
|
|
|
3847
5577
|
body: await readJsonBody(req),
|
|
3848
5578
|
headers: req.headers,
|
|
3849
5579
|
query: getQuery(url),
|
|
3850
|
-
ip: getClientIp(req)
|
|
5580
|
+
ip: getClientIp(req),
|
|
5581
|
+
kora: routeContext
|
|
3851
5582
|
});
|
|
3852
5583
|
writeJsonResponse(res, result);
|
|
3853
5584
|
return;
|
|
@@ -3881,7 +5612,7 @@ data: ${JSON.stringify(s)}
|
|
|
3881
5612
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
3882
5613
|
res.writeHead(200, { "Content-Type": contentType });
|
|
3883
5614
|
createReadStream(filePath).pipe(res);
|
|
3884
|
-
}
|
|
5615
|
+
}
|
|
3885
5616
|
const wss = new WebSocketServer({ noServer: true });
|
|
3886
5617
|
httpServer.on("upgrade", (req, socket, head) => {
|
|
3887
5618
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
@@ -3894,8 +5625,13 @@ data: ${JSON.stringify(s)}
|
|
|
3894
5625
|
socket.destroy();
|
|
3895
5626
|
}
|
|
3896
5627
|
});
|
|
3897
|
-
return new Promise((resolve2) => {
|
|
5628
|
+
return new Promise((resolve2, reject) => {
|
|
5629
|
+
const onError = (error) => {
|
|
5630
|
+
reject(error);
|
|
5631
|
+
};
|
|
5632
|
+
httpServer?.once("error", onError);
|
|
3898
5633
|
httpServer?.listen(port, "0.0.0.0", () => {
|
|
5634
|
+
httpServer?.off("error", onError);
|
|
3899
5635
|
resolve2(`http://localhost:${port}`);
|
|
3900
5636
|
});
|
|
3901
5637
|
});
|
|
@@ -3998,6 +5734,7 @@ function formatUptime(ms) {
|
|
|
3998
5734
|
MixedAuthProvider,
|
|
3999
5735
|
NoAuthProvider,
|
|
4000
5736
|
PostgresServerStore,
|
|
5737
|
+
RETRIABLE_REJECTION_CODES,
|
|
4001
5738
|
SqliteServerStore,
|
|
4002
5739
|
TokenAuthProvider,
|
|
4003
5740
|
WsServerTransport,
|
|
@@ -4008,6 +5745,7 @@ function formatUptime(ms) {
|
|
|
4008
5745
|
createPrettyLogger,
|
|
4009
5746
|
createProductionServer,
|
|
4010
5747
|
createSilentLogger,
|
|
4011
|
-
createSqliteServerStore
|
|
5748
|
+
createSqliteServerStore,
|
|
5749
|
+
isRetriableRejection
|
|
4012
5750
|
});
|
|
4013
5751
|
//# sourceMappingURL=index.cjs.map
|