@korajs/server 1.0.0-beta.1 → 1.0.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1740 -348
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +918 -279
- package/dist/index.d.ts +918 -279
- package/dist/index.js +1720 -326
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.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
|
};
|
|
@@ -169,6 +169,7 @@ var index_exports = {};
|
|
|
169
169
|
__export(index_exports, {
|
|
170
170
|
AwarenessRelay: () => AwarenessRelay,
|
|
171
171
|
ClientSession: () => ClientSession,
|
|
172
|
+
DEFAULT_MAX_SCOPE_PREDICATE_VALUES: () => DEFAULT_MAX_SCOPE_PREDICATE_VALUES,
|
|
172
173
|
HttpServerTransport: () => HttpServerTransport,
|
|
173
174
|
KoraAuthProvider: () => KoraAuthProvider,
|
|
174
175
|
KoraSyncServer: () => KoraSyncServer,
|
|
@@ -176,6 +177,7 @@ __export(index_exports, {
|
|
|
176
177
|
MixedAuthProvider: () => MixedAuthProvider,
|
|
177
178
|
NoAuthProvider: () => NoAuthProvider,
|
|
178
179
|
PostgresServerStore: () => PostgresServerStore,
|
|
180
|
+
RETRIABLE_REJECTION_CODES: () => RETRIABLE_REJECTION_CODES,
|
|
179
181
|
SqliteServerStore: () => SqliteServerStore,
|
|
180
182
|
TokenAuthProvider: () => TokenAuthProvider,
|
|
181
183
|
WsServerTransport: () => WsServerTransport,
|
|
@@ -186,7 +188,10 @@ __export(index_exports, {
|
|
|
186
188
|
createPrettyLogger: () => createPrettyLogger,
|
|
187
189
|
createProductionServer: () => createProductionServer,
|
|
188
190
|
createSilentLogger: () => createSilentLogger,
|
|
189
|
-
createSqliteServerStore: () => createSqliteServerStore
|
|
191
|
+
createSqliteServerStore: () => createSqliteServerStore,
|
|
192
|
+
isRetriableRejection: () => isRetriableRejection,
|
|
193
|
+
normalizeScopeMap: () => normalizeScopeMap,
|
|
194
|
+
operationMatchesScopes: () => operationMatchesScopes
|
|
190
195
|
});
|
|
191
196
|
module.exports = __toCommonJS(index_exports);
|
|
192
197
|
init_cjs_shims();
|
|
@@ -248,6 +253,13 @@ function createDefaultLogger() {
|
|
|
248
253
|
}
|
|
249
254
|
}
|
|
250
255
|
|
|
256
|
+
// src/apply/rejection-taxonomy.ts
|
|
257
|
+
init_cjs_shims();
|
|
258
|
+
var RETRIABLE_REJECTION_CODES = /* @__PURE__ */ new Set(["RATE_LIMIT"]);
|
|
259
|
+
function isRetriableRejection(code) {
|
|
260
|
+
return RETRIABLE_REJECTION_CODES.has(code);
|
|
261
|
+
}
|
|
262
|
+
|
|
251
263
|
// src/store/memory-server-store.ts
|
|
252
264
|
init_cjs_shims();
|
|
253
265
|
var import_core2 = require("@korajs/core");
|
|
@@ -269,6 +281,14 @@ function fieldTypeToSql(descriptor, dialect) {
|
|
|
269
281
|
return dialect === "postgres" ? "BIGINT" : "INTEGER";
|
|
270
282
|
case "array":
|
|
271
283
|
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
284
|
+
case "object":
|
|
285
|
+
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
286
|
+
case "json":
|
|
287
|
+
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
288
|
+
case "blob":
|
|
289
|
+
return "TEXT";
|
|
290
|
+
case "secret":
|
|
291
|
+
return "TEXT";
|
|
272
292
|
case "richtext":
|
|
273
293
|
return dialect === "postgres" ? "BYTEA" : "BLOB";
|
|
274
294
|
}
|
|
@@ -285,13 +305,13 @@ function generateCollectionDDL(name, collection, dialect) {
|
|
|
285
305
|
const columns = ["id TEXT PRIMARY KEY NOT NULL"];
|
|
286
306
|
for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
|
|
287
307
|
const sqlType = fieldTypeToSql(descriptor, dialect);
|
|
288
|
-
let colDef = `${fieldName} ${sqlType}`;
|
|
308
|
+
let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
|
|
289
309
|
if (descriptor.defaultValue !== void 0) {
|
|
290
310
|
colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
|
|
291
311
|
}
|
|
292
312
|
if (descriptor.kind === "enum" && descriptor.enumValues) {
|
|
293
313
|
const values = descriptor.enumValues.map((v) => `'${v}'`).join(", ");
|
|
294
|
-
colDef += ` CHECK (${fieldName} IN (${values}))`;
|
|
314
|
+
colDef += ` CHECK (${(0, import_core.quoteIdent)(fieldName)} IN (${values}))`;
|
|
295
315
|
}
|
|
296
316
|
columns.push(colDef);
|
|
297
317
|
}
|
|
@@ -299,24 +319,26 @@ function generateCollectionDDL(name, collection, dialect) {
|
|
|
299
319
|
columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
|
|
300
320
|
columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
|
|
301
321
|
columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
|
|
302
|
-
statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
|
|
322
|
+
statements.push(`CREATE TABLE IF NOT EXISTS ${(0, import_core.quoteIdent)(name)} (
|
|
303
323
|
${columns.join(",\n ")}
|
|
304
324
|
)`);
|
|
305
325
|
for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
|
|
306
326
|
const sqlType = fieldTypeToSql(descriptor, dialect);
|
|
307
|
-
let colDef = `${fieldName} ${sqlType}`;
|
|
327
|
+
let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
|
|
308
328
|
if (descriptor.defaultValue !== void 0) {
|
|
309
329
|
colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
|
|
310
330
|
}
|
|
311
331
|
statements.push(`--kora:safe-alter
|
|
312
|
-
ALTER TABLE ${name} ADD COLUMN ${colDef}`);
|
|
332
|
+
ALTER TABLE ${(0, import_core.quoteIdent)(name)} ADD COLUMN ${colDef}`);
|
|
313
333
|
}
|
|
314
334
|
for (const indexField of collection.indexes) {
|
|
315
335
|
statements.push(
|
|
316
|
-
`CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
|
|
336
|
+
`CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${(0, import_core.quoteIdent)(name)} (${(0, import_core.quoteIdent)(indexField)})`
|
|
317
337
|
);
|
|
318
338
|
}
|
|
319
|
-
statements.push(
|
|
339
|
+
statements.push(
|
|
340
|
+
`CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${(0, import_core.quoteIdent)(name)} (_deleted)`
|
|
341
|
+
);
|
|
320
342
|
return statements;
|
|
321
343
|
}
|
|
322
344
|
function generateAllCollectionDDL(schema, dialect) {
|
|
@@ -326,30 +348,6 @@ function generateAllCollectionDDL(schema, dialect) {
|
|
|
326
348
|
}
|
|
327
349
|
return statements;
|
|
328
350
|
}
|
|
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
351
|
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
354
352
|
var BASE64_REVERSE = new Map(
|
|
355
353
|
Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
|
|
@@ -400,6 +398,9 @@ function serializeFieldValue(value, descriptor) {
|
|
|
400
398
|
if (value === null || value === void 0) return null;
|
|
401
399
|
switch (descriptor.kind) {
|
|
402
400
|
case "array":
|
|
401
|
+
case "object":
|
|
402
|
+
case "json":
|
|
403
|
+
case "blob":
|
|
403
404
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
404
405
|
case "boolean":
|
|
405
406
|
return value ? 1 : 0;
|
|
@@ -413,6 +414,9 @@ function deserializeFieldValue(value, descriptor) {
|
|
|
413
414
|
if (value === null || value === void 0) return null;
|
|
414
415
|
switch (descriptor.kind) {
|
|
415
416
|
case "array":
|
|
417
|
+
case "object":
|
|
418
|
+
case "json":
|
|
419
|
+
case "blob":
|
|
416
420
|
return typeof value === "string" ? JSON.parse(value) : value;
|
|
417
421
|
case "boolean":
|
|
418
422
|
return value === 1 || value === true;
|
|
@@ -445,6 +449,14 @@ var MemoryServerStore = class {
|
|
|
445
449
|
operations = [];
|
|
446
450
|
operationIndex = /* @__PURE__ */ new Map();
|
|
447
451
|
versionVector = /* @__PURE__ */ new Map();
|
|
452
|
+
/**
|
|
453
|
+
* Server-assigned delivery sequence per operation id. Single-process, so a plain
|
|
454
|
+
* counter incremented at insert time gives commit-order delivery sequence with no
|
|
455
|
+
* gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
|
|
456
|
+
* is a forward walk.
|
|
457
|
+
*/
|
|
458
|
+
deliverySeqByOpId = /* @__PURE__ */ new Map();
|
|
459
|
+
deliverySeqCounter = 0;
|
|
448
460
|
schema = null;
|
|
449
461
|
/** Materialized records: collection -> recordId -> record data */
|
|
450
462
|
materializedRecords = /* @__PURE__ */ new Map();
|
|
@@ -478,6 +490,8 @@ var MemoryServerStore = class {
|
|
|
478
490
|
}
|
|
479
491
|
this.operations.push(op);
|
|
480
492
|
this.operationIndex.set(op.id, op);
|
|
493
|
+
this.deliverySeqCounter += 1;
|
|
494
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
481
495
|
const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
|
|
482
496
|
if (op.sequenceNumber > currentSeq) {
|
|
483
497
|
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
@@ -497,6 +511,23 @@ var MemoryServerStore = class {
|
|
|
497
511
|
this.assertOpen();
|
|
498
512
|
return this.operations.length;
|
|
499
513
|
}
|
|
514
|
+
async getMaxDeliverySequence() {
|
|
515
|
+
this.assertOpen();
|
|
516
|
+
return this.deliverySeqCounter;
|
|
517
|
+
}
|
|
518
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
519
|
+
this.assertOpen();
|
|
520
|
+
const result = [];
|
|
521
|
+
if (limit <= 0) return result;
|
|
522
|
+
for (const op of this.operations) {
|
|
523
|
+
const deliverySequence = this.deliverySeqByOpId.get(op.id) ?? 0;
|
|
524
|
+
if (deliverySequence > afterDeliverySequence) {
|
|
525
|
+
result.push({ operation: op, deliverySequence });
|
|
526
|
+
if (result.length >= limit) break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return result;
|
|
530
|
+
}
|
|
500
531
|
async materializeCollection(collection) {
|
|
501
532
|
this.assertOpen();
|
|
502
533
|
if (this.schema?.collections[collection]) {
|
|
@@ -641,12 +672,16 @@ var MemoryServerStore = class {
|
|
|
641
672
|
this.operations.length = 0;
|
|
642
673
|
this.operationIndex.clear();
|
|
643
674
|
this.versionVector.clear();
|
|
675
|
+
this.deliverySeqByOpId.clear();
|
|
676
|
+
this.deliverySeqCounter = 0;
|
|
644
677
|
for (const [nid, seq] of versionVector) {
|
|
645
678
|
this.versionVector.set(nid, seq);
|
|
646
679
|
}
|
|
647
680
|
for (const op of operations2) {
|
|
648
681
|
this.operations.push(op);
|
|
649
682
|
this.operationIndex.set(op.id, op);
|
|
683
|
+
this.deliverySeqCounter += 1;
|
|
684
|
+
this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
|
|
650
685
|
if (this.schema?.collections[op.collection]) {
|
|
651
686
|
this.rebuildMaterializedRecord(op.collection, op.recordId);
|
|
652
687
|
}
|
|
@@ -676,13 +711,14 @@ var MemoryServerStore = class {
|
|
|
676
711
|
return a.timestamp.wallTime - b.timestamp.wallTime;
|
|
677
712
|
if (a.timestamp.logical !== b.timestamp.logical)
|
|
678
713
|
return a.timestamp.logical - b.timestamp.logical;
|
|
679
|
-
return a.
|
|
714
|
+
return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
|
|
680
715
|
});
|
|
681
716
|
const parsedOps = recordOps.map((op) => ({
|
|
682
717
|
type: op.type,
|
|
683
|
-
data: op.data
|
|
718
|
+
data: op.data,
|
|
719
|
+
atomicOps: op.atomicOps ?? null
|
|
684
720
|
}));
|
|
685
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
721
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
686
722
|
if (recordData) {
|
|
687
723
|
const createdAt = recordOps.length > 0 ? recordOps[0].timestamp.wallTime : Date.now();
|
|
688
724
|
const updatedAt = recordOps.length > 0 ? recordOps[recordOps.length - 1].timestamp.wallTime : Date.now();
|
|
@@ -805,6 +841,8 @@ var pgOperations = (0, import_pg_core.pgTable)(
|
|
|
805
841
|
// JSON-serialized, null for deletes
|
|
806
842
|
previousData: (0, import_pg_core.text)("previous_data"),
|
|
807
843
|
// JSON-serialized, null for insert/delete
|
|
844
|
+
atomicOps: (0, import_pg_core.text)("atomic_ops"),
|
|
845
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
808
846
|
wallTime: (0, import_pg_core.bigint)("wall_time", { mode: "number" }).notNull(),
|
|
809
847
|
logical: (0, import_pg_core.integer)("logical").notNull(),
|
|
810
848
|
timestampNodeId: (0, import_pg_core.text)("timestamp_node_id").notNull(),
|
|
@@ -812,12 +850,18 @@ var pgOperations = (0, import_pg_core.pgTable)(
|
|
|
812
850
|
causalDeps: (0, import_pg_core.text)("causal_deps").notNull().default("[]"),
|
|
813
851
|
// JSON array of op IDs
|
|
814
852
|
schemaVersion: (0, import_pg_core.integer)("schema_version").notNull(),
|
|
815
|
-
receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull()
|
|
853
|
+
receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull(),
|
|
854
|
+
// Server-assigned monotonic delivery sequence, ordered by commit (assigned from
|
|
855
|
+
// a counter row locked inside the append transaction, so delivery order equals
|
|
856
|
+
// visibility order even across instances). Drives the gap-free server->client
|
|
857
|
+
// delivery watermark. Nullable only for pre-column rows, backfilled on startup.
|
|
858
|
+
deliverySeq: (0, import_pg_core.bigint)("delivery_seq", { mode: "number" })
|
|
816
859
|
},
|
|
817
860
|
(table) => ({
|
|
818
861
|
nodeSeqIdx: (0, import_pg_core.index)("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
819
862
|
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)
|
|
863
|
+
receivedIdx: (0, import_pg_core.index)("idx_pg_received").on(table.receivedAt),
|
|
864
|
+
deliveryIdx: (0, import_pg_core.index)("idx_pg_delivery_seq").on(table.deliverySeq)
|
|
821
865
|
})
|
|
822
866
|
);
|
|
823
867
|
var pgSyncState = (0, import_pg_core.pgTable)("sync_state", {
|
|
@@ -834,15 +878,43 @@ var PostgresServerStore = class {
|
|
|
834
878
|
ready;
|
|
835
879
|
schema = null;
|
|
836
880
|
closed = false;
|
|
837
|
-
|
|
881
|
+
/**
|
|
882
|
+
* Monotonic counter for this node's server-originated sequence numbers. Lazily
|
|
883
|
+
* seeded from the persisted version vector on first use, then advanced in memory
|
|
884
|
+
* (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
|
|
885
|
+
* applies never receive the same number. Null means "not yet seeded"; reset to
|
|
886
|
+
* null after a backup import so it re-seeds from the restored version vector.
|
|
887
|
+
*/
|
|
888
|
+
sequenceCounter = null;
|
|
889
|
+
/**
|
|
890
|
+
* Time source for HLC timestamps on server-originated operations. Injectable so a
|
|
891
|
+
* test can freeze wall-clock time and prove the conditional-apply ordering holds
|
|
892
|
+
* under same-millisecond commits (the case the advisory lock and clock advance
|
|
893
|
+
* exist to make correct). Defaults to the system clock in production.
|
|
894
|
+
*/
|
|
895
|
+
timeSource;
|
|
896
|
+
constructor(db, nodeId, timeSource) {
|
|
838
897
|
this.db = db;
|
|
839
898
|
this.nodeId = nodeId ?? (0, import_core3.generateUUIDv7)();
|
|
899
|
+
this.timeSource = timeSource ?? { now: () => Date.now() };
|
|
840
900
|
this.ready = this.initialize();
|
|
841
901
|
}
|
|
842
902
|
getVersionVector() {
|
|
843
903
|
this.assertOpen();
|
|
844
904
|
return new Map(this.versionVector);
|
|
845
905
|
}
|
|
906
|
+
/**
|
|
907
|
+
* Atomically reserve this node's next server-operation sequence number. The
|
|
908
|
+
* synchronous increment cannot interleave on the single JS thread, so two
|
|
909
|
+
* concurrent conditional applies are always handed distinct numbers.
|
|
910
|
+
*/
|
|
911
|
+
reserveSequenceNumber() {
|
|
912
|
+
if (this.sequenceCounter === null) {
|
|
913
|
+
this.sequenceCounter = this.versionVector.get(this.nodeId) ?? 0;
|
|
914
|
+
}
|
|
915
|
+
this.sequenceCounter += 1;
|
|
916
|
+
return this.sequenceCounter;
|
|
917
|
+
}
|
|
846
918
|
getNodeId() {
|
|
847
919
|
return this.nodeId;
|
|
848
920
|
}
|
|
@@ -880,8 +952,9 @@ var PostgresServerStore = class {
|
|
|
880
952
|
return "duplicate";
|
|
881
953
|
}
|
|
882
954
|
const now = Date.now();
|
|
883
|
-
const row = this.serializeOperation(op, now);
|
|
884
955
|
await this.db.transaction(async (tx) => {
|
|
956
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
957
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
885
958
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
886
959
|
await tx.insert(pgSyncState).values({
|
|
887
960
|
nodeId: op.nodeId,
|
|
@@ -904,6 +977,73 @@ var PostgresServerStore = class {
|
|
|
904
977
|
}
|
|
905
978
|
return "applied";
|
|
906
979
|
}
|
|
980
|
+
/**
|
|
981
|
+
* Conditionally apply operations atomically, serialized across server instances
|
|
982
|
+
* on the target record via a transaction-scoped advisory lock. The lock makes
|
|
983
|
+
* the read-decide-write cycle atomic against concurrent instances, so a cap
|
|
984
|
+
* check like `responseCount < max` cannot be passed by two admissions at once.
|
|
985
|
+
*/
|
|
986
|
+
async applyConditional(input) {
|
|
987
|
+
this.assertOpen();
|
|
988
|
+
await this.ready;
|
|
989
|
+
this.assertSchema();
|
|
990
|
+
this.assertCollection(input.target.collection);
|
|
991
|
+
const schema = this.schema;
|
|
992
|
+
const collectionDef = schema.collections[input.target.collection];
|
|
993
|
+
const lockKey = `kora:${input.target.collection}:${input.target.id}`;
|
|
994
|
+
const result = await this.db.transaction(async (tx) => {
|
|
995
|
+
await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
|
996
|
+
if (input.idempotencyKey) {
|
|
997
|
+
const existing = await tx.execute(
|
|
998
|
+
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`
|
|
999
|
+
);
|
|
1000
|
+
if (existing.length > 0) {
|
|
1001
|
+
return { admitted: true, idempotent: true, applied: [] };
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
const rows = await tx.execute(
|
|
1005
|
+
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`
|
|
1006
|
+
);
|
|
1007
|
+
const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
|
|
1008
|
+
if (!input.admit(current)) {
|
|
1009
|
+
return { admitted: false, idempotent: false, applied: [] };
|
|
1010
|
+
}
|
|
1011
|
+
const clock = new import_core3.HybridLogicalClock(this.nodeId, this.timeSource);
|
|
1012
|
+
const latest = await this.latestOperationTimestamp(
|
|
1013
|
+
tx,
|
|
1014
|
+
input.target.collection,
|
|
1015
|
+
input.target.id
|
|
1016
|
+
);
|
|
1017
|
+
if (latest) {
|
|
1018
|
+
clock.advanceTo(latest);
|
|
1019
|
+
}
|
|
1020
|
+
const ops = await input.buildOperations(current, { clock });
|
|
1021
|
+
const now = Date.now();
|
|
1022
|
+
for (const op of ops) {
|
|
1023
|
+
const deliverySeq = await this.nextDeliverySeq(tx);
|
|
1024
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
1025
|
+
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
1026
|
+
await tx.insert(pgSyncState).values({ nodeId: op.nodeId, maxSequenceNumber: op.sequenceNumber, lastSeenAt: now }).onConflictDoUpdate({
|
|
1027
|
+
target: pgSyncState.nodeId,
|
|
1028
|
+
set: {
|
|
1029
|
+
maxSequenceNumber: import_drizzle_orm.sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
|
|
1030
|
+
lastSeenAt: import_drizzle_orm.sql`${now}`
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
if (this.schema?.collections[op.collection]) {
|
|
1034
|
+
await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return { admitted: true, idempotent: false, applied: ops };
|
|
1038
|
+
});
|
|
1039
|
+
for (const op of result.applied) {
|
|
1040
|
+
const currentMax = this.versionVector.get(op.nodeId) ?? 0;
|
|
1041
|
+
if (op.sequenceNumber > currentMax) {
|
|
1042
|
+
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return result;
|
|
1046
|
+
}
|
|
907
1047
|
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
908
1048
|
this.assertOpen();
|
|
909
1049
|
await this.ready;
|
|
@@ -918,6 +1058,23 @@ var PostgresServerStore = class {
|
|
|
918
1058
|
const result = await this.db.select({ value: (0, import_drizzle_orm.count)() }).from(pgOperations);
|
|
919
1059
|
return result[0]?.value ?? 0;
|
|
920
1060
|
}
|
|
1061
|
+
async getMaxDeliverySequence() {
|
|
1062
|
+
this.assertOpen();
|
|
1063
|
+
await this.ready;
|
|
1064
|
+
const rows = await this.db.execute(
|
|
1065
|
+
import_drizzle_orm.sql`SELECT COALESCE(MAX(delivery_seq), 0) AS m FROM operations`
|
|
1066
|
+
);
|
|
1067
|
+
return Number(rows[0]?.m ?? 0);
|
|
1068
|
+
}
|
|
1069
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
1070
|
+
this.assertOpen();
|
|
1071
|
+
await this.ready;
|
|
1072
|
+
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);
|
|
1073
|
+
return rows.map((row) => ({
|
|
1074
|
+
operation: this.deserializeOperation(row),
|
|
1075
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
1076
|
+
}));
|
|
1077
|
+
}
|
|
921
1078
|
async materializeCollection(collection) {
|
|
922
1079
|
this.assertOpen();
|
|
923
1080
|
await this.ready;
|
|
@@ -952,7 +1109,7 @@ var PostgresServerStore = class {
|
|
|
952
1109
|
this.assertCollection(collection);
|
|
953
1110
|
const schema = this.schema;
|
|
954
1111
|
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`;
|
|
1112
|
+
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
1113
|
const rows = await this.db.execute(query);
|
|
957
1114
|
if (rows.length === 0) return null;
|
|
958
1115
|
return this.deserializeRow(rows[0], collectionDef);
|
|
@@ -969,7 +1126,7 @@ var PostgresServerStore = class {
|
|
|
969
1126
|
}
|
|
970
1127
|
}
|
|
971
1128
|
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}`;
|
|
1129
|
+
const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`;
|
|
973
1130
|
const rows = await this.db.execute(query);
|
|
974
1131
|
const cnt = rows[0]?.cnt;
|
|
975
1132
|
return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
|
|
@@ -981,7 +1138,7 @@ var PostgresServerStore = class {
|
|
|
981
1138
|
this.assertOpen();
|
|
982
1139
|
await this.ready;
|
|
983
1140
|
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.
|
|
1141
|
+
const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.deliverySeq));
|
|
985
1142
|
const operations2 = rows.map((row) => this.deserializeOperation(row));
|
|
986
1143
|
return buildServerBackup2(this.nodeId, operations2, this.versionVector);
|
|
987
1144
|
}
|
|
@@ -996,6 +1153,7 @@ var PostgresServerStore = class {
|
|
|
996
1153
|
const result = await this.applyRemoteOperation(op);
|
|
997
1154
|
if (result === "applied") restored++;
|
|
998
1155
|
}
|
|
1156
|
+
this.sequenceCounter = null;
|
|
999
1157
|
return { operationsRestored: restored, success: true };
|
|
1000
1158
|
}
|
|
1001
1159
|
const now = Date.now();
|
|
@@ -1005,20 +1163,49 @@ var PostgresServerStore = class {
|
|
|
1005
1163
|
for (const [nid, seq] of versionVector) {
|
|
1006
1164
|
await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
|
|
1007
1165
|
}
|
|
1166
|
+
let deliverySeq = 0;
|
|
1008
1167
|
for (const op of operations2) {
|
|
1009
|
-
|
|
1168
|
+
deliverySeq += 1;
|
|
1169
|
+
const row = this.serializeOperation(op, now, deliverySeq);
|
|
1010
1170
|
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
1011
1171
|
}
|
|
1172
|
+
await tx.execute(
|
|
1173
|
+
import_drizzle_orm.sql`INSERT INTO delivery_counter (id, value) VALUES (1, ${deliverySeq})
|
|
1174
|
+
ON CONFLICT (id) DO UPDATE SET value = ${deliverySeq}`
|
|
1175
|
+
);
|
|
1012
1176
|
});
|
|
1013
1177
|
this.versionVector.clear();
|
|
1014
1178
|
for (const [nid, seq] of versionVector) {
|
|
1015
1179
|
this.versionVector.set(nid, seq);
|
|
1016
1180
|
}
|
|
1181
|
+
this.sequenceCounter = null;
|
|
1017
1182
|
return { operationsRestored: operations2.length, success: true };
|
|
1018
1183
|
}
|
|
1019
1184
|
// ---------------------------------------------------------------------------
|
|
1020
1185
|
// Materialization internals
|
|
1021
1186
|
// ---------------------------------------------------------------------------
|
|
1187
|
+
/**
|
|
1188
|
+
* The HLC timestamp of the most recent operation on a record, by total order
|
|
1189
|
+
* (wallTime, logical, nodeId), or null when the record has no operations. Used
|
|
1190
|
+
* by {@link applyConditional} to advance its clock past the record's latest
|
|
1191
|
+
* write so newly built operations sort strictly after it.
|
|
1192
|
+
*/
|
|
1193
|
+
async latestOperationTimestamp(txOrDb, collection, recordId) {
|
|
1194
|
+
const rows = await txOrDb.select({
|
|
1195
|
+
wallTime: pgOperations.wallTime,
|
|
1196
|
+
logical: pgOperations.logical,
|
|
1197
|
+
timestampNodeId: pgOperations.timestampNodeId
|
|
1198
|
+
}).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(
|
|
1199
|
+
(0, import_drizzle_orm.desc)(pgOperations.wallTime),
|
|
1200
|
+
(0, import_drizzle_orm.desc)(pgOperations.logical),
|
|
1201
|
+
(0, import_drizzle_orm.desc)(pgOperations.timestampNodeId)
|
|
1202
|
+
).limit(1);
|
|
1203
|
+
const row = rows[0];
|
|
1204
|
+
if (!row) {
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
return { wallTime: row.wallTime, logical: row.logical, nodeId: row.timestampNodeId };
|
|
1208
|
+
}
|
|
1022
1209
|
/**
|
|
1023
1210
|
* Rebuild a single record in the materialized collection table by replaying
|
|
1024
1211
|
* all operations for that record.
|
|
@@ -1029,17 +1216,19 @@ var PostgresServerStore = class {
|
|
|
1029
1216
|
const ops = await txOrDb.select({
|
|
1030
1217
|
type: pgOperations.type,
|
|
1031
1218
|
data: pgOperations.data,
|
|
1219
|
+
atomicOps: pgOperations.atomicOps,
|
|
1032
1220
|
wallTime: pgOperations.wallTime
|
|
1033
1221
|
}).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
1222
|
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
1035
1223
|
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
1036
|
-
(0, import_drizzle_orm.asc)(pgOperations.
|
|
1224
|
+
(0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
|
|
1037
1225
|
);
|
|
1038
1226
|
const parsedOps = ops.map((op) => ({
|
|
1039
1227
|
type: op.type,
|
|
1040
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1228
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1229
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1041
1230
|
}));
|
|
1042
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1231
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1043
1232
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
1044
1233
|
if (recordData) {
|
|
1045
1234
|
const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
|
|
@@ -1056,7 +1245,7 @@ var PostgresServerStore = class {
|
|
|
1056
1245
|
);
|
|
1057
1246
|
} else {
|
|
1058
1247
|
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}`
|
|
1248
|
+
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
1249
|
);
|
|
1061
1250
|
}
|
|
1062
1251
|
}
|
|
@@ -1075,16 +1264,16 @@ var PostgresServerStore = class {
|
|
|
1075
1264
|
updatedAt,
|
|
1076
1265
|
0
|
|
1077
1266
|
];
|
|
1078
|
-
const columnsSql = import_drizzle_orm.sql.raw(allColumns.join(", "));
|
|
1267
|
+
const columnsSql = import_drizzle_orm.sql.raw(allColumns.map((c) => (0, import_core3.quoteIdent)(c)).join(", "));
|
|
1079
1268
|
const valuesSql = import_drizzle_orm.sql.join(
|
|
1080
1269
|
values.map((v) => import_drizzle_orm.sql`${v}`),
|
|
1081
1270
|
import_drizzle_orm.sql.raw(", ")
|
|
1082
1271
|
);
|
|
1083
1272
|
const updateSet = import_drizzle_orm.sql.raw(
|
|
1084
|
-
allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
|
|
1273
|
+
allColumns.slice(1).map((c) => `${(0, import_core3.quoteIdent)(c)} = excluded.${(0, import_core3.quoteIdent)(c)}`).join(", ")
|
|
1085
1274
|
);
|
|
1086
1275
|
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}`
|
|
1276
|
+
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
1277
|
);
|
|
1089
1278
|
}
|
|
1090
1279
|
/**
|
|
@@ -1106,11 +1295,12 @@ var PostgresServerStore = class {
|
|
|
1106
1295
|
recordId: pgOperations.recordId,
|
|
1107
1296
|
type: pgOperations.type,
|
|
1108
1297
|
data: pgOperations.data,
|
|
1298
|
+
atomicOps: pgOperations.atomicOps,
|
|
1109
1299
|
wallTime: pgOperations.wallTime
|
|
1110
1300
|
}).from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collectionName)).orderBy(
|
|
1111
1301
|
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
1112
1302
|
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
1113
|
-
(0, import_drizzle_orm.asc)(pgOperations.
|
|
1303
|
+
(0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
|
|
1114
1304
|
);
|
|
1115
1305
|
if (allOps.length === 0) return;
|
|
1116
1306
|
const grouped = /* @__PURE__ */ new Map();
|
|
@@ -1126,9 +1316,10 @@ var PostgresServerStore = class {
|
|
|
1126
1316
|
for (const [recordId, recordOps] of grouped) {
|
|
1127
1317
|
const parsedOps = recordOps.map((op) => ({
|
|
1128
1318
|
type: op.type,
|
|
1129
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1319
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1320
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1130
1321
|
}));
|
|
1131
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1322
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1132
1323
|
if (recordData) {
|
|
1133
1324
|
const createdAt = recordOps[0].wallTime;
|
|
1134
1325
|
const updatedAt = recordOps[recordOps.length - 1].wallTime;
|
|
@@ -1144,7 +1335,7 @@ var PostgresServerStore = class {
|
|
|
1144
1335
|
);
|
|
1145
1336
|
} else {
|
|
1146
1337
|
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()}`
|
|
1338
|
+
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
1339
|
);
|
|
1149
1340
|
}
|
|
1150
1341
|
}
|
|
@@ -1157,10 +1348,12 @@ var PostgresServerStore = class {
|
|
|
1157
1348
|
options?.where ?? {},
|
|
1158
1349
|
options?.includeDeleted ?? false
|
|
1159
1350
|
);
|
|
1160
|
-
const parts = [
|
|
1351
|
+
const parts = [
|
|
1352
|
+
import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`
|
|
1353
|
+
];
|
|
1161
1354
|
if (options?.orderBy) {
|
|
1162
1355
|
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
1163
|
-
parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
1356
|
+
parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${(0, import_core3.quoteIdent)(options.orderBy)} ${dir}`));
|
|
1164
1357
|
}
|
|
1165
1358
|
if (options?.limit !== void 0) {
|
|
1166
1359
|
parts.push(import_drizzle_orm.sql` LIMIT ${options.limit}`);
|
|
@@ -1176,7 +1369,7 @@ var PostgresServerStore = class {
|
|
|
1176
1369
|
conditions.push(import_drizzle_orm.sql.raw("_deleted = 0"));
|
|
1177
1370
|
}
|
|
1178
1371
|
for (const [key, value] of Object.entries(where)) {
|
|
1179
|
-
conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw(key)} = ${value}`);
|
|
1372
|
+
conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(key))} = ${value}`);
|
|
1180
1373
|
}
|
|
1181
1374
|
if (conditions.length === 0) {
|
|
1182
1375
|
return import_drizzle_orm.sql.raw("1 = 1");
|
|
@@ -1249,44 +1442,92 @@ var PostgresServerStore = class {
|
|
|
1249
1442
|
}
|
|
1250
1443
|
}
|
|
1251
1444
|
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
|
-
|
|
1445
|
+
await this.db.transaction(async (tx) => {
|
|
1446
|
+
await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended('kora:ensure-tables', 0))`);
|
|
1447
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1448
|
+
CREATE TABLE IF NOT EXISTS operations (
|
|
1449
|
+
id TEXT PRIMARY KEY,
|
|
1450
|
+
node_id TEXT NOT NULL,
|
|
1451
|
+
type TEXT NOT NULL,
|
|
1452
|
+
collection TEXT NOT NULL,
|
|
1453
|
+
record_id TEXT NOT NULL,
|
|
1454
|
+
data TEXT,
|
|
1455
|
+
previous_data TEXT,
|
|
1456
|
+
atomic_ops TEXT,
|
|
1457
|
+
wall_time BIGINT NOT NULL,
|
|
1458
|
+
logical INTEGER NOT NULL,
|
|
1459
|
+
timestamp_node_id TEXT NOT NULL,
|
|
1460
|
+
sequence_number INTEGER NOT NULL,
|
|
1461
|
+
causal_deps TEXT NOT NULL DEFAULT '[]',
|
|
1462
|
+
schema_version INTEGER NOT NULL,
|
|
1463
|
+
received_at BIGINT NOT NULL
|
|
1464
|
+
)
|
|
1465
|
+
`);
|
|
1466
|
+
await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS atomic_ops TEXT`);
|
|
1467
|
+
await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS delivery_seq BIGINT`);
|
|
1468
|
+
await tx.execute(
|
|
1469
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
|
|
1470
|
+
);
|
|
1471
|
+
await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
|
|
1472
|
+
await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
|
|
1473
|
+
await tx.execute(
|
|
1474
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)`
|
|
1475
|
+
);
|
|
1476
|
+
await tx.execute(
|
|
1477
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
|
|
1478
|
+
);
|
|
1479
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1480
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
1481
|
+
node_id TEXT PRIMARY KEY,
|
|
1482
|
+
max_sequence_number INTEGER NOT NULL,
|
|
1483
|
+
last_seen_at BIGINT NOT NULL
|
|
1484
|
+
)
|
|
1485
|
+
`);
|
|
1486
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1487
|
+
CREATE TABLE IF NOT EXISTS delivery_counter (
|
|
1488
|
+
id INTEGER PRIMARY KEY,
|
|
1489
|
+
value BIGINT NOT NULL
|
|
1490
|
+
)
|
|
1491
|
+
`);
|
|
1492
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1493
|
+
WITH ordered AS (
|
|
1494
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
1495
|
+
ORDER BY received_at ASC, sequence_number ASC, id ASC
|
|
1496
|
+
) + COALESCE((SELECT MAX(delivery_seq) FROM operations), 0) AS rn
|
|
1497
|
+
FROM operations WHERE delivery_seq IS NULL
|
|
1498
|
+
)
|
|
1499
|
+
UPDATE operations o SET delivery_seq = ordered.rn
|
|
1500
|
+
FROM ordered WHERE o.id = ordered.id
|
|
1501
|
+
`);
|
|
1502
|
+
await tx.execute(import_drizzle_orm.sql`
|
|
1503
|
+
INSERT INTO delivery_counter (id, value)
|
|
1504
|
+
VALUES (1, COALESCE((SELECT MAX(delivery_seq) FROM operations), 0))
|
|
1505
|
+
ON CONFLICT (id) DO UPDATE
|
|
1506
|
+
SET value = GREATEST(delivery_counter.value, EXCLUDED.value)
|
|
1507
|
+
`);
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Reserve the next delivery sequence inside an open append transaction. The
|
|
1512
|
+
* `UPDATE ... RETURNING` locks the counter row until this transaction commits,
|
|
1513
|
+
* serializing assignment into commit order (see the counter table comment).
|
|
1514
|
+
*/
|
|
1515
|
+
async nextDeliverySeq(tx) {
|
|
1516
|
+
const rows = await tx.execute(
|
|
1517
|
+
import_drizzle_orm.sql`UPDATE delivery_counter SET value = value + 1 WHERE id = 1 RETURNING value`
|
|
1277
1518
|
);
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1519
|
+
const value = rows[0]?.value;
|
|
1520
|
+
if (value === void 0 || value === null) {
|
|
1521
|
+
throw new Error(
|
|
1522
|
+
"delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
return Number(value);
|
|
1285
1526
|
}
|
|
1286
1527
|
// ---------------------------------------------------------------------------
|
|
1287
1528
|
// Operation serialization
|
|
1288
1529
|
// ---------------------------------------------------------------------------
|
|
1289
|
-
serializeOperation(op, receivedAt) {
|
|
1530
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1290
1531
|
return {
|
|
1291
1532
|
id: op.id,
|
|
1292
1533
|
nodeId: op.nodeId,
|
|
@@ -1295,16 +1536,19 @@ var PostgresServerStore = class {
|
|
|
1295
1536
|
recordId: op.recordId,
|
|
1296
1537
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1297
1538
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
1539
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1298
1540
|
wallTime: op.timestamp.wallTime,
|
|
1299
1541
|
logical: op.timestamp.logical,
|
|
1300
1542
|
timestampNodeId: op.timestamp.nodeId,
|
|
1301
1543
|
sequenceNumber: op.sequenceNumber,
|
|
1302
1544
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1303
1545
|
schemaVersion: op.schemaVersion,
|
|
1304
|
-
receivedAt
|
|
1546
|
+
receivedAt,
|
|
1547
|
+
deliverySeq
|
|
1305
1548
|
};
|
|
1306
1549
|
}
|
|
1307
1550
|
deserializeOperation(row) {
|
|
1551
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1308
1552
|
return {
|
|
1309
1553
|
id: row.id,
|
|
1310
1554
|
nodeId: row.nodeId,
|
|
@@ -1320,7 +1564,8 @@ var PostgresServerStore = class {
|
|
|
1320
1564
|
},
|
|
1321
1565
|
sequenceNumber: row.sequenceNumber,
|
|
1322
1566
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1323
|
-
schemaVersion: row.schemaVersion
|
|
1567
|
+
schemaVersion: row.schemaVersion,
|
|
1568
|
+
...atomicOps ? { atomicOps } : {}
|
|
1324
1569
|
};
|
|
1325
1570
|
}
|
|
1326
1571
|
// ---------------------------------------------------------------------------
|
|
@@ -1390,6 +1635,8 @@ var operations = (0, import_sqlite_core.sqliteTable)(
|
|
|
1390
1635
|
// JSON-serialized, null for deletes
|
|
1391
1636
|
previousData: (0, import_sqlite_core.text)("previous_data"),
|
|
1392
1637
|
// JSON-serialized, null for insert/delete
|
|
1638
|
+
atomicOps: (0, import_sqlite_core.text)("atomic_ops"),
|
|
1639
|
+
// JSON-serialized Record<field, AtomicOp>, null when none
|
|
1393
1640
|
wallTime: (0, import_sqlite_core.integer)("wall_time").notNull(),
|
|
1394
1641
|
logical: (0, import_sqlite_core.integer)("logical").notNull(),
|
|
1395
1642
|
timestampNodeId: (0, import_sqlite_core.text)("timestamp_node_id").notNull(),
|
|
@@ -1397,12 +1644,17 @@ var operations = (0, import_sqlite_core.sqliteTable)(
|
|
|
1397
1644
|
causalDeps: (0, import_sqlite_core.text)("causal_deps").notNull().default("[]"),
|
|
1398
1645
|
// JSON array of op IDs
|
|
1399
1646
|
schemaVersion: (0, import_sqlite_core.integer)("schema_version").notNull(),
|
|
1400
|
-
receivedAt: (0, import_sqlite_core.integer)("received_at").notNull()
|
|
1647
|
+
receivedAt: (0, import_sqlite_core.integer)("received_at").notNull(),
|
|
1648
|
+
// Server-assigned monotonic delivery sequence, ordered by commit. Drives the
|
|
1649
|
+
// gap-free server->client delivery watermark. Nullable only for rows written
|
|
1650
|
+
// before the column existed; those are backfilled on startup.
|
|
1651
|
+
deliverySeq: (0, import_sqlite_core.integer)("delivery_seq")
|
|
1401
1652
|
},
|
|
1402
1653
|
(table) => ({
|
|
1403
1654
|
nodeSeqIdx: (0, import_sqlite_core.index)("idx_node_seq").on(table.nodeId, table.sequenceNumber),
|
|
1404
1655
|
collectionIdx: (0, import_sqlite_core.index)("idx_collection").on(table.collection),
|
|
1405
|
-
receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt)
|
|
1656
|
+
receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt),
|
|
1657
|
+
deliveryIdx: (0, import_sqlite_core.index)("idx_delivery_seq").on(table.deliverySeq)
|
|
1406
1658
|
})
|
|
1407
1659
|
);
|
|
1408
1660
|
var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
|
|
@@ -1410,6 +1662,10 @@ var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
|
|
|
1410
1662
|
maxSequenceNumber: (0, import_sqlite_core.integer)("max_sequence_number").notNull(),
|
|
1411
1663
|
lastSeenAt: (0, import_sqlite_core.integer)("last_seen_at").notNull()
|
|
1412
1664
|
});
|
|
1665
|
+
var deliveryCounter = (0, import_sqlite_core.sqliteTable)("delivery_counter", {
|
|
1666
|
+
id: (0, import_sqlite_core.integer)("id").primaryKey(),
|
|
1667
|
+
value: (0, import_sqlite_core.integer)("value").notNull()
|
|
1668
|
+
});
|
|
1413
1669
|
|
|
1414
1670
|
// src/store/sqlite-server-store.ts
|
|
1415
1671
|
var esmRequire = (0, import_node_module.createRequire)(importMetaUrl);
|
|
@@ -1463,12 +1719,15 @@ var SqliteServerStore = class {
|
|
|
1463
1719
|
async applyRemoteOperation(op) {
|
|
1464
1720
|
this.assertOpen();
|
|
1465
1721
|
const now = Date.now();
|
|
1466
|
-
const row = this.serializeOperation(op, now);
|
|
1467
1722
|
const result = this.db.transaction((tx) => {
|
|
1468
|
-
const
|
|
1469
|
-
|
|
1723
|
+
const existing = tx.all(
|
|
1724
|
+
import_drizzle_orm2.sql`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
|
|
1725
|
+
);
|
|
1726
|
+
if (existing.length > 0) {
|
|
1470
1727
|
return "duplicate";
|
|
1471
1728
|
}
|
|
1729
|
+
const row = this.serializeOperation(op, now, this.nextDeliverySeq(tx));
|
|
1730
|
+
tx.insert(operations).values(row).run();
|
|
1472
1731
|
tx.insert(syncState).values({
|
|
1473
1732
|
nodeId: op.nodeId,
|
|
1474
1733
|
maxSequenceNumber: op.sequenceNumber,
|
|
@@ -1497,6 +1756,21 @@ var SqliteServerStore = class {
|
|
|
1497
1756
|
const result = this.db.select({ value: (0, import_drizzle_orm2.count)() }).from(operations).all();
|
|
1498
1757
|
return result[0]?.value ?? 0;
|
|
1499
1758
|
}
|
|
1759
|
+
async getMaxDeliverySequence() {
|
|
1760
|
+
this.assertOpen();
|
|
1761
|
+
const rows = this.db.all(
|
|
1762
|
+
import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
1763
|
+
);
|
|
1764
|
+
return rows[0]?.m ?? 0;
|
|
1765
|
+
}
|
|
1766
|
+
async getOperationsAfterDelivery(afterDeliverySequence, limit) {
|
|
1767
|
+
this.assertOpen();
|
|
1768
|
+
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();
|
|
1769
|
+
return rows.map((row) => ({
|
|
1770
|
+
operation: this.deserializeOperation(row),
|
|
1771
|
+
deliverySequence: row.deliverySeq ?? 0
|
|
1772
|
+
}));
|
|
1773
|
+
}
|
|
1500
1774
|
async materializeCollection(collection) {
|
|
1501
1775
|
this.assertOpen();
|
|
1502
1776
|
if (this.schema?.collections[collection]) {
|
|
@@ -1528,7 +1802,7 @@ var SqliteServerStore = class {
|
|
|
1528
1802
|
this.assertCollection(collection);
|
|
1529
1803
|
const schema = this.schema;
|
|
1530
1804
|
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`;
|
|
1805
|
+
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
1806
|
const rows = this.db.all(query);
|
|
1533
1807
|
if (rows.length === 0) return null;
|
|
1534
1808
|
return this.deserializeRow(rows[0], collectionDef);
|
|
@@ -1544,7 +1818,7 @@ var SqliteServerStore = class {
|
|
|
1544
1818
|
}
|
|
1545
1819
|
}
|
|
1546
1820
|
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}`;
|
|
1821
|
+
const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`;
|
|
1548
1822
|
const rows = this.db.all(query);
|
|
1549
1823
|
return rows[0]?.cnt ?? 0;
|
|
1550
1824
|
}
|
|
@@ -1554,7 +1828,7 @@ var SqliteServerStore = class {
|
|
|
1554
1828
|
async exportBackup() {
|
|
1555
1829
|
this.assertOpen();
|
|
1556
1830
|
const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
1557
|
-
const rows = this.db.select().from(operations).all();
|
|
1831
|
+
const rows = this.db.select().from(operations).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).all();
|
|
1558
1832
|
const deserialized = rows.map((row) => this.deserializeOperation(row));
|
|
1559
1833
|
const vv = this.getVersionVector();
|
|
1560
1834
|
return buildServerBackup2(this.nodeId, deserialized, vv);
|
|
@@ -1574,13 +1848,17 @@ var SqliteServerStore = class {
|
|
|
1574
1848
|
this.db.transaction((tx) => {
|
|
1575
1849
|
tx.run(import_drizzle_orm2.sql.raw("DELETE FROM operations"));
|
|
1576
1850
|
tx.run(import_drizzle_orm2.sql.raw("DELETE FROM sync_state"));
|
|
1851
|
+
tx.update(deliveryCounter).set({ value: 0 }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).run();
|
|
1577
1852
|
for (const [nid, seq] of versionVector) {
|
|
1578
1853
|
tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
|
|
1579
1854
|
}
|
|
1855
|
+
let deliverySeq = 0;
|
|
1580
1856
|
for (const op of ops) {
|
|
1581
|
-
|
|
1857
|
+
deliverySeq += 1;
|
|
1858
|
+
const row = this.serializeOperation(op, Date.now(), deliverySeq);
|
|
1582
1859
|
tx.insert(operations).values(row).run();
|
|
1583
1860
|
}
|
|
1861
|
+
tx.update(deliveryCounter).set({ value: deliverySeq }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).run();
|
|
1584
1862
|
});
|
|
1585
1863
|
return { operationsRestored: ops.length, success: true };
|
|
1586
1864
|
}
|
|
@@ -1598,13 +1876,15 @@ var SqliteServerStore = class {
|
|
|
1598
1876
|
const ops = txOrDb.select({
|
|
1599
1877
|
type: operations.type,
|
|
1600
1878
|
data: operations.data,
|
|
1879
|
+
atomicOps: operations.atomicOps,
|
|
1601
1880
|
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.
|
|
1881
|
+
}).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
1882
|
const parsedOps = ops.map((op) => ({
|
|
1604
1883
|
type: op.type,
|
|
1605
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1884
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1885
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1606
1886
|
}));
|
|
1607
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1887
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1608
1888
|
const fieldNames = Object.keys(collectionDef.fields);
|
|
1609
1889
|
if (recordData) {
|
|
1610
1890
|
const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
|
|
@@ -1621,7 +1901,7 @@ var SqliteServerStore = class {
|
|
|
1621
1901
|
);
|
|
1622
1902
|
} else {
|
|
1623
1903
|
txOrDb.run(
|
|
1624
|
-
import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1904
|
+
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
1905
|
);
|
|
1626
1906
|
}
|
|
1627
1907
|
}
|
|
@@ -1642,16 +1922,16 @@ var SqliteServerStore = class {
|
|
|
1642
1922
|
0
|
|
1643
1923
|
// _deleted = false
|
|
1644
1924
|
];
|
|
1645
|
-
const columnsSql = import_drizzle_orm2.sql.raw(allColumns.join(", "));
|
|
1925
|
+
const columnsSql = import_drizzle_orm2.sql.raw(allColumns.map((c) => (0, import_core4.quoteIdent)(c)).join(", "));
|
|
1646
1926
|
const valuesSql = import_drizzle_orm2.sql.join(
|
|
1647
1927
|
values.map((v) => import_drizzle_orm2.sql`${v}`),
|
|
1648
1928
|
import_drizzle_orm2.sql.raw(", ")
|
|
1649
1929
|
);
|
|
1650
1930
|
const updateSet = import_drizzle_orm2.sql.raw(
|
|
1651
|
-
allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
|
|
1931
|
+
allColumns.slice(1).map((c) => `${(0, import_core4.quoteIdent)(c)} = excluded.${(0, import_core4.quoteIdent)(c)}`).join(", ")
|
|
1652
1932
|
);
|
|
1653
1933
|
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}`
|
|
1934
|
+
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
1935
|
);
|
|
1656
1936
|
}
|
|
1657
1937
|
/**
|
|
@@ -1674,8 +1954,9 @@ var SqliteServerStore = class {
|
|
|
1674
1954
|
recordId: operations.recordId,
|
|
1675
1955
|
type: operations.type,
|
|
1676
1956
|
data: operations.data,
|
|
1957
|
+
atomicOps: operations.atomicOps,
|
|
1677
1958
|
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.
|
|
1959
|
+
}).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
1960
|
if (allOps.length === 0) return;
|
|
1680
1961
|
const grouped = /* @__PURE__ */ new Map();
|
|
1681
1962
|
for (const op of allOps) {
|
|
@@ -1691,9 +1972,10 @@ var SqliteServerStore = class {
|
|
|
1691
1972
|
for (const [recordId, recordOps] of grouped) {
|
|
1692
1973
|
const parsedOps = recordOps.map((op) => ({
|
|
1693
1974
|
type: op.type,
|
|
1694
|
-
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1975
|
+
data: op.data !== null ? JSON.parse(op.data) : null,
|
|
1976
|
+
atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
|
|
1695
1977
|
}));
|
|
1696
|
-
const recordData = replayOperationsForRecord(parsedOps);
|
|
1978
|
+
const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
|
|
1697
1979
|
if (recordData) {
|
|
1698
1980
|
const createdAt = recordOps[0].wallTime;
|
|
1699
1981
|
const updatedAt = recordOps[recordOps.length - 1].wallTime;
|
|
@@ -1709,7 +1991,7 @@ var SqliteServerStore = class {
|
|
|
1709
1991
|
);
|
|
1710
1992
|
} else {
|
|
1711
1993
|
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()}`
|
|
1994
|
+
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
1995
|
);
|
|
1714
1996
|
}
|
|
1715
1997
|
}
|
|
@@ -1723,10 +2005,12 @@ var SqliteServerStore = class {
|
|
|
1723
2005
|
options?.where ?? {},
|
|
1724
2006
|
options?.includeDeleted ?? false
|
|
1725
2007
|
);
|
|
1726
|
-
const parts = [
|
|
2008
|
+
const parts = [
|
|
2009
|
+
import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`
|
|
2010
|
+
];
|
|
1727
2011
|
if (options?.orderBy) {
|
|
1728
2012
|
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
1729
|
-
parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
2013
|
+
parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${(0, import_core4.quoteIdent)(options.orderBy)} ${dir}`));
|
|
1730
2014
|
}
|
|
1731
2015
|
if (options?.limit !== void 0) {
|
|
1732
2016
|
parts.push(import_drizzle_orm2.sql` LIMIT ${options.limit}`);
|
|
@@ -1742,7 +2026,7 @@ var SqliteServerStore = class {
|
|
|
1742
2026
|
conditions.push(import_drizzle_orm2.sql.raw("_deleted = 0"));
|
|
1743
2027
|
}
|
|
1744
2028
|
for (const [key, value] of Object.entries(where)) {
|
|
1745
|
-
conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw(key)} = ${value}`);
|
|
2029
|
+
conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(key))} = ${value}`);
|
|
1746
2030
|
}
|
|
1747
2031
|
if (conditions.length === 0) {
|
|
1748
2032
|
return import_drizzle_orm2.sql.raw("1 = 1");
|
|
@@ -1813,6 +2097,7 @@ var SqliteServerStore = class {
|
|
|
1813
2097
|
record_id TEXT NOT NULL,
|
|
1814
2098
|
data TEXT,
|
|
1815
2099
|
previous_data TEXT,
|
|
2100
|
+
atomic_ops TEXT,
|
|
1816
2101
|
wall_time INTEGER NOT NULL,
|
|
1817
2102
|
logical INTEGER NOT NULL,
|
|
1818
2103
|
timestamp_node_id TEXT NOT NULL,
|
|
@@ -1822,9 +2107,35 @@ var SqliteServerStore = class {
|
|
|
1822
2107
|
received_at INTEGER NOT NULL
|
|
1823
2108
|
)
|
|
1824
2109
|
`);
|
|
2110
|
+
try {
|
|
2111
|
+
this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
|
|
2112
|
+
} catch (e) {
|
|
2113
|
+
const msg = e instanceof Error ? e.message : "";
|
|
2114
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
2115
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
2116
|
+
throw e;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
try {
|
|
2120
|
+
this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
|
|
2121
|
+
} catch (e) {
|
|
2122
|
+
const msg = e instanceof Error ? e.message : "";
|
|
2123
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
2124
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
2125
|
+
throw e;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
1825
2128
|
this.db.run(import_drizzle_orm2.sql`
|
|
1826
2129
|
CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
|
|
1827
2130
|
`);
|
|
2131
|
+
this.db.run(import_drizzle_orm2.sql`
|
|
2132
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
|
|
2133
|
+
`);
|
|
2134
|
+
this.db.run(import_drizzle_orm2.sql`
|
|
2135
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_seq_unique
|
|
2136
|
+
ON operations (delivery_seq)
|
|
2137
|
+
WHERE delivery_seq IS NOT NULL
|
|
2138
|
+
`);
|
|
1828
2139
|
this.db.run(import_drizzle_orm2.sql`
|
|
1829
2140
|
CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
|
|
1830
2141
|
`);
|
|
@@ -1841,11 +2152,60 @@ var SqliteServerStore = class {
|
|
|
1841
2152
|
last_seen_at INTEGER NOT NULL
|
|
1842
2153
|
)
|
|
1843
2154
|
`);
|
|
2155
|
+
this.db.run(import_drizzle_orm2.sql`
|
|
2156
|
+
CREATE TABLE IF NOT EXISTS delivery_counter (
|
|
2157
|
+
id INTEGER PRIMARY KEY,
|
|
2158
|
+
value INTEGER NOT NULL
|
|
2159
|
+
)
|
|
2160
|
+
`);
|
|
2161
|
+
this.backfillDeliverySequence();
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Assign delivery sequences to any operations written before the column existed,
|
|
2165
|
+
* then seed the durable counter from the current max. Ordered by
|
|
2166
|
+
* (received_at, sequence_number, id) so the backfill is deterministic and stable
|
|
2167
|
+
* across restarts. A one-time migration: after it runs, all rows have a value.
|
|
2168
|
+
*/
|
|
2169
|
+
backfillDeliverySequence() {
|
|
2170
|
+
this.db.transaction((tx) => {
|
|
2171
|
+
const maxRow = tx.all(
|
|
2172
|
+
import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
|
|
2173
|
+
);
|
|
2174
|
+
let next = maxRow[0]?.m ?? 0;
|
|
2175
|
+
const nullRows = tx.all(
|
|
2176
|
+
import_drizzle_orm2.sql`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
|
|
2177
|
+
);
|
|
2178
|
+
for (const row of nullRows) {
|
|
2179
|
+
next += 1;
|
|
2180
|
+
tx.run(import_drizzle_orm2.sql`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
|
|
2181
|
+
}
|
|
2182
|
+
tx.insert(deliveryCounter).values({ id: 1, value: next }).onConflictDoUpdate({
|
|
2183
|
+
target: deliveryCounter.id,
|
|
2184
|
+
set: {
|
|
2185
|
+
value: import_drizzle_orm2.sql`MAX(${deliveryCounter.value}, ${next})`
|
|
2186
|
+
}
|
|
2187
|
+
}).run();
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Reserve the next delivery sequence inside the append transaction. SQLite locks
|
|
2192
|
+
* the database for the write transaction, and this counter row keeps sequence
|
|
2193
|
+
* allocation shared by every SqliteServerStore instance using the same file.
|
|
2194
|
+
*/
|
|
2195
|
+
nextDeliverySeq(tx) {
|
|
2196
|
+
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();
|
|
2197
|
+
const value = rows[0]?.value;
|
|
2198
|
+
if (value === void 0 || value === null) {
|
|
2199
|
+
throw new Error(
|
|
2200
|
+
"delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
|
|
2201
|
+
);
|
|
2202
|
+
}
|
|
2203
|
+
return value;
|
|
1844
2204
|
}
|
|
1845
2205
|
// ---------------------------------------------------------------------------
|
|
1846
2206
|
// Operation serialization
|
|
1847
2207
|
// ---------------------------------------------------------------------------
|
|
1848
|
-
serializeOperation(op, receivedAt) {
|
|
2208
|
+
serializeOperation(op, receivedAt, deliverySeq) {
|
|
1849
2209
|
return {
|
|
1850
2210
|
id: op.id,
|
|
1851
2211
|
nodeId: op.nodeId,
|
|
@@ -1854,16 +2214,19 @@ var SqliteServerStore = class {
|
|
|
1854
2214
|
recordId: op.recordId,
|
|
1855
2215
|
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
1856
2216
|
previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
2217
|
+
atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
|
|
1857
2218
|
wallTime: op.timestamp.wallTime,
|
|
1858
2219
|
logical: op.timestamp.logical,
|
|
1859
2220
|
timestampNodeId: op.timestamp.nodeId,
|
|
1860
2221
|
sequenceNumber: op.sequenceNumber,
|
|
1861
2222
|
causalDeps: JSON.stringify(op.causalDeps),
|
|
1862
2223
|
schemaVersion: op.schemaVersion,
|
|
1863
|
-
receivedAt
|
|
2224
|
+
receivedAt,
|
|
2225
|
+
deliverySeq
|
|
1864
2226
|
};
|
|
1865
2227
|
}
|
|
1866
2228
|
deserializeOperation(row) {
|
|
2229
|
+
const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
|
|
1867
2230
|
return {
|
|
1868
2231
|
id: row.id,
|
|
1869
2232
|
nodeId: row.nodeId,
|
|
@@ -1879,7 +2242,8 @@ var SqliteServerStore = class {
|
|
|
1879
2242
|
},
|
|
1880
2243
|
sequenceNumber: row.sequenceNumber,
|
|
1881
2244
|
causalDeps: JSON.parse(row.causalDeps),
|
|
1882
|
-
schemaVersion: row.schemaVersion
|
|
2245
|
+
schemaVersion: row.schemaVersion,
|
|
2246
|
+
...atomicOps ? { atomicOps } : {}
|
|
1883
2247
|
};
|
|
1884
2248
|
}
|
|
1885
2249
|
// ---------------------------------------------------------------------------
|
|
@@ -2065,6 +2429,7 @@ var WsServerTransport = class {
|
|
|
2065
2429
|
// src/session/client-session.ts
|
|
2066
2430
|
init_cjs_shims();
|
|
2067
2431
|
var import_core10 = require("@korajs/core");
|
|
2432
|
+
var import_core11 = require("@korajs/core");
|
|
2068
2433
|
var import_internal = require("@korajs/core/internal");
|
|
2069
2434
|
var import_sync2 = require("@korajs/sync");
|
|
2070
2435
|
var import_sync3 = require("@korajs/sync");
|
|
@@ -2178,6 +2543,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
|
|
|
2178
2543
|
);
|
|
2179
2544
|
}
|
|
2180
2545
|
function nextServerSequenceNumber(store) {
|
|
2546
|
+
if (store.reserveSequenceNumber) {
|
|
2547
|
+
return store.reserveSequenceNumber();
|
|
2548
|
+
}
|
|
2181
2549
|
const nodeId = store.getNodeId();
|
|
2182
2550
|
const current = store.getVersionVector().get(nodeId) ?? 0;
|
|
2183
2551
|
return current + 1;
|
|
@@ -2187,14 +2555,28 @@ function nextServerSequenceNumber(store) {
|
|
|
2187
2555
|
async function applyServerOperation(store, op, relationLookup) {
|
|
2188
2556
|
const schema = store.getSchema();
|
|
2189
2557
|
const lookup = relationLookup ?? (schema ? (0, import_merge2.buildMergeRelationLookup)(schema) : /* @__PURE__ */ new Map());
|
|
2558
|
+
const shapeCheck = validateOperationShape(op, schema);
|
|
2559
|
+
if (!shapeCheck.valid) {
|
|
2560
|
+
return {
|
|
2561
|
+
result: "skipped",
|
|
2562
|
+
appliedOperations: [],
|
|
2563
|
+
rejection: {
|
|
2564
|
+
code: shapeCheck.code ?? "SCHEMA_VALIDATION_ERROR",
|
|
2565
|
+
message: shapeCheck.message ?? `Operation "${op.id}" does not match the server schema`,
|
|
2566
|
+
retriable: false
|
|
2567
|
+
}
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2190
2570
|
const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
|
|
2191
2571
|
if (!constraintCheck.valid) {
|
|
2572
|
+
const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
|
|
2192
2573
|
return {
|
|
2193
2574
|
result: "skipped",
|
|
2194
2575
|
appliedOperations: [],
|
|
2195
2576
|
rejection: {
|
|
2196
|
-
code
|
|
2197
|
-
message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint
|
|
2577
|
+
code,
|
|
2578
|
+
message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`,
|
|
2579
|
+
retriable: isRetriableRejection(code)
|
|
2198
2580
|
}
|
|
2199
2581
|
};
|
|
2200
2582
|
}
|
|
@@ -2207,7 +2589,8 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2207
2589
|
appliedOperations: [],
|
|
2208
2590
|
rejection: {
|
|
2209
2591
|
code: "REFERENTIAL_INTEGRITY",
|
|
2210
|
-
message: `Operation "${op.id}" violates referential integrity on "${op.collection}"
|
|
2592
|
+
message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`,
|
|
2593
|
+
retriable: isRetriableRejection("REFERENTIAL_INTEGRITY")
|
|
2211
2594
|
}
|
|
2212
2595
|
};
|
|
2213
2596
|
}
|
|
@@ -2216,16 +2599,14 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2216
2599
|
return { result: primaryResult, appliedOperations: [] };
|
|
2217
2600
|
}
|
|
2218
2601
|
const appliedOperations = [op];
|
|
2219
|
-
let serverSeq = nextServerSequenceNumber(store);
|
|
2220
2602
|
for (const effect of referential.sideEffectOps) {
|
|
2221
2603
|
const sideOp = await createServerSideEffectOperation(
|
|
2222
2604
|
store,
|
|
2223
2605
|
op,
|
|
2224
2606
|
effect,
|
|
2225
2607
|
op.schemaVersion,
|
|
2226
|
-
|
|
2608
|
+
nextServerSequenceNumber(store)
|
|
2227
2609
|
);
|
|
2228
|
-
serverSeq += 1;
|
|
2229
2610
|
const sideResult = await store.applyRemoteOperation(sideOp);
|
|
2230
2611
|
if (sideResult === "applied") {
|
|
2231
2612
|
appliedOperations.push(sideOp);
|
|
@@ -2239,6 +2620,67 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2239
2620
|
appliedOperations: result === "applied" ? [op] : []
|
|
2240
2621
|
};
|
|
2241
2622
|
}
|
|
2623
|
+
function validateOperationShape(op, schema) {
|
|
2624
|
+
if (!schema) {
|
|
2625
|
+
return { valid: true };
|
|
2626
|
+
}
|
|
2627
|
+
const collection = schema.collections[op.collection];
|
|
2628
|
+
if (!collection) {
|
|
2629
|
+
return {
|
|
2630
|
+
valid: false,
|
|
2631
|
+
code: "UNKNOWN_COLLECTION",
|
|
2632
|
+
message: `Operation "${op.id}" targets unknown collection "${op.collection}".`
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
const declaredFields = new Set(Object.keys(collection.fields));
|
|
2636
|
+
const systemPreviousFields = /* @__PURE__ */ new Set(["id", "_created_at", "_updated_at", "_deleted"]);
|
|
2637
|
+
const invalidDataField = firstInvalidField(op.data, declaredFields);
|
|
2638
|
+
if (invalidDataField) {
|
|
2639
|
+
return {
|
|
2640
|
+
valid: false,
|
|
2641
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2642
|
+
message: `Operation "${op.id}" contains undeclared field "${invalidDataField}" in data for collection "${op.collection}".`
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
const invalidPreviousField = firstInvalidField(
|
|
2646
|
+
op.previousData,
|
|
2647
|
+
declaredFields,
|
|
2648
|
+
systemPreviousFields
|
|
2649
|
+
);
|
|
2650
|
+
if (invalidPreviousField) {
|
|
2651
|
+
return {
|
|
2652
|
+
valid: false,
|
|
2653
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2654
|
+
message: `Operation "${op.id}" contains undeclared field "${invalidPreviousField}" in previousData for collection "${op.collection}".`
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2657
|
+
if (op.atomicOps) {
|
|
2658
|
+
for (const field of Object.keys(op.atomicOps)) {
|
|
2659
|
+
if (!declaredFields.has(field)) {
|
|
2660
|
+
return {
|
|
2661
|
+
valid: false,
|
|
2662
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2663
|
+
message: `Operation "${op.id}" contains undeclared atomic field "${field}" for collection "${op.collection}".`
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
return { valid: true };
|
|
2669
|
+
}
|
|
2670
|
+
function firstInvalidField(value, declaredFields, extraAllowedFields) {
|
|
2671
|
+
if (value === null || value === void 0) {
|
|
2672
|
+
return null;
|
|
2673
|
+
}
|
|
2674
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
2675
|
+
return null;
|
|
2676
|
+
}
|
|
2677
|
+
for (const field of Object.keys(value)) {
|
|
2678
|
+
if (!declaredFields.has(field) && !extraAllowedFields?.has(field)) {
|
|
2679
|
+
return field;
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
return null;
|
|
2683
|
+
}
|
|
2242
2684
|
|
|
2243
2685
|
// src/auth/no-auth.ts
|
|
2244
2686
|
init_cjs_shims();
|
|
@@ -2286,24 +2728,74 @@ function mergeScopeMaps(base, overlay, source) {
|
|
|
2286
2728
|
|
|
2287
2729
|
// src/scopes/server-scope-filter.ts
|
|
2288
2730
|
init_cjs_shims();
|
|
2289
|
-
|
|
2731
|
+
var DEFAULT_MAX_SCOPE_PREDICATE_VALUES = 100;
|
|
2732
|
+
function normalizeScopeMap(scopes, maxValues = DEFAULT_MAX_SCOPE_PREDICATE_VALUES) {
|
|
2733
|
+
const normalized = {};
|
|
2734
|
+
for (const collection of Object.keys(scopes).sort()) {
|
|
2735
|
+
const predicate = {};
|
|
2736
|
+
for (const field of Object.keys(scopes[collection] ?? {}).sort()) {
|
|
2737
|
+
const expected = scopes[collection]?.[field];
|
|
2738
|
+
if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
|
|
2739
|
+
const values = expected.$in;
|
|
2740
|
+
if (!Array.isArray(values))
|
|
2741
|
+
throw new Error(`Invalid $in predicate for ${collection}.${field}`);
|
|
2742
|
+
const unique = [...new Map(values.map((value) => [stableValueKey(value), value])).values()];
|
|
2743
|
+
if (unique.length > maxValues)
|
|
2744
|
+
throw new Error(
|
|
2745
|
+
`Scope predicate for ${collection}.${field} exceeds the ${maxValues}-value limit`
|
|
2746
|
+
);
|
|
2747
|
+
predicate[field] = {
|
|
2748
|
+
$in: unique.sort((a, b) => stableValueKey(a).localeCompare(stableValueKey(b)))
|
|
2749
|
+
};
|
|
2750
|
+
} else predicate[field] = expected;
|
|
2751
|
+
}
|
|
2752
|
+
normalized[collection] = predicate;
|
|
2753
|
+
}
|
|
2754
|
+
return normalized;
|
|
2755
|
+
}
|
|
2756
|
+
function stableValueKey(value) {
|
|
2757
|
+
if (Array.isArray(value)) return `[${value.map(stableValueKey).join(",")}]`;
|
|
2758
|
+
if (value && typeof value === "object")
|
|
2759
|
+
return `{${Object.keys(value).sort().map(
|
|
2760
|
+
(key) => `${JSON.stringify(key)}:${stableValueKey(value[key])}`
|
|
2761
|
+
).join(",")}}`;
|
|
2762
|
+
return JSON.stringify(value);
|
|
2763
|
+
}
|
|
2764
|
+
function operationMatchesScopes(op, scopes, fullRecord) {
|
|
2290
2765
|
if (!scopes) return true;
|
|
2291
2766
|
const collectionScope = scopes[op.collection];
|
|
2292
2767
|
if (!collectionScope) return false;
|
|
2293
|
-
|
|
2768
|
+
if (Object.keys(collectionScope).length === 0) return true;
|
|
2769
|
+
const snapshot = buildSnapshot(op, fullRecord);
|
|
2294
2770
|
if (!snapshot) return false;
|
|
2295
2771
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
2296
|
-
if (snapshot[field]
|
|
2772
|
+
if (!matchesPredicate(snapshot[field], expected)) {
|
|
2297
2773
|
return false;
|
|
2298
2774
|
}
|
|
2299
2775
|
}
|
|
2300
2776
|
return true;
|
|
2301
2777
|
}
|
|
2302
|
-
function
|
|
2778
|
+
function matchesPredicate(actual, expected) {
|
|
2779
|
+
if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
|
|
2780
|
+
const values = expected.$in;
|
|
2781
|
+
return Array.isArray(values) && values.some((value) => Object.is(actual, value));
|
|
2782
|
+
}
|
|
2783
|
+
return Object.is(actual, expected);
|
|
2784
|
+
}
|
|
2785
|
+
function missingScopeFields(op, scopes) {
|
|
2786
|
+
if (!scopes) return [];
|
|
2787
|
+
const collectionScope = scopes[op.collection];
|
|
2788
|
+
if (!collectionScope) return [];
|
|
2789
|
+
if (Object.keys(collectionScope).length === 0) return [];
|
|
2790
|
+
const snapshot = buildSnapshot(op);
|
|
2791
|
+
return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
|
|
2792
|
+
}
|
|
2793
|
+
function buildSnapshot(op, fullRecord) {
|
|
2303
2794
|
const previous = asRecord(op.previousData);
|
|
2304
2795
|
const next = asRecord(op.data);
|
|
2305
|
-
if (!previous && !next) return null;
|
|
2796
|
+
if (!previous && !next && !fullRecord) return null;
|
|
2306
2797
|
return {
|
|
2798
|
+
...fullRecord ?? {},
|
|
2307
2799
|
...previous ?? {},
|
|
2308
2800
|
...next ?? {}
|
|
2309
2801
|
};
|
|
@@ -2396,21 +2888,74 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
|
|
|
2396
2888
|
"[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.)"
|
|
2397
2889
|
);
|
|
2398
2890
|
}
|
|
2399
|
-
|
|
2891
|
+
function stableStringify(value) {
|
|
2892
|
+
if (Array.isArray(value)) {
|
|
2893
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
2894
|
+
}
|
|
2895
|
+
if (value && typeof value === "object") {
|
|
2896
|
+
const record = value;
|
|
2897
|
+
const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
|
|
2898
|
+
return `{${entries.join(",")}}`;
|
|
2899
|
+
}
|
|
2900
|
+
return JSON.stringify(value);
|
|
2901
|
+
}
|
|
2902
|
+
function sameScopeMap(a, b) {
|
|
2903
|
+
const normalize = (value) => value && typeof value === "object" ? normalizeScopeMap(value) : value;
|
|
2904
|
+
return stableStringify(normalize(a) ?? null) === stableStringify(normalize(b) ?? null);
|
|
2905
|
+
}
|
|
2906
|
+
var ClientSession = class _ClientSession {
|
|
2400
2907
|
state = "connected";
|
|
2401
2908
|
clientNodeId = null;
|
|
2402
2909
|
authContext = null;
|
|
2403
2910
|
syncQuerySubsets = [];
|
|
2404
2911
|
resumeDeltaCursor = null;
|
|
2912
|
+
/**
|
|
2913
|
+
* The delivery watermark the client reported at handshake, or null when the client
|
|
2914
|
+
* does not use the delivery watermark (old client, or no watermark yet). When set,
|
|
2915
|
+
* the server drives the gap-free server->client stream from delivery sequences
|
|
2916
|
+
* instead of the version-vector delta.
|
|
2917
|
+
*/
|
|
2918
|
+
clientDeliveryWatermark = null;
|
|
2919
|
+
/**
|
|
2920
|
+
* The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
|
|
2921
|
+
* watermark, as reported in acks). Incremental streaming pushes resume from here, not
|
|
2922
|
+
* from the highest sequence sent, so a dropped or unapplied batch is always
|
|
2923
|
+
* re-included by the next push (which re-scans from the acknowledged position). This
|
|
2924
|
+
* is what makes streaming recovery independent of any bounded retransmit buffer: there
|
|
2925
|
+
* is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
|
|
2926
|
+
* client's reported watermark at handshake.
|
|
2927
|
+
*/
|
|
2928
|
+
lastAckedDeliverySeq = 0;
|
|
2929
|
+
/**
|
|
2930
|
+
* Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
|
|
2931
|
+
* this as its stale window so a slow client does not receive duplicate full-stream
|
|
2932
|
+
* batches every server tick while the first batch is still being applied.
|
|
2933
|
+
*/
|
|
2934
|
+
lastDeliveryPushAttemptAtMs = 0;
|
|
2935
|
+
deliveryStallRepeatCount = 0;
|
|
2936
|
+
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
2937
|
+
deliveryPushChain = Promise.resolve();
|
|
2938
|
+
/**
|
|
2939
|
+
* Relay batches sent to this client but not yet acknowledged, keyed by messageId.
|
|
2940
|
+
* The client acks every operation-batch it applies; on ack the entry is cleared.
|
|
2941
|
+
* {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
|
|
2942
|
+
* dropped by a lossy transport is redelivered instead of leaving the client with a
|
|
2943
|
+
* permanent version-vector gap (a lost operation) that delta sync cannot recover.
|
|
2944
|
+
* Bounded so a silent client cannot grow it without limit.
|
|
2945
|
+
*/
|
|
2946
|
+
pendingRelays = /* @__PURE__ */ new Map();
|
|
2947
|
+
static MAX_PENDING_RELAYS = 1e3;
|
|
2405
2948
|
sessionId;
|
|
2406
2949
|
transport;
|
|
2407
2950
|
store;
|
|
2408
2951
|
auth;
|
|
2409
2952
|
serializer;
|
|
2410
2953
|
emitter;
|
|
2954
|
+
logger;
|
|
2411
2955
|
batchSize;
|
|
2412
2956
|
schemaVersion;
|
|
2413
2957
|
supportedSchemaVersions;
|
|
2958
|
+
operationTransforms;
|
|
2414
2959
|
onRelay;
|
|
2415
2960
|
onAwarenessUpdate;
|
|
2416
2961
|
onYjsDocUpdate;
|
|
@@ -2418,9 +2963,13 @@ var ClientSession = class {
|
|
|
2418
2963
|
onBlobChunkResponse;
|
|
2419
2964
|
persistBlobChunk;
|
|
2420
2965
|
onClose;
|
|
2966
|
+
onOrphanedRelays;
|
|
2967
|
+
takeOrphanedRelays;
|
|
2421
2968
|
maxOperationBytes;
|
|
2422
2969
|
maxOpsPerMinute;
|
|
2423
2970
|
rateLimiter;
|
|
2971
|
+
validateOperation;
|
|
2972
|
+
koraContext;
|
|
2424
2973
|
constructor(options) {
|
|
2425
2974
|
this.sessionId = options.sessionId;
|
|
2426
2975
|
this.transport = options.transport;
|
|
@@ -2428,6 +2977,7 @@ var ClientSession = class {
|
|
|
2428
2977
|
this.auth = options.auth ?? null;
|
|
2429
2978
|
this.serializer = options.serializer ?? new import_sync3.NegotiatedMessageSerializer("json");
|
|
2430
2979
|
this.emitter = options.emitter ?? null;
|
|
2980
|
+
this.logger = options.logger ?? null;
|
|
2431
2981
|
this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2432
2982
|
this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
2433
2983
|
const supported = options.supportedSchemaVersions;
|
|
@@ -2435,6 +2985,7 @@ var ClientSession = class {
|
|
|
2435
2985
|
min: this.schemaVersion,
|
|
2436
2986
|
max: this.schemaVersion
|
|
2437
2987
|
};
|
|
2988
|
+
this.operationTransforms = options.operationTransforms ?? [];
|
|
2438
2989
|
this.onRelay = options.onRelay ?? null;
|
|
2439
2990
|
this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
|
|
2440
2991
|
this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
|
|
@@ -2442,9 +2993,13 @@ var ClientSession = class {
|
|
|
2442
2993
|
this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
|
|
2443
2994
|
this.persistBlobChunk = options.persistBlobChunk ?? null;
|
|
2444
2995
|
this.onClose = options.onClose ?? null;
|
|
2996
|
+
this.onOrphanedRelays = options.onOrphanedRelays ?? null;
|
|
2997
|
+
this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
|
|
2445
2998
|
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2446
2999
|
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
2447
3000
|
this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
|
|
3001
|
+
this.validateOperation = options.validateOperation ?? null;
|
|
3002
|
+
this.koraContext = options.koraContext ?? null;
|
|
2448
3003
|
}
|
|
2449
3004
|
/**
|
|
2450
3005
|
* Start handling messages from the client transport.
|
|
@@ -2464,25 +3019,129 @@ var ClientSession = class {
|
|
|
2464
3019
|
*/
|
|
2465
3020
|
relayOperations(operations2) {
|
|
2466
3021
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
3022
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
3023
|
+
this.pushDeliveryStream();
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
2467
3026
|
if (operations2.length === 0) return;
|
|
2468
|
-
|
|
3027
|
+
void this.relayVisibleOperations(operations2);
|
|
3028
|
+
}
|
|
3029
|
+
/**
|
|
3030
|
+
* Push newly-available in-scope operations to a delivery-watermark client, resuming
|
|
3031
|
+
* from the highest delivery sequence already sent. Pushes are serialized so their
|
|
3032
|
+
* batches never interleave (which would break the base/max chain).
|
|
3033
|
+
*/
|
|
3034
|
+
pushDeliveryStream() {
|
|
3035
|
+
this.deliveryPushChain = this.deliveryPushChain.then(async () => {
|
|
3036
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
3037
|
+
try {
|
|
3038
|
+
this.lastDeliveryPushAttemptAtMs = Date.now();
|
|
3039
|
+
await this.sendDeliveryStream(
|
|
3040
|
+
this.lastAckedDeliverySeq,
|
|
3041
|
+
false,
|
|
3042
|
+
this.clientNodeId ?? void 0
|
|
3043
|
+
);
|
|
3044
|
+
} catch {
|
|
3045
|
+
}
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
3048
|
+
async relayVisibleOperations(operations2) {
|
|
3049
|
+
const visibleOperations = [];
|
|
3050
|
+
for (const op of operations2) {
|
|
3051
|
+
if (await this.operationVisibleToClient(op)) {
|
|
3052
|
+
visibleOperations.push(op);
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
2469
3055
|
if (visibleOperations.length === 0) return;
|
|
3056
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2470
3057
|
const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
|
|
2471
3058
|
const msg = {
|
|
2472
3059
|
type: "operation-batch",
|
|
2473
|
-
messageId: (0,
|
|
3060
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2474
3061
|
operations: serializedOps,
|
|
2475
3062
|
isFinal: true,
|
|
2476
3063
|
batchIndex: 0
|
|
2477
3064
|
};
|
|
3065
|
+
this.trackPendingRelay(msg, visibleOperations);
|
|
2478
3066
|
this.sendToClient(msg);
|
|
2479
3067
|
}
|
|
3068
|
+
/** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
|
|
3069
|
+
trackPendingRelay(msg, ops) {
|
|
3070
|
+
if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
|
|
3071
|
+
const oldest = this.pendingRelays.keys().next().value;
|
|
3072
|
+
if (oldest !== void 0) {
|
|
3073
|
+
this.pendingRelays.delete(oldest);
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Retransmit relay batches this client has not acknowledged within `staleMs`.
|
|
3080
|
+
* Called on a periodic tick by the server (and directly by tests). Redelivering an
|
|
3081
|
+
* already-applied op is harmless: the client dedups by content-addressed id.
|
|
3082
|
+
*/
|
|
3083
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
3084
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
3085
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3088
|
+
if (this.pendingRelays.size === 0) return;
|
|
3089
|
+
const cutoff = Date.now() - staleMs;
|
|
3090
|
+
for (const { message, sentAtMs } of this.pendingRelays.values()) {
|
|
3091
|
+
if (sentAtMs <= cutoff) {
|
|
3092
|
+
this.sendToClient(message);
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
/**
|
|
3097
|
+
* Wake the durable delivery stream for clients that negotiated delivery
|
|
3098
|
+
* watermarks. This is used both for dropped watermark batches and for operations
|
|
3099
|
+
* appended by another server/store instance: the session always scans from its
|
|
3100
|
+
* own acknowledged cursor and applies its visibility filter before sending.
|
|
3101
|
+
*/
|
|
3102
|
+
pushDeliveryStreamIfSupported(staleMs = 0, options = {}) {
|
|
3103
|
+
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
3104
|
+
if (this.clientDeliveryWatermark === null) return;
|
|
3105
|
+
if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
if (options.trackStall) {
|
|
3109
|
+
this.deliveryStallRepeatCount += 1;
|
|
3110
|
+
if (this.deliveryStallRepeatCount >= 3) {
|
|
3111
|
+
this.emitter?.emit({
|
|
3112
|
+
type: "sync:delivery-stalled",
|
|
3113
|
+
sessionId: this.sessionId,
|
|
3114
|
+
watermark: this.lastAckedDeliverySeq,
|
|
3115
|
+
repeatCount: this.deliveryStallRepeatCount
|
|
3116
|
+
});
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
this.pushDeliveryStream();
|
|
3120
|
+
}
|
|
2480
3121
|
/**
|
|
2481
3122
|
* Close this session.
|
|
2482
3123
|
*/
|
|
3124
|
+
/**
|
|
3125
|
+
* Hand any unacknowledged relays to the server so they survive this session and are
|
|
3126
|
+
* redelivered when this client reconnects (per-node buffer), then clear them. Called
|
|
3127
|
+
* from every session-teardown path (explicit close and transport close).
|
|
3128
|
+
*/
|
|
3129
|
+
flushOrphanedRelays() {
|
|
3130
|
+
if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
|
|
3131
|
+
const ops = [];
|
|
3132
|
+
for (const entry of this.pendingRelays.values()) {
|
|
3133
|
+
ops.push(...entry.ops);
|
|
3134
|
+
}
|
|
3135
|
+
if (ops.length > 0) {
|
|
3136
|
+
this.onOrphanedRelays(this.clientNodeId, ops);
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
this.pendingRelays.clear();
|
|
3140
|
+
}
|
|
2483
3141
|
close(reason) {
|
|
2484
3142
|
if (this.state === "closed") return;
|
|
2485
3143
|
this.state = "closed";
|
|
3144
|
+
this.flushOrphanedRelays();
|
|
2486
3145
|
if (this.transport.isConnected()) {
|
|
2487
3146
|
this.transport.close(1e3, reason ?? "session closed");
|
|
2488
3147
|
}
|
|
@@ -2537,6 +3196,11 @@ var ClientSession = class {
|
|
|
2537
3196
|
await this.handleOperationBatch(message);
|
|
2538
3197
|
break;
|
|
2539
3198
|
case "acknowledgment":
|
|
3199
|
+
this.pendingRelays.delete(message.acknowledgedMessageId);
|
|
3200
|
+
if (message.deliverySequence !== void 0) {
|
|
3201
|
+
this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
|
|
3202
|
+
this.deliveryStallRepeatCount = 0;
|
|
3203
|
+
}
|
|
2540
3204
|
break;
|
|
2541
3205
|
case "error":
|
|
2542
3206
|
break;
|
|
@@ -2567,7 +3231,7 @@ var ClientSession = class {
|
|
|
2567
3231
|
return;
|
|
2568
3232
|
}
|
|
2569
3233
|
const bytes = (0, import_sync2.decodeBlobChunkBytes)(message.bytes);
|
|
2570
|
-
const actual = await (0,
|
|
3234
|
+
const actual = await (0, import_core11.hashBlob)(bytes);
|
|
2571
3235
|
if (actual !== message.hash) {
|
|
2572
3236
|
return;
|
|
2573
3237
|
}
|
|
@@ -2575,6 +3239,18 @@ var ClientSession = class {
|
|
|
2575
3239
|
}
|
|
2576
3240
|
handleMessageFailure(error) {
|
|
2577
3241
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
3242
|
+
this.logger?.log({
|
|
3243
|
+
timestamp: Date.now(),
|
|
3244
|
+
level: "error",
|
|
3245
|
+
event: "session.message_failed",
|
|
3246
|
+
sessionId: this.sessionId,
|
|
3247
|
+
nodeId: this.clientNodeId ?? void 0,
|
|
3248
|
+
error: reason,
|
|
3249
|
+
details: {
|
|
3250
|
+
state: this.state,
|
|
3251
|
+
errorName: error instanceof Error ? error.name : typeof error
|
|
3252
|
+
}
|
|
3253
|
+
});
|
|
2578
3254
|
this.sendError("SYNC_ERROR", reason, true);
|
|
2579
3255
|
this.close(reason);
|
|
2580
3256
|
}
|
|
@@ -2595,11 +3271,25 @@ var ClientSession = class {
|
|
|
2595
3271
|
this.authContext = context;
|
|
2596
3272
|
this.state = "authenticated";
|
|
2597
3273
|
}
|
|
2598
|
-
const
|
|
3274
|
+
const rawResolvedScopes = resolveSessionScopes(this.store.getSchema(), {
|
|
2599
3275
|
handshakeScope: msg.syncScope,
|
|
2600
3276
|
authScopes: this.authContext?.scopes
|
|
2601
3277
|
});
|
|
2602
|
-
|
|
3278
|
+
let resolvedScopes;
|
|
3279
|
+
try {
|
|
3280
|
+
resolvedScopes = rawResolvedScopes ? normalizeScopeMap(rawResolvedScopes) : void 0;
|
|
3281
|
+
} catch (error) {
|
|
3282
|
+
this.sendToClient({
|
|
3283
|
+
type: "error",
|
|
3284
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
3285
|
+
code: "SCOPE_PREDICATE_LIMIT",
|
|
3286
|
+
message: error instanceof Error ? error.message : "Invalid scope predicate",
|
|
3287
|
+
retriable: false
|
|
3288
|
+
});
|
|
3289
|
+
this.close("invalid scope predicate");
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
3292
|
+
if (resolvedScopes) {
|
|
2603
3293
|
if (this.authContext) {
|
|
2604
3294
|
this.authContext = { ...this.authContext, scopes: resolvedScopes };
|
|
2605
3295
|
} else {
|
|
@@ -2613,14 +3303,34 @@ var ClientSession = class {
|
|
|
2613
3303
|
this.syncQuerySubsets = [];
|
|
2614
3304
|
}
|
|
2615
3305
|
this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
|
|
3306
|
+
this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
|
|
3307
|
+
if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
|
|
3308
|
+
this.clientDeliveryWatermark = 0;
|
|
3309
|
+
}
|
|
3310
|
+
const clientUsesWatermark = this.clientDeliveryWatermark !== null;
|
|
3311
|
+
let serverMaxDelivery = 0;
|
|
3312
|
+
if (clientUsesWatermark) {
|
|
3313
|
+
serverMaxDelivery = await this.store.getMaxDeliverySequence();
|
|
3314
|
+
if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
|
|
3315
|
+
this.clientDeliveryWatermark = 0;
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
2616
3318
|
const serverVector = this.store.getVersionVector();
|
|
2617
3319
|
const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
|
|
2618
3320
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
2619
3321
|
if (!(0, import_sync3.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
|
|
2620
3322
|
const { min, max } = this.supportedSchemaVersions;
|
|
3323
|
+
this.emitter?.emit({
|
|
3324
|
+
type: "sync:schema-mismatch",
|
|
3325
|
+
clientSchemaVersion: msg.schemaVersion,
|
|
3326
|
+
serverSchemaVersion: this.schemaVersion,
|
|
3327
|
+
supportedMin: min,
|
|
3328
|
+
supportedMax: max,
|
|
3329
|
+
reason: `${import_sync3.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`
|
|
3330
|
+
});
|
|
2621
3331
|
const response2 = {
|
|
2622
3332
|
type: "handshake-response",
|
|
2623
|
-
messageId: (0,
|
|
3333
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2624
3334
|
nodeId: this.store.getNodeId(),
|
|
2625
3335
|
versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
|
|
2626
3336
|
schemaVersion: this.schemaVersion,
|
|
@@ -2636,13 +3346,17 @@ var ClientSession = class {
|
|
|
2636
3346
|
}
|
|
2637
3347
|
const response = {
|
|
2638
3348
|
type: "handshake-response",
|
|
2639
|
-
messageId: (0,
|
|
3349
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2640
3350
|
nodeId: this.store.getNodeId(),
|
|
2641
3351
|
versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
|
|
2642
3352
|
schemaVersion: this.schemaVersion,
|
|
2643
3353
|
accepted: true,
|
|
2644
3354
|
selectedWireFormat,
|
|
2645
3355
|
serverTime: Date.now(),
|
|
3356
|
+
// The server's highest delivery sequence, so a client whose persisted watermark
|
|
3357
|
+
// is ahead of it (server rolled back) can reset to a full resync. Sent only to
|
|
3358
|
+
// watermark-using clients.
|
|
3359
|
+
...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
|
|
2646
3360
|
// Advertise central blob storage so the client uploads the bytes behind
|
|
2647
3361
|
// its blob fields, keeping them available after the author goes offline.
|
|
2648
3362
|
...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
|
|
@@ -2653,17 +3367,40 @@ var ClientSession = class {
|
|
|
2653
3367
|
this.sendToClient(response);
|
|
2654
3368
|
this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
|
|
2655
3369
|
this.state = "syncing";
|
|
2656
|
-
|
|
2657
|
-
|
|
3370
|
+
if (this.clientDeliveryWatermark !== null) {
|
|
3371
|
+
this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
|
|
3372
|
+
const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
|
|
3373
|
+
this.lastDeliveryPushAttemptAtMs = Date.now();
|
|
3374
|
+
await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
|
|
3375
|
+
} else {
|
|
3376
|
+
const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
|
|
3377
|
+
await this.sendDelta(clientVector);
|
|
3378
|
+
}
|
|
2658
3379
|
this.state = "streaming";
|
|
3380
|
+
if (this.takeOrphanedRelays && this.clientNodeId) {
|
|
3381
|
+
const buffered = this.takeOrphanedRelays(this.clientNodeId);
|
|
3382
|
+
if (buffered.length > 0) {
|
|
3383
|
+
this.relayOperations(buffered);
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
2659
3386
|
}
|
|
2660
3387
|
async handleOperationBatch(msg) {
|
|
2661
3388
|
const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2662
3389
|
const applied = [];
|
|
2663
|
-
|
|
3390
|
+
let acknowledgedThrough = 0;
|
|
3391
|
+
let canAdvanceAck = true;
|
|
2664
3392
|
for (const op of operations2) {
|
|
2665
|
-
if (!
|
|
2666
|
-
|
|
3393
|
+
if (!canAdvanceAck) {
|
|
3394
|
+
continue;
|
|
3395
|
+
}
|
|
3396
|
+
if (!await this.operationVisibleToClient(op)) {
|
|
3397
|
+
this.sendOperationRejected(
|
|
3398
|
+
op,
|
|
3399
|
+
"SCOPE_VIOLATION",
|
|
3400
|
+
`Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
|
|
3401
|
+
true
|
|
3402
|
+
);
|
|
3403
|
+
canAdvanceAck = false;
|
|
2667
3404
|
continue;
|
|
2668
3405
|
}
|
|
2669
3406
|
if (!isOperationTimestampValid(op)) {
|
|
@@ -2672,6 +3409,7 @@ var ClientSession = class {
|
|
|
2672
3409
|
`Operation "${op.id}" timestamp is too far in the future`,
|
|
2673
3410
|
false
|
|
2674
3411
|
);
|
|
3412
|
+
canAdvanceAck = false;
|
|
2675
3413
|
continue;
|
|
2676
3414
|
}
|
|
2677
3415
|
if (!this.rateLimiter.allow(1)) {
|
|
@@ -2680,6 +3418,7 @@ var ClientSession = class {
|
|
|
2680
3418
|
`Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
|
|
2681
3419
|
true
|
|
2682
3420
|
);
|
|
3421
|
+
canAdvanceAck = false;
|
|
2683
3422
|
continue;
|
|
2684
3423
|
}
|
|
2685
3424
|
const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
|
|
@@ -2689,27 +3428,73 @@ var ClientSession = class {
|
|
|
2689
3428
|
sizeCheck.message ?? `Operation "${op.id}" is too large`,
|
|
2690
3429
|
false
|
|
2691
3430
|
);
|
|
3431
|
+
canAdvanceAck = false;
|
|
2692
3432
|
continue;
|
|
2693
3433
|
}
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
3434
|
+
const serverOp = this.transformForServerSchema(op);
|
|
3435
|
+
if (serverOp === null) {
|
|
3436
|
+
this.sendOperationRejected(
|
|
3437
|
+
op,
|
|
3438
|
+
"SCHEMA_TRANSFORM_UNAVAILABLE",
|
|
3439
|
+
`Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
|
|
3440
|
+
false
|
|
3441
|
+
);
|
|
3442
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3443
|
+
continue;
|
|
3444
|
+
}
|
|
3445
|
+
if (this.validateOperation && this.koraContext) {
|
|
3446
|
+
let decision;
|
|
3447
|
+
try {
|
|
3448
|
+
decision = await this.validateOperation(serverOp, {
|
|
3449
|
+
auth: this.authContext,
|
|
3450
|
+
kora: this.koraContext
|
|
3451
|
+
});
|
|
3452
|
+
} catch (error) {
|
|
3453
|
+
const message = error instanceof Error ? error.message : "validator error";
|
|
3454
|
+
this.sendOperationRejected(
|
|
3455
|
+
serverOp,
|
|
3456
|
+
"VALIDATION_ERROR",
|
|
3457
|
+
`Validator threw: ${message}`,
|
|
3458
|
+
true
|
|
3459
|
+
);
|
|
3460
|
+
canAdvanceAck = false;
|
|
3461
|
+
continue;
|
|
3462
|
+
}
|
|
3463
|
+
if (decision.action === "reject") {
|
|
3464
|
+
const retriable = decision.retriable ?? isRetriableRejection(decision.code);
|
|
3465
|
+
this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
|
|
3466
|
+
if (retriable) {
|
|
3467
|
+
canAdvanceAck = false;
|
|
3468
|
+
} else {
|
|
3469
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3470
|
+
}
|
|
2698
3471
|
continue;
|
|
2699
3472
|
}
|
|
2700
|
-
if (
|
|
2701
|
-
|
|
3473
|
+
if (decision.action === "ignore") {
|
|
3474
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3475
|
+
continue;
|
|
2702
3476
|
}
|
|
2703
|
-
} catch {
|
|
2704
3477
|
}
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
3478
|
+
const applyResult = await applyServerOperation(this.store, serverOp);
|
|
3479
|
+
if (applyResult.rejection) {
|
|
3480
|
+
this.sendOperationRejected(
|
|
3481
|
+
serverOp,
|
|
3482
|
+
applyResult.rejection.code,
|
|
3483
|
+
applyResult.rejection.message,
|
|
3484
|
+
applyResult.rejection.retriable
|
|
2712
3485
|
);
|
|
3486
|
+
if (applyResult.rejection.retriable) {
|
|
3487
|
+
canAdvanceAck = false;
|
|
3488
|
+
} else {
|
|
3489
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3490
|
+
}
|
|
3491
|
+
continue;
|
|
3492
|
+
}
|
|
3493
|
+
if (applyResult.result === "applied") {
|
|
3494
|
+
applied.push(...applyResult.appliedOperations);
|
|
3495
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3496
|
+
} else {
|
|
3497
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
2713
3498
|
}
|
|
2714
3499
|
}
|
|
2715
3500
|
if (operations2.length > 0) {
|
|
@@ -2722,15 +3507,21 @@ var ClientSession = class {
|
|
|
2722
3507
|
const lastOp = operations2[operations2.length - 1];
|
|
2723
3508
|
const ack = {
|
|
2724
3509
|
type: "acknowledgment",
|
|
2725
|
-
messageId: (0,
|
|
3510
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2726
3511
|
acknowledgedMessageId: msg.messageId,
|
|
2727
|
-
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3512
|
+
lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
|
|
2728
3513
|
};
|
|
2729
3514
|
this.sendToClient(ack);
|
|
2730
3515
|
if (applied.length > 0) {
|
|
2731
3516
|
this.onRelay?.(this.sessionId, applied);
|
|
2732
3517
|
}
|
|
2733
3518
|
}
|
|
3519
|
+
transformForServerSchema(op) {
|
|
3520
|
+
if (op.schemaVersion === this.schemaVersion) {
|
|
3521
|
+
return op;
|
|
3522
|
+
}
|
|
3523
|
+
return (0, import_core10.applyOperationTransforms)(op, this.schemaVersion, this.operationTransforms);
|
|
3524
|
+
}
|
|
2734
3525
|
async sendDelta(clientVector) {
|
|
2735
3526
|
const serverVector = this.store.getVersionVector();
|
|
2736
3527
|
const missing = [];
|
|
@@ -2738,14 +3529,17 @@ var ClientSession = class {
|
|
|
2738
3529
|
const clientSeq = clientVector.get(nodeId) ?? 0;
|
|
2739
3530
|
if (serverSeq > clientSeq) {
|
|
2740
3531
|
const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
|
|
2741
|
-
const
|
|
2742
|
-
|
|
3532
|
+
for (const op of ops) {
|
|
3533
|
+
if (await this.operationVisibleToClient(op)) {
|
|
3534
|
+
missing.push(op);
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
2743
3537
|
}
|
|
2744
3538
|
}
|
|
2745
3539
|
if (missing.length === 0) {
|
|
2746
3540
|
const emptyBatch = {
|
|
2747
3541
|
type: "operation-batch",
|
|
2748
|
-
messageId: (0,
|
|
3542
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2749
3543
|
operations: [],
|
|
2750
3544
|
isFinal: true,
|
|
2751
3545
|
batchIndex: 0,
|
|
@@ -2760,7 +3554,7 @@ var ClientSession = class {
|
|
|
2760
3554
|
if (afterCursor.length === 0) {
|
|
2761
3555
|
const emptyBatch = {
|
|
2762
3556
|
type: "operation-batch",
|
|
2763
|
-
messageId: (0,
|
|
3557
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2764
3558
|
operations: [],
|
|
2765
3559
|
isFinal: true,
|
|
2766
3560
|
batchIndex: this.resumeDeltaCursor?.batchIndex ?? 0,
|
|
@@ -2776,7 +3570,7 @@ var ClientSession = class {
|
|
|
2776
3570
|
const batchCursor = (0, import_sync3.createDeltaCursorFromBatch)(batchOps, i);
|
|
2777
3571
|
const batchMsg = {
|
|
2778
3572
|
type: "operation-batch",
|
|
2779
|
-
messageId: (0,
|
|
3573
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2780
3574
|
operations: serializedOps,
|
|
2781
3575
|
isFinal: i === totalBatches - 1,
|
|
2782
3576
|
batchIndex: i,
|
|
@@ -2791,11 +3585,111 @@ var ClientSession = class {
|
|
|
2791
3585
|
});
|
|
2792
3586
|
}
|
|
2793
3587
|
}
|
|
2794
|
-
|
|
2795
|
-
|
|
3588
|
+
/**
|
|
3589
|
+
* Send the gap-free server->client delivery stream, resuming just after
|
|
3590
|
+
* `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
|
|
3591
|
+
* (commit order), scope-filtered for this session, and sent in batches that chain:
|
|
3592
|
+
* each batch's `baseDeliverySequence` equals the previous batch's
|
|
3593
|
+
* `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
|
|
3594
|
+
* applies a batch only when its watermark equals the base and advances the
|
|
3595
|
+
* watermark to the max, so a dropped batch stalls the watermark and is recovered by
|
|
3596
|
+
* the next handshake resend. Because delivery-sequence order respects causal order
|
|
3597
|
+
* (a dependency is always committed, and thus sequenced, before its dependent), no
|
|
3598
|
+
* topological sort is needed and the client never defers on a missing dependency
|
|
3599
|
+
* that is itself in this stream.
|
|
3600
|
+
*
|
|
3601
|
+
* The final batch advances the watermark to the highest delivery sequence scanned,
|
|
3602
|
+
* not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
|
|
3603
|
+
* next reconnect. Returns the number of operations actually sent.
|
|
3604
|
+
*/
|
|
3605
|
+
async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
|
|
3606
|
+
const scanChunk = Math.max(this.batchSize, 1) * 5;
|
|
3607
|
+
let scanCursor = fromDeliverySeq;
|
|
3608
|
+
let maxScanned = fromDeliverySeq;
|
|
3609
|
+
const visible = [];
|
|
3610
|
+
while (true) {
|
|
3611
|
+
const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
|
|
3612
|
+
const last = chunk[chunk.length - 1];
|
|
3613
|
+
if (last === void 0) break;
|
|
3614
|
+
scanCursor = last.deliverySequence;
|
|
3615
|
+
maxScanned = scanCursor;
|
|
3616
|
+
for (const delivered of chunk) {
|
|
3617
|
+
if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
|
|
3618
|
+
continue;
|
|
3619
|
+
}
|
|
3620
|
+
if (await this.operationVisibleToClient(delivered.operation)) {
|
|
3621
|
+
visible.push(delivered);
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
if (chunk.length < scanChunk) break;
|
|
3625
|
+
}
|
|
3626
|
+
if (visible.length === 0) {
|
|
3627
|
+
if (finalizeWhenEmpty) {
|
|
3628
|
+
this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
|
|
3629
|
+
}
|
|
3630
|
+
return 0;
|
|
3631
|
+
}
|
|
3632
|
+
const totalBatches = Math.ceil(visible.length / this.batchSize);
|
|
3633
|
+
let base = fromDeliverySeq;
|
|
3634
|
+
for (let i = 0; i < totalBatches; i++) {
|
|
3635
|
+
const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
|
|
3636
|
+
const lastInSlice = slice[slice.length - 1];
|
|
3637
|
+
if (lastInSlice === void 0) continue;
|
|
3638
|
+
const isFinal = i === totalBatches - 1;
|
|
3639
|
+
const lastSeq = lastInSlice.deliverySequence;
|
|
3640
|
+
const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
|
|
3641
|
+
this.sendDeliveryBatch(slice, base, max, i, isFinal);
|
|
3642
|
+
base = max;
|
|
3643
|
+
}
|
|
3644
|
+
return visible.length;
|
|
3645
|
+
}
|
|
3646
|
+
/** Build, track, and send one chained delivery-stream batch. */
|
|
3647
|
+
sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
|
|
3648
|
+
const batchMsg = {
|
|
3649
|
+
type: "operation-batch",
|
|
3650
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
3651
|
+
operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
|
|
3652
|
+
isFinal,
|
|
3653
|
+
batchIndex,
|
|
3654
|
+
baseDeliverySequence: base,
|
|
3655
|
+
maxDeliverySequence: max
|
|
3656
|
+
};
|
|
3657
|
+
this.sendToClient(batchMsg);
|
|
3658
|
+
if (slice.length > 0) {
|
|
3659
|
+
this.emitter?.emit({
|
|
3660
|
+
type: "sync:sent",
|
|
3661
|
+
operations: slice.map((delivered) => delivered.operation),
|
|
3662
|
+
batchSize: slice.length
|
|
3663
|
+
});
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
async operationVisibleToClient(op) {
|
|
3667
|
+
const scopes = this.authContext?.scopes;
|
|
3668
|
+
const subsets = this.syncQuerySubsets;
|
|
3669
|
+
const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
|
|
3670
|
+
const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
|
|
3671
|
+
if (!operationMatchesScopes(op, scopes, fullRecord)) {
|
|
2796
3672
|
return false;
|
|
2797
3673
|
}
|
|
2798
|
-
return (0, import_sync3.operationMatchesQuerySubsets)(op,
|
|
3674
|
+
return (0, import_sync3.operationMatchesQuerySubsets)(op, subsets, fullRecord);
|
|
3675
|
+
}
|
|
3676
|
+
/**
|
|
3677
|
+
* Read a record's current field values from the materialized store for scope /
|
|
3678
|
+
* query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
|
|
3679
|
+
* carries no fields) is still judged against the record's actual scope. Returns
|
|
3680
|
+
* undefined when the record cannot be read (never materialized, or no schema).
|
|
3681
|
+
*/
|
|
3682
|
+
async lookupRecordFields(collection, recordId) {
|
|
3683
|
+
try {
|
|
3684
|
+
const rows = await this.store.queryCollection(collection, {
|
|
3685
|
+
where: { id: recordId },
|
|
3686
|
+
includeDeleted: true,
|
|
3687
|
+
limit: 1
|
|
3688
|
+
});
|
|
3689
|
+
return rows[0];
|
|
3690
|
+
} catch {
|
|
3691
|
+
return void 0;
|
|
3692
|
+
}
|
|
2799
3693
|
}
|
|
2800
3694
|
handleAwarenessUpdate(msg) {
|
|
2801
3695
|
this.onAwarenessUpdate?.(this.sessionId, msg);
|
|
@@ -2806,13 +3700,32 @@ var ClientSession = class {
|
|
|
2806
3700
|
sendError(code, message, retriable) {
|
|
2807
3701
|
const errorMsg = {
|
|
2808
3702
|
type: "error",
|
|
2809
|
-
messageId: (0,
|
|
3703
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
2810
3704
|
code,
|
|
2811
3705
|
message,
|
|
2812
3706
|
retriable
|
|
2813
3707
|
};
|
|
2814
3708
|
this.sendToClient(errorMsg);
|
|
2815
3709
|
}
|
|
3710
|
+
/**
|
|
3711
|
+
* Reject one specific client operation, tied to its id, so the submitter can
|
|
3712
|
+
* divert it out of its pending queue into a durable rejected store rather than
|
|
3713
|
+
* losing it or retrying forever. The op is NOT materialized, so no other
|
|
3714
|
+
* replica ever sees it.
|
|
3715
|
+
*/
|
|
3716
|
+
sendOperationRejected(op, code, message, retriable) {
|
|
3717
|
+
const rejectedMsg = {
|
|
3718
|
+
type: "operation-rejected",
|
|
3719
|
+
messageId: (0, import_core11.generateUUIDv7)(),
|
|
3720
|
+
operationId: op.id,
|
|
3721
|
+
collection: op.collection,
|
|
3722
|
+
recordId: op.recordId,
|
|
3723
|
+
code,
|
|
3724
|
+
message,
|
|
3725
|
+
retriable
|
|
3726
|
+
};
|
|
3727
|
+
this.sendToClient(rejectedMsg);
|
|
3728
|
+
}
|
|
2816
3729
|
setSerializerWireFormat(format) {
|
|
2817
3730
|
if (typeof this.serializer.setWireFormat === "function") {
|
|
2818
3731
|
this.serializer.setWireFormat(format);
|
|
@@ -2821,6 +3734,7 @@ var ClientSession = class {
|
|
|
2821
3734
|
handleTransportClose() {
|
|
2822
3735
|
if (this.state === "closed") return;
|
|
2823
3736
|
this.state = "closed";
|
|
3737
|
+
this.flushOrphanedRelays();
|
|
2824
3738
|
this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
|
|
2825
3739
|
this.onClose?.(this.sessionId);
|
|
2826
3740
|
}
|
|
@@ -2834,13 +3748,13 @@ function selectWireFormat(supportedWireFormats) {
|
|
|
2834
3748
|
|
|
2835
3749
|
// src/server/kora-sync-server.ts
|
|
2836
3750
|
init_cjs_shims();
|
|
2837
|
-
var
|
|
3751
|
+
var import_core14 = require("@korajs/core");
|
|
2838
3752
|
var import_internal2 = require("@korajs/core/internal");
|
|
2839
3753
|
var import_sync5 = require("@korajs/sync");
|
|
2840
3754
|
|
|
2841
3755
|
// src/awareness/awareness-relay.ts
|
|
2842
3756
|
init_cjs_shims();
|
|
2843
|
-
var
|
|
3757
|
+
var import_core12 = require("@korajs/core");
|
|
2844
3758
|
var AwarenessRelay = class {
|
|
2845
3759
|
clients = /* @__PURE__ */ new Map();
|
|
2846
3760
|
/**
|
|
@@ -2869,7 +3783,7 @@ var AwarenessRelay = class {
|
|
|
2869
3783
|
if (hasStates) {
|
|
2870
3784
|
const catchUpMsg = {
|
|
2871
3785
|
type: "awareness-update",
|
|
2872
|
-
messageId: (0,
|
|
3786
|
+
messageId: (0, import_core12.generateUUIDv7)(),
|
|
2873
3787
|
clientId: 0,
|
|
2874
3788
|
// Server-sourced
|
|
2875
3789
|
states: existingStates
|
|
@@ -2892,7 +3806,7 @@ var AwarenessRelay = class {
|
|
|
2892
3806
|
};
|
|
2893
3807
|
const msg = {
|
|
2894
3808
|
type: "awareness-update",
|
|
2895
|
-
messageId: (0,
|
|
3809
|
+
messageId: (0, import_core12.generateUUIDv7)(),
|
|
2896
3810
|
clientId: client.clientId,
|
|
2897
3811
|
states: removalStates
|
|
2898
3812
|
};
|
|
@@ -3188,13 +4102,395 @@ var YjsDocRelay = class {
|
|
|
3188
4102
|
if (client.sessionId === excludeSessionId) {
|
|
3189
4103
|
continue;
|
|
3190
4104
|
}
|
|
3191
|
-
if (!client.transport.isConnected()) {
|
|
3192
|
-
continue;
|
|
4105
|
+
if (!client.transport.isConnected()) {
|
|
4106
|
+
continue;
|
|
4107
|
+
}
|
|
4108
|
+
client.transport.send(message);
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
};
|
|
4112
|
+
|
|
4113
|
+
// src/server/route-context.ts
|
|
4114
|
+
init_cjs_shims();
|
|
4115
|
+
var import_core13 = require("@korajs/core");
|
|
4116
|
+
|
|
4117
|
+
// src/apply/route-predicate.ts
|
|
4118
|
+
init_cjs_shims();
|
|
4119
|
+
function evaluateField(actual, operators) {
|
|
4120
|
+
if ("$eq" in operators && !Object.is(actual, operators.$eq)) {
|
|
4121
|
+
return false;
|
|
4122
|
+
}
|
|
4123
|
+
if ("$ne" in operators && Object.is(actual, operators.$ne)) {
|
|
4124
|
+
return false;
|
|
4125
|
+
}
|
|
4126
|
+
if (operators.$in !== void 0 && !operators.$in.some((candidate) => Object.is(actual, candidate))) {
|
|
4127
|
+
return false;
|
|
4128
|
+
}
|
|
4129
|
+
const numericChecks = [
|
|
4130
|
+
["$lt", (a, b) => a < b],
|
|
4131
|
+
["$lte", (a, b) => a <= b],
|
|
4132
|
+
["$gt", (a, b) => a > b],
|
|
4133
|
+
["$gte", (a, b) => a >= b]
|
|
4134
|
+
];
|
|
4135
|
+
for (const [operator, compare] of numericChecks) {
|
|
4136
|
+
const bound = operators[operator];
|
|
4137
|
+
if (bound === void 0) {
|
|
4138
|
+
continue;
|
|
4139
|
+
}
|
|
4140
|
+
if (typeof actual !== "number" || typeof bound !== "number") {
|
|
4141
|
+
return false;
|
|
4142
|
+
}
|
|
4143
|
+
if (!compare(actual, bound)) {
|
|
4144
|
+
return false;
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
4147
|
+
return true;
|
|
4148
|
+
}
|
|
4149
|
+
function evaluateRoutePredicate(record, predicate) {
|
|
4150
|
+
const fields = Object.keys(predicate);
|
|
4151
|
+
if (fields.length === 0) {
|
|
4152
|
+
return true;
|
|
4153
|
+
}
|
|
4154
|
+
if (record === null) {
|
|
4155
|
+
return false;
|
|
4156
|
+
}
|
|
4157
|
+
for (const field of fields) {
|
|
4158
|
+
const operators = predicate[field];
|
|
4159
|
+
if (operators && !evaluateField(record[field], operators)) {
|
|
4160
|
+
return false;
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4163
|
+
return true;
|
|
4164
|
+
}
|
|
4165
|
+
|
|
4166
|
+
// src/server/route-context.ts
|
|
4167
|
+
function recordMatchesScope(collection, record, scope) {
|
|
4168
|
+
const collectionScope = scope[collection];
|
|
4169
|
+
if (!collectionScope) {
|
|
4170
|
+
return false;
|
|
4171
|
+
}
|
|
4172
|
+
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
4173
|
+
if (record[field] !== expected) {
|
|
4174
|
+
return false;
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
return true;
|
|
4178
|
+
}
|
|
4179
|
+
function pickFields(record, keys) {
|
|
4180
|
+
const picked = {};
|
|
4181
|
+
for (const key of keys) {
|
|
4182
|
+
if (key in record) {
|
|
4183
|
+
picked[key] = record[key];
|
|
4184
|
+
}
|
|
4185
|
+
}
|
|
4186
|
+
return picked;
|
|
4187
|
+
}
|
|
4188
|
+
async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
|
|
4189
|
+
const nodeId = store.getNodeId();
|
|
4190
|
+
const clock = clockOverride ?? new import_core13.HybridLogicalClock(nodeId);
|
|
4191
|
+
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
4192
|
+
const sequenceNumber = nextServerSequenceNumber(store);
|
|
4193
|
+
if (mutation.type === "insert") {
|
|
4194
|
+
return (0, import_core13.createOperation)(
|
|
4195
|
+
{
|
|
4196
|
+
nodeId,
|
|
4197
|
+
type: "insert",
|
|
4198
|
+
collection: mutation.collection,
|
|
4199
|
+
recordId: mutation.recordId ?? (0, import_core13.generateUUIDv7)(),
|
|
4200
|
+
data: mutation.data ?? {},
|
|
4201
|
+
previousData: null,
|
|
4202
|
+
sequenceNumber,
|
|
4203
|
+
causalDeps: [],
|
|
4204
|
+
schemaVersion
|
|
4205
|
+
},
|
|
4206
|
+
clock
|
|
4207
|
+
);
|
|
4208
|
+
}
|
|
4209
|
+
if (!mutation.recordId) {
|
|
4210
|
+
throw new RouteMutationError(
|
|
4211
|
+
"MISSING_RECORD_ID",
|
|
4212
|
+
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
4213
|
+
);
|
|
4214
|
+
}
|
|
4215
|
+
const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
|
|
4216
|
+
if (mutation.type === "update") {
|
|
4217
|
+
const rawData = mutation.data ?? {};
|
|
4218
|
+
const data = {};
|
|
4219
|
+
const atomicOps = {};
|
|
4220
|
+
for (const key of Object.keys(rawData)) {
|
|
4221
|
+
const value = rawData[key];
|
|
4222
|
+
if ((0, import_core13.isAtomicOp)(value)) {
|
|
4223
|
+
data[key] = (0, import_core13.resolveAtomicOp)(current ? current[key] : void 0, value);
|
|
4224
|
+
atomicOps[key] = (0, import_core13.toAtomicOp)(value);
|
|
4225
|
+
} else {
|
|
4226
|
+
data[key] = value;
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
4229
|
+
return (0, import_core13.createOperation)(
|
|
4230
|
+
{
|
|
4231
|
+
nodeId,
|
|
4232
|
+
type: "update",
|
|
4233
|
+
collection: mutation.collection,
|
|
4234
|
+
recordId: mutation.recordId,
|
|
4235
|
+
data,
|
|
4236
|
+
previousData: current ? pickFields(current, Object.keys(rawData)) : {},
|
|
4237
|
+
sequenceNumber,
|
|
4238
|
+
causalDeps: [],
|
|
4239
|
+
schemaVersion,
|
|
4240
|
+
...Object.keys(atomicOps).length > 0 ? { atomicOps } : {}
|
|
4241
|
+
},
|
|
4242
|
+
clock
|
|
4243
|
+
);
|
|
4244
|
+
}
|
|
4245
|
+
return (0, import_core13.createOperation)(
|
|
4246
|
+
{
|
|
4247
|
+
nodeId,
|
|
4248
|
+
type: "delete",
|
|
4249
|
+
collection: mutation.collection,
|
|
4250
|
+
recordId: mutation.recordId,
|
|
4251
|
+
data: null,
|
|
4252
|
+
previousData: current ? { ...current } : null,
|
|
4253
|
+
sequenceNumber,
|
|
4254
|
+
causalDeps: [],
|
|
4255
|
+
schemaVersion
|
|
4256
|
+
},
|
|
4257
|
+
clock
|
|
4258
|
+
);
|
|
4259
|
+
}
|
|
4260
|
+
var RouteMutationError = class extends Error {
|
|
4261
|
+
constructor(code, message) {
|
|
4262
|
+
super(message);
|
|
4263
|
+
this.code = code;
|
|
4264
|
+
this.name = "RouteMutationError";
|
|
4265
|
+
}
|
|
4266
|
+
code;
|
|
4267
|
+
};
|
|
4268
|
+
function createRouteContext(server, store) {
|
|
4269
|
+
let mutationTail = Promise.resolve();
|
|
4270
|
+
function serialize(work) {
|
|
4271
|
+
const run = mutationTail.then(work, work);
|
|
4272
|
+
mutationTail = run.then(
|
|
4273
|
+
() => void 0,
|
|
4274
|
+
() => void 0
|
|
4275
|
+
);
|
|
4276
|
+
return run;
|
|
4277
|
+
}
|
|
4278
|
+
async function prepareMutation(mutation, scope) {
|
|
4279
|
+
let op;
|
|
4280
|
+
try {
|
|
4281
|
+
op = await buildRouteOperation(store, mutation);
|
|
4282
|
+
} catch (error) {
|
|
4283
|
+
if (error instanceof RouteMutationError) {
|
|
4284
|
+
return {
|
|
4285
|
+
ok: false,
|
|
4286
|
+
rejection: {
|
|
4287
|
+
ok: false,
|
|
4288
|
+
code: error.code,
|
|
4289
|
+
message: error.message,
|
|
4290
|
+
retriable: isRetriableRejection(error.code)
|
|
4291
|
+
}
|
|
4292
|
+
};
|
|
4293
|
+
}
|
|
4294
|
+
throw error;
|
|
4295
|
+
}
|
|
4296
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
4297
|
+
return {
|
|
4298
|
+
ok: false,
|
|
4299
|
+
rejection: {
|
|
4300
|
+
ok: false,
|
|
4301
|
+
code: "SCOPE_VIOLATION",
|
|
4302
|
+
message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
|
|
4303
|
+
retriable: isRetriableRejection("SCOPE_VIOLATION")
|
|
4304
|
+
}
|
|
4305
|
+
};
|
|
4306
|
+
}
|
|
4307
|
+
return { ok: true, op };
|
|
4308
|
+
}
|
|
4309
|
+
async function commitOperation(op, collection) {
|
|
4310
|
+
const result = await server.applyLocalOperation(op);
|
|
4311
|
+
if (result.result !== "applied") {
|
|
4312
|
+
const code = result.rejection?.code ?? "NOT_APPLIED";
|
|
4313
|
+
return {
|
|
4314
|
+
ok: false,
|
|
4315
|
+
code,
|
|
4316
|
+
message: result.rejection?.message ?? `Operation on "${collection}" was not applied (${result.result}).`,
|
|
4317
|
+
// Prefer the pipeline's own classification when present; fall back
|
|
4318
|
+
// to classifying the code so this stays correct if new codes appear.
|
|
4319
|
+
retriable: result.rejection?.retriable ?? isRetriableRejection(code)
|
|
4320
|
+
};
|
|
4321
|
+
}
|
|
4322
|
+
const record = await store.findRecord(collection, op.recordId);
|
|
4323
|
+
return { ok: true, operation: op, record };
|
|
4324
|
+
}
|
|
4325
|
+
async function rawApply(mutation, scope) {
|
|
4326
|
+
const prepared = await prepareMutation(mutation, scope);
|
|
4327
|
+
if (!prepared.ok) {
|
|
4328
|
+
return prepared.rejection;
|
|
4329
|
+
}
|
|
4330
|
+
return commitOperation(prepared.op, mutation.collection);
|
|
4331
|
+
}
|
|
4332
|
+
async function runConditionalViaStore(storeApplyConditional, spec, options) {
|
|
4333
|
+
const scope = options?.scope;
|
|
4334
|
+
const mutations = [];
|
|
4335
|
+
const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
|
|
4336
|
+
if (hasTargetUpdate) {
|
|
4337
|
+
mutations.push({
|
|
4338
|
+
collection: spec.collection,
|
|
4339
|
+
type: "update",
|
|
4340
|
+
recordId: spec.id,
|
|
4341
|
+
data: spec.update
|
|
4342
|
+
});
|
|
4343
|
+
}
|
|
4344
|
+
if (spec.also) {
|
|
4345
|
+
mutations.push(...spec.also);
|
|
4346
|
+
}
|
|
4347
|
+
let result;
|
|
4348
|
+
try {
|
|
4349
|
+
result = await storeApplyConditional({
|
|
4350
|
+
target: { collection: spec.collection, id: spec.id },
|
|
4351
|
+
...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
|
|
4352
|
+
admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
|
|
4353
|
+
buildOperations: async (current, context) => {
|
|
4354
|
+
const ops = [];
|
|
4355
|
+
for (let i = 0; i < mutations.length; i++) {
|
|
4356
|
+
const mutation = mutations[i];
|
|
4357
|
+
const useLockedCurrent = i === 0 && hasTargetUpdate;
|
|
4358
|
+
const op = await buildRouteOperation(
|
|
4359
|
+
store,
|
|
4360
|
+
mutation,
|
|
4361
|
+
useLockedCurrent ? { record: current } : void 0,
|
|
4362
|
+
context.clock
|
|
4363
|
+
);
|
|
4364
|
+
if (scope && !operationMatchesScopes(op, scope)) {
|
|
4365
|
+
throw new RouteMutationError(
|
|
4366
|
+
"SCOPE_VIOLATION",
|
|
4367
|
+
`Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
4368
|
+
);
|
|
4369
|
+
}
|
|
4370
|
+
ops.push(op);
|
|
4371
|
+
}
|
|
4372
|
+
return ops;
|
|
4373
|
+
}
|
|
4374
|
+
});
|
|
4375
|
+
} catch (error) {
|
|
4376
|
+
if (error instanceof RouteMutationError) {
|
|
4377
|
+
return {
|
|
4378
|
+
ok: false,
|
|
4379
|
+
code: error.code,
|
|
4380
|
+
message: error.message,
|
|
4381
|
+
retriable: isRetriableRejection(error.code)
|
|
4382
|
+
};
|
|
4383
|
+
}
|
|
4384
|
+
throw error;
|
|
4385
|
+
}
|
|
4386
|
+
if (!result.admitted) {
|
|
4387
|
+
return {
|
|
4388
|
+
ok: false,
|
|
4389
|
+
code: spec.reject?.code ?? "CONDITION_NOT_MET",
|
|
4390
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
4391
|
+
retriable: false
|
|
4392
|
+
};
|
|
4393
|
+
}
|
|
4394
|
+
if (result.idempotent) {
|
|
4395
|
+
const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
|
|
4396
|
+
return { ok: true, idempotent: true, operations: [], records: records2 };
|
|
4397
|
+
}
|
|
4398
|
+
server.relayServerOperations(result.applied);
|
|
4399
|
+
const records = [];
|
|
4400
|
+
for (const op of result.applied) {
|
|
4401
|
+
records.push(await store.findRecord(op.collection, op.recordId));
|
|
4402
|
+
}
|
|
4403
|
+
return { ok: true, idempotent: false, operations: result.applied, records };
|
|
4404
|
+
}
|
|
4405
|
+
return {
|
|
4406
|
+
apply(mutation, options) {
|
|
4407
|
+
return serialize(() => rawApply(mutation, options?.scope));
|
|
4408
|
+
},
|
|
4409
|
+
applyConditional(spec, options) {
|
|
4410
|
+
const storeApplyConditional = store.applyConditional;
|
|
4411
|
+
if (storeApplyConditional) {
|
|
4412
|
+
return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
|
|
4413
|
+
}
|
|
4414
|
+
return serialize(async () => {
|
|
4415
|
+
if (spec.idempotencyKey) {
|
|
4416
|
+
const existing = await store.findRecord(
|
|
4417
|
+
spec.idempotencyKey.collection,
|
|
4418
|
+
spec.idempotencyKey.id
|
|
4419
|
+
);
|
|
4420
|
+
if (existing) {
|
|
4421
|
+
return { ok: true, idempotent: true, operations: [], records: [existing] };
|
|
4422
|
+
}
|
|
4423
|
+
}
|
|
4424
|
+
if (spec.if && Object.keys(spec.if).length > 0) {
|
|
4425
|
+
const target = await store.findRecord(spec.collection, spec.id);
|
|
4426
|
+
if (!evaluateRoutePredicate(target, spec.if)) {
|
|
4427
|
+
const code = spec.reject?.code ?? "CONDITION_NOT_MET";
|
|
4428
|
+
return {
|
|
4429
|
+
ok: false,
|
|
4430
|
+
code,
|
|
4431
|
+
message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
|
|
4432
|
+
retriable: false
|
|
4433
|
+
};
|
|
4434
|
+
}
|
|
4435
|
+
}
|
|
4436
|
+
const mutations = [];
|
|
4437
|
+
if (spec.update !== void 0 && spec.update !== null) {
|
|
4438
|
+
mutations.push({
|
|
4439
|
+
collection: spec.collection,
|
|
4440
|
+
type: "update",
|
|
4441
|
+
recordId: spec.id,
|
|
4442
|
+
data: spec.update
|
|
4443
|
+
});
|
|
4444
|
+
}
|
|
4445
|
+
if (spec.also) {
|
|
4446
|
+
mutations.push(...spec.also);
|
|
4447
|
+
}
|
|
4448
|
+
const prepared = [];
|
|
4449
|
+
for (const mutation of mutations) {
|
|
4450
|
+
const p = await prepareMutation(mutation, options?.scope);
|
|
4451
|
+
if (!p.ok) {
|
|
4452
|
+
return p.rejection;
|
|
4453
|
+
}
|
|
4454
|
+
prepared.push({ op: p.op, collection: mutation.collection });
|
|
4455
|
+
}
|
|
4456
|
+
const operations2 = [];
|
|
4457
|
+
const records = [];
|
|
4458
|
+
for (const { op, collection } of prepared) {
|
|
4459
|
+
const result = await commitOperation(op, collection);
|
|
4460
|
+
if (!result.ok) {
|
|
4461
|
+
return {
|
|
4462
|
+
ok: false,
|
|
4463
|
+
code: result.code,
|
|
4464
|
+
message: result.message,
|
|
4465
|
+
retriable: result.retriable
|
|
4466
|
+
};
|
|
4467
|
+
}
|
|
4468
|
+
operations2.push(result.operation);
|
|
4469
|
+
records.push(result.record);
|
|
4470
|
+
}
|
|
4471
|
+
return { ok: true, idempotent: false, operations: operations2, records };
|
|
4472
|
+
});
|
|
4473
|
+
},
|
|
4474
|
+
async query(collection, options) {
|
|
4475
|
+
const { scope, ...queryOptions } = options ?? {};
|
|
4476
|
+
const records = await store.queryCollection(collection, queryOptions);
|
|
4477
|
+
if (!scope) {
|
|
4478
|
+
return records;
|
|
3193
4479
|
}
|
|
3194
|
-
|
|
4480
|
+
return records.filter((record) => recordMatchesScope(collection, record, scope));
|
|
4481
|
+
},
|
|
4482
|
+
async findById(collection, id, options) {
|
|
4483
|
+
const record = await store.findRecord(collection, id);
|
|
4484
|
+
if (!record) {
|
|
4485
|
+
return null;
|
|
4486
|
+
}
|
|
4487
|
+
if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
|
|
4488
|
+
return null;
|
|
4489
|
+
}
|
|
4490
|
+
return record;
|
|
3195
4491
|
}
|
|
3196
|
-
}
|
|
3197
|
-
}
|
|
4492
|
+
};
|
|
4493
|
+
}
|
|
3198
4494
|
|
|
3199
4495
|
// src/server/kora-sync-server.ts
|
|
3200
4496
|
var DEFAULT_MAX_CONNECTIONS = 0;
|
|
@@ -3202,15 +4498,28 @@ var DEFAULT_BATCH_SIZE2 = 100;
|
|
|
3202
4498
|
var DEFAULT_SCHEMA_VERSION2 = 1;
|
|
3203
4499
|
var DEFAULT_HOST = "0.0.0.0";
|
|
3204
4500
|
var DEFAULT_PATH = "/";
|
|
3205
|
-
var
|
|
4501
|
+
var DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
|
|
4502
|
+
var DEFAULT_DELIVERY_POLL_INTERVAL_MS = 2e3;
|
|
4503
|
+
function validateIntervalOption(name, value) {
|
|
4504
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
4505
|
+
throw new import_core14.SyncError(`${name} must be a non-negative integer number of milliseconds`, {
|
|
4506
|
+
[name]: value
|
|
4507
|
+
});
|
|
4508
|
+
}
|
|
4509
|
+
return value;
|
|
4510
|
+
}
|
|
4511
|
+
var KoraSyncServer = class _KoraSyncServer {
|
|
3206
4512
|
store;
|
|
3207
4513
|
auth;
|
|
3208
4514
|
serializer;
|
|
3209
4515
|
emitter;
|
|
3210
4516
|
maxConnections;
|
|
3211
4517
|
batchSize;
|
|
4518
|
+
relayRetransmitIntervalMs;
|
|
4519
|
+
deliveryPollIntervalMs;
|
|
3212
4520
|
schemaVersion;
|
|
3213
4521
|
supportedSchemaVersions;
|
|
4522
|
+
operationTransforms;
|
|
3214
4523
|
port;
|
|
3215
4524
|
host;
|
|
3216
4525
|
path;
|
|
@@ -3220,24 +4529,63 @@ var KoraSyncServer = class {
|
|
|
3220
4529
|
yjsDocRelay = new YjsDocRelay();
|
|
3221
4530
|
blobChunkRelay;
|
|
3222
4531
|
persistBlobChunk;
|
|
4532
|
+
maxOperationBytes;
|
|
4533
|
+
maxOpsPerMinute;
|
|
4534
|
+
validateOperation;
|
|
4535
|
+
koraContext;
|
|
3223
4536
|
sessions = /* @__PURE__ */ new Map();
|
|
3224
4537
|
httpClients = /* @__PURE__ */ new Map();
|
|
3225
4538
|
httpSessionToClient = /* @__PURE__ */ new Map();
|
|
3226
|
-
|
|
4539
|
+
// Informational value reported by getStatus(). Keep in sync with the
|
|
4540
|
+
// @korajs/server package version on release; it is not used for protocol negotiation.
|
|
4541
|
+
serverVersion = "1.0.0-beta.0";
|
|
3227
4542
|
wsServer = null;
|
|
3228
4543
|
running = false;
|
|
4544
|
+
/**
|
|
4545
|
+
* Periodic tick that retransmits relay batches connected clients have not
|
|
4546
|
+
* acknowledged, so a relay dropped by a lossy transport is redelivered instead of
|
|
4547
|
+
* leaving that client with a permanent version-vector gap (a lost operation).
|
|
4548
|
+
*/
|
|
4549
|
+
relayRetransmitTimer = null;
|
|
4550
|
+
deliveryPollTimer = null;
|
|
4551
|
+
lastObservedDeliverySequence = 0;
|
|
4552
|
+
deliveryPollInFlight = false;
|
|
4553
|
+
/**
|
|
4554
|
+
* Relay operations a client never acknowledged before it disconnected, buffered by
|
|
4555
|
+
* client node id so they can be redelivered on its next connection. Bounded per
|
|
4556
|
+
* node and expired by age so a client that never returns cannot grow this forever.
|
|
4557
|
+
*/
|
|
4558
|
+
orphanedRelaysByNode = /* @__PURE__ */ new Map();
|
|
4559
|
+
static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
|
|
4560
|
+
static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
|
|
3229
4561
|
constructor(config) {
|
|
3230
4562
|
this.store = config.store;
|
|
4563
|
+
const storeSchemaVersion = this.store.getSchema()?.version;
|
|
4564
|
+
if (config.schemaVersion !== void 0 && storeSchemaVersion !== void 0 && config.schemaVersion !== storeSchemaVersion) {
|
|
4565
|
+
throw new import_core14.SyncError("Sync schema version does not match the configured store schema.", {
|
|
4566
|
+
syncSchemaVersion: config.schemaVersion,
|
|
4567
|
+
storeSchemaVersion
|
|
4568
|
+
});
|
|
4569
|
+
}
|
|
3231
4570
|
this.auth = config.auth ?? null;
|
|
3232
4571
|
this.serializer = config.serializer ?? new import_sync5.JsonMessageSerializer();
|
|
3233
4572
|
this.emitter = config.emitter ?? null;
|
|
3234
4573
|
this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
3235
4574
|
this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
|
|
3236
|
-
this.
|
|
4575
|
+
this.relayRetransmitIntervalMs = validateIntervalOption(
|
|
4576
|
+
"relayRetransmitIntervalMs",
|
|
4577
|
+
config.relayRetransmitIntervalMs ?? DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS
|
|
4578
|
+
);
|
|
4579
|
+
this.deliveryPollIntervalMs = validateIntervalOption(
|
|
4580
|
+
"deliveryPollIntervalMs",
|
|
4581
|
+
config.deliveryPollIntervalMs ?? DEFAULT_DELIVERY_POLL_INTERVAL_MS
|
|
4582
|
+
);
|
|
4583
|
+
this.schemaVersion = config.schemaVersion ?? storeSchemaVersion ?? DEFAULT_SCHEMA_VERSION2;
|
|
3237
4584
|
this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
|
|
3238
4585
|
min: this.schemaVersion,
|
|
3239
4586
|
max: this.schemaVersion
|
|
3240
4587
|
};
|
|
4588
|
+
this.operationTransforms = config.operationTransforms ?? [];
|
|
3241
4589
|
this.port = config.port;
|
|
3242
4590
|
this.host = config.host ?? DEFAULT_HOST;
|
|
3243
4591
|
this.path = config.path ?? DEFAULT_PATH;
|
|
@@ -3246,10 +4594,116 @@ var KoraSyncServer = class {
|
|
|
3246
4594
|
this.metrics.setSchemaVersion(this.schemaVersion);
|
|
3247
4595
|
this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
|
|
3248
4596
|
this.persistBlobChunk = config.persistBlobChunk ?? null;
|
|
4597
|
+
this.maxOperationBytes = config.maxOperationBytes;
|
|
4598
|
+
this.maxOpsPerMinute = config.maxOpsPerMinute;
|
|
4599
|
+
this.validateOperation = config.validateOperation;
|
|
4600
|
+
this.koraContext = createRouteContext(this, this.store);
|
|
3249
4601
|
if (!this.emitter) {
|
|
3250
4602
|
this.emitter = new import_internal2.SimpleEventEmitter();
|
|
3251
4603
|
}
|
|
3252
4604
|
}
|
|
4605
|
+
ensureBackgroundTimersStarted() {
|
|
4606
|
+
if (this.relayRetransmitIntervalMs > 0 && !this.relayRetransmitTimer) {
|
|
4607
|
+
this.relayRetransmitTimer = setInterval(() => {
|
|
4608
|
+
this.retransmitPendingRelays(this.relayRetransmitIntervalMs);
|
|
4609
|
+
}, this.relayRetransmitIntervalMs);
|
|
4610
|
+
this.relayRetransmitTimer.unref?.();
|
|
4611
|
+
}
|
|
4612
|
+
if (this.deliveryPollIntervalMs > 0 && !this.deliveryPollTimer) {
|
|
4613
|
+
this.deliveryPollTimer = setInterval(() => {
|
|
4614
|
+
void this.pollDeliveryLog();
|
|
4615
|
+
}, this.deliveryPollIntervalMs);
|
|
4616
|
+
this.deliveryPollTimer.unref?.();
|
|
4617
|
+
}
|
|
4618
|
+
}
|
|
4619
|
+
/**
|
|
4620
|
+
* Retransmit relay batches that connected clients have not acknowledged within
|
|
4621
|
+
* `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
|
|
4622
|
+
* already-applied operation is harmless (clients dedup by content-addressed id).
|
|
4623
|
+
*/
|
|
4624
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
4625
|
+
for (const session of this.sessions.values()) {
|
|
4626
|
+
session.retransmitPendingRelays(staleMs);
|
|
4627
|
+
}
|
|
4628
|
+
this.expireOrphanedRelays();
|
|
4629
|
+
}
|
|
4630
|
+
/**
|
|
4631
|
+
* Check whether the authoritative delivery log advanced without passing through
|
|
4632
|
+
* this server instance, then wake delivery-watermark sessions to scan from their
|
|
4633
|
+
* own acknowledged cursors. Session-level filtering remains the only fan-out path.
|
|
4634
|
+
*/
|
|
4635
|
+
async pollDeliveryLog() {
|
|
4636
|
+
if (this.deliveryPollInFlight) return;
|
|
4637
|
+
this.deliveryPollInFlight = true;
|
|
4638
|
+
try {
|
|
4639
|
+
const maxDeliverySequence = await this.store.getMaxDeliverySequence();
|
|
4640
|
+
if (maxDeliverySequence <= this.lastObservedDeliverySequence) {
|
|
4641
|
+
for (const session of this.sessions.values()) {
|
|
4642
|
+
session.pushDeliveryStreamIfSupported(this.deliveryPollIntervalMs, {
|
|
4643
|
+
trackStall: true
|
|
4644
|
+
});
|
|
4645
|
+
}
|
|
4646
|
+
return;
|
|
4647
|
+
}
|
|
4648
|
+
this.lastObservedDeliverySequence = maxDeliverySequence;
|
|
4649
|
+
for (const session of this.sessions.values()) {
|
|
4650
|
+
session.pushDeliveryStreamIfSupported(0);
|
|
4651
|
+
}
|
|
4652
|
+
} catch (error) {
|
|
4653
|
+
this.logger.log({
|
|
4654
|
+
timestamp: Date.now(),
|
|
4655
|
+
level: "warn",
|
|
4656
|
+
event: "delivery_poll.failed",
|
|
4657
|
+
details: { error: error instanceof Error ? error.message : String(error) }
|
|
4658
|
+
});
|
|
4659
|
+
} finally {
|
|
4660
|
+
this.deliveryPollInFlight = false;
|
|
4661
|
+
}
|
|
4662
|
+
}
|
|
4663
|
+
/**
|
|
4664
|
+
* Buffer relay operations a disconnected client never acknowledged, keyed by its
|
|
4665
|
+
* node id, deduped by operation id and bounded by count. Redelivered when the
|
|
4666
|
+
* client reconnects (see {@link takeOrphanedRelays}).
|
|
4667
|
+
*/
|
|
4668
|
+
bufferOrphanedRelays(nodeId, ops) {
|
|
4669
|
+
if (ops.length === 0) return;
|
|
4670
|
+
const existing = this.orphanedRelaysByNode.get(nodeId);
|
|
4671
|
+
const merged = existing ? existing.ops : [];
|
|
4672
|
+
const seen = new Set(merged.map((op) => op.id));
|
|
4673
|
+
for (const op of ops) {
|
|
4674
|
+
if (!seen.has(op.id)) {
|
|
4675
|
+
merged.push(op);
|
|
4676
|
+
seen.add(op.id);
|
|
4677
|
+
}
|
|
4678
|
+
}
|
|
4679
|
+
const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
|
|
4680
|
+
this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
|
|
4681
|
+
}
|
|
4682
|
+
/** Remove and return a node's buffered orphaned relay operations, if any. */
|
|
4683
|
+
takeOrphanedRelays(nodeId) {
|
|
4684
|
+
const entry = this.orphanedRelaysByNode.get(nodeId);
|
|
4685
|
+
if (!entry) return [];
|
|
4686
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4687
|
+
return entry.ops;
|
|
4688
|
+
}
|
|
4689
|
+
/** Drop buffered orphaned relays older than the TTL (client never returned). */
|
|
4690
|
+
expireOrphanedRelays() {
|
|
4691
|
+
if (this.orphanedRelaysByNode.size === 0) return;
|
|
4692
|
+
const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
|
|
4693
|
+
for (const [nodeId, entry] of this.orphanedRelaysByNode) {
|
|
4694
|
+
if (entry.bufferedAtMs <= cutoff) {
|
|
4695
|
+
this.orphanedRelaysByNode.delete(nodeId);
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4699
|
+
/**
|
|
4700
|
+
* The trusted, scoped data-plane context (`apply` / `query` / `findById`).
|
|
4701
|
+
* Shared with the production server so HTTP routes, the operation validator,
|
|
4702
|
+
* and `server.kora` are all the same object over the same pipeline.
|
|
4703
|
+
*/
|
|
4704
|
+
getKoraContext() {
|
|
4705
|
+
return this.koraContext;
|
|
4706
|
+
}
|
|
3253
4707
|
/**
|
|
3254
4708
|
* Subscribe to session-level events for metrics collection and logging.
|
|
3255
4709
|
* Called when a new session is created.
|
|
@@ -3298,6 +4752,21 @@ var KoraSyncServer = class {
|
|
|
3298
4752
|
details: { reason: event.reason }
|
|
3299
4753
|
});
|
|
3300
4754
|
});
|
|
4755
|
+
sessionEmitter.on("sync:schema-mismatch", (event) => {
|
|
4756
|
+
this.logger.log({
|
|
4757
|
+
timestamp: Date.now(),
|
|
4758
|
+
level: "warn",
|
|
4759
|
+
event: "session.schema_mismatch",
|
|
4760
|
+
sessionId,
|
|
4761
|
+
details: {
|
|
4762
|
+
clientSchemaVersion: event.clientSchemaVersion,
|
|
4763
|
+
serverSchemaVersion: event.serverSchemaVersion,
|
|
4764
|
+
supportedMin: event.supportedMin,
|
|
4765
|
+
supportedMax: event.supportedMax,
|
|
4766
|
+
reason: event.reason
|
|
4767
|
+
}
|
|
4768
|
+
});
|
|
4769
|
+
});
|
|
3301
4770
|
}
|
|
3302
4771
|
/**
|
|
3303
4772
|
* Get the metrics collector for external access (e.g., HTTP endpoints).
|
|
@@ -3318,10 +4787,10 @@ var KoraSyncServer = class {
|
|
|
3318
4787
|
*/
|
|
3319
4788
|
async start(wsServerImpl) {
|
|
3320
4789
|
if (this.running) {
|
|
3321
|
-
throw new
|
|
4790
|
+
throw new import_core14.SyncError("Server is already running", { port: this.port });
|
|
3322
4791
|
}
|
|
3323
4792
|
if (!wsServerImpl && this.port === void 0) {
|
|
3324
|
-
throw new
|
|
4793
|
+
throw new import_core14.SyncError(
|
|
3325
4794
|
"Port is required for standalone mode. Provide port in config or use handleConnection() for attach mode.",
|
|
3326
4795
|
{}
|
|
3327
4796
|
);
|
|
@@ -3367,6 +4836,15 @@ var KoraSyncServer = class {
|
|
|
3367
4836
|
event: "server.stopping",
|
|
3368
4837
|
details: { connectedClients: this.sessions.size }
|
|
3369
4838
|
});
|
|
4839
|
+
if (this.relayRetransmitTimer) {
|
|
4840
|
+
clearInterval(this.relayRetransmitTimer);
|
|
4841
|
+
this.relayRetransmitTimer = null;
|
|
4842
|
+
}
|
|
4843
|
+
if (this.deliveryPollTimer) {
|
|
4844
|
+
clearInterval(this.deliveryPollTimer);
|
|
4845
|
+
this.deliveryPollTimer = null;
|
|
4846
|
+
}
|
|
4847
|
+
this.orphanedRelaysByNode.clear();
|
|
3370
4848
|
this.awarenessRelay.clear();
|
|
3371
4849
|
this.yjsDocRelay.clear();
|
|
3372
4850
|
this.blobChunkRelay.clear();
|
|
@@ -3431,7 +4909,7 @@ var KoraSyncServer = class {
|
|
|
3431
4909
|
if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
|
|
3432
4910
|
transport.send({
|
|
3433
4911
|
type: "error",
|
|
3434
|
-
messageId: (0,
|
|
4912
|
+
messageId: (0, import_core14.generateUUIDv7)(),
|
|
3435
4913
|
code: "MAX_CONNECTIONS",
|
|
3436
4914
|
message: `Server has reached maximum connections (${this.maxConnections})`,
|
|
3437
4915
|
retriable: true
|
|
@@ -3444,12 +4922,13 @@ var KoraSyncServer = class {
|
|
|
3444
4922
|
event: "connection.rejected",
|
|
3445
4923
|
details: { reason: "max_connections", max: this.maxConnections }
|
|
3446
4924
|
});
|
|
3447
|
-
throw new
|
|
4925
|
+
throw new import_core14.SyncError("Maximum connections reached", {
|
|
3448
4926
|
current: this.sessions.size,
|
|
3449
4927
|
max: this.maxConnections
|
|
3450
4928
|
});
|
|
3451
4929
|
}
|
|
3452
|
-
|
|
4930
|
+
this.ensureBackgroundTimersStarted();
|
|
4931
|
+
const sessionId = (0, import_core14.generateUUIDv7)();
|
|
3453
4932
|
this.metrics.recordConnection(sessionId);
|
|
3454
4933
|
const sessionEmitter = new import_internal2.SimpleEventEmitter();
|
|
3455
4934
|
sessionEmitter.on("sync:connected", (event) => {
|
|
@@ -3495,6 +4974,33 @@ var KoraSyncServer = class {
|
|
|
3495
4974
|
sessionId
|
|
3496
4975
|
});
|
|
3497
4976
|
});
|
|
4977
|
+
sessionEmitter.on("sync:schema-mismatch", (event) => {
|
|
4978
|
+
this.logger.log({
|
|
4979
|
+
timestamp: Date.now(),
|
|
4980
|
+
level: "warn",
|
|
4981
|
+
event: "session.schema_mismatch",
|
|
4982
|
+
sessionId,
|
|
4983
|
+
details: {
|
|
4984
|
+
clientSchemaVersion: event.clientSchemaVersion,
|
|
4985
|
+
serverSchemaVersion: event.serverSchemaVersion,
|
|
4986
|
+
supportedMin: event.supportedMin,
|
|
4987
|
+
supportedMax: event.supportedMax,
|
|
4988
|
+
reason: event.reason
|
|
4989
|
+
}
|
|
4990
|
+
});
|
|
4991
|
+
});
|
|
4992
|
+
sessionEmitter.on("sync:delivery-stalled", (event) => {
|
|
4993
|
+
this.logger.log({
|
|
4994
|
+
timestamp: Date.now(),
|
|
4995
|
+
level: "warn",
|
|
4996
|
+
event: "session.delivery_stalled",
|
|
4997
|
+
sessionId,
|
|
4998
|
+
details: {
|
|
4999
|
+
watermark: event.watermark,
|
|
5000
|
+
repeatCount: event.repeatCount
|
|
5001
|
+
}
|
|
5002
|
+
});
|
|
5003
|
+
});
|
|
3498
5004
|
const session = new ClientSession({
|
|
3499
5005
|
sessionId,
|
|
3500
5006
|
transport,
|
|
@@ -3502,9 +5008,11 @@ var KoraSyncServer = class {
|
|
|
3502
5008
|
auth: this.auth ?? void 0,
|
|
3503
5009
|
serializer: this.serializer,
|
|
3504
5010
|
emitter: sessionEmitter,
|
|
5011
|
+
logger: this.logger,
|
|
3505
5012
|
batchSize: this.batchSize,
|
|
3506
5013
|
schemaVersion: this.schemaVersion,
|
|
3507
5014
|
supportedSchemaVersions: this.supportedSchemaVersions,
|
|
5015
|
+
operationTransforms: this.operationTransforms,
|
|
3508
5016
|
onRelay: (sourceSessionId, operations2) => {
|
|
3509
5017
|
this.handleRelay(sourceSessionId, operations2);
|
|
3510
5018
|
},
|
|
@@ -3521,9 +5029,18 @@ var KoraSyncServer = class {
|
|
|
3521
5029
|
this.blobChunkRelay.handleResponse(sourceSessionId, message);
|
|
3522
5030
|
},
|
|
3523
5031
|
...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
|
|
5032
|
+
// Only forward when configured, so an unset server value leaves the
|
|
5033
|
+
// session on its own documented default rather than `undefined`.
|
|
5034
|
+
...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
|
|
5035
|
+
...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
|
|
5036
|
+
...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
|
|
3524
5037
|
onClose: (sid) => {
|
|
3525
5038
|
this.handleSessionClose(sid);
|
|
3526
|
-
}
|
|
5039
|
+
},
|
|
5040
|
+
onOrphanedRelays: (nodeId, ops) => {
|
|
5041
|
+
this.bufferOrphanedRelays(nodeId, ops);
|
|
5042
|
+
},
|
|
5043
|
+
takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
|
|
3527
5044
|
});
|
|
3528
5045
|
this.sessions.set(sessionId, session);
|
|
3529
5046
|
this.yjsDocRelay.addClient(sessionId, transport);
|
|
@@ -3583,6 +5100,22 @@ var KoraSyncServer = class {
|
|
|
3583
5100
|
}
|
|
3584
5101
|
return result;
|
|
3585
5102
|
}
|
|
5103
|
+
/**
|
|
5104
|
+
* Relay already-applied, server-originated operations to every connected client.
|
|
5105
|
+
* Used by the conditional route apply, which commits its operations atomically
|
|
5106
|
+
* through the store (bypassing {@link applyLocalOperation}) and then fans them
|
|
5107
|
+
* out. Each session still enforces its own per-scope visibility filter.
|
|
5108
|
+
*
|
|
5109
|
+
* @param operations - Operations that have already been committed to the store
|
|
5110
|
+
*/
|
|
5111
|
+
relayServerOperations(operations2) {
|
|
5112
|
+
if (operations2.length === 0) {
|
|
5113
|
+
return;
|
|
5114
|
+
}
|
|
5115
|
+
for (const session of this.sessions.values()) {
|
|
5116
|
+
session.relayOperations(operations2);
|
|
5117
|
+
}
|
|
5118
|
+
}
|
|
3586
5119
|
/**
|
|
3587
5120
|
* Collect every blob reference still reachable from live records on the server.
|
|
3588
5121
|
*
|
|
@@ -3607,7 +5140,7 @@ var KoraSyncServer = class {
|
|
|
3607
5140
|
const records = await this.store.materializeCollection(name);
|
|
3608
5141
|
for (const record of records) {
|
|
3609
5142
|
for (const value of Object.values(record)) {
|
|
3610
|
-
if ((0,
|
|
5143
|
+
if ((0, import_core14.isBlobRef)(value)) {
|
|
3611
5144
|
refs.push(value);
|
|
3612
5145
|
}
|
|
3613
5146
|
}
|
|
@@ -3787,162 +5320,6 @@ function createKoraServer(config) {
|
|
|
3787
5320
|
|
|
3788
5321
|
// src/server/production-server.ts
|
|
3789
5322
|
init_cjs_shims();
|
|
3790
|
-
|
|
3791
|
-
// src/server/route-context.ts
|
|
3792
|
-
init_cjs_shims();
|
|
3793
|
-
var import_core13 = require("@korajs/core");
|
|
3794
|
-
function recordMatchesScope(collection, record, scope) {
|
|
3795
|
-
const collectionScope = scope[collection];
|
|
3796
|
-
if (!collectionScope) {
|
|
3797
|
-
return false;
|
|
3798
|
-
}
|
|
3799
|
-
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
3800
|
-
if (record[field] !== expected) {
|
|
3801
|
-
return false;
|
|
3802
|
-
}
|
|
3803
|
-
}
|
|
3804
|
-
return true;
|
|
3805
|
-
}
|
|
3806
|
-
function pickFields(record, keys) {
|
|
3807
|
-
const picked = {};
|
|
3808
|
-
for (const key of keys) {
|
|
3809
|
-
if (key in record) {
|
|
3810
|
-
picked[key] = record[key];
|
|
3811
|
-
}
|
|
3812
|
-
}
|
|
3813
|
-
return picked;
|
|
3814
|
-
}
|
|
3815
|
-
async function buildRouteOperation(store, mutation) {
|
|
3816
|
-
const nodeId = store.getNodeId();
|
|
3817
|
-
const clock = new import_core13.HybridLogicalClock(nodeId);
|
|
3818
|
-
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
3819
|
-
const sequenceNumber = nextServerSequenceNumber(store);
|
|
3820
|
-
if (mutation.type === "insert") {
|
|
3821
|
-
return (0, import_core13.createOperation)(
|
|
3822
|
-
{
|
|
3823
|
-
nodeId,
|
|
3824
|
-
type: "insert",
|
|
3825
|
-
collection: mutation.collection,
|
|
3826
|
-
recordId: mutation.recordId ?? (0, import_core13.generateUUIDv7)(),
|
|
3827
|
-
data: mutation.data ?? {},
|
|
3828
|
-
previousData: null,
|
|
3829
|
-
sequenceNumber,
|
|
3830
|
-
causalDeps: [],
|
|
3831
|
-
schemaVersion
|
|
3832
|
-
},
|
|
3833
|
-
clock
|
|
3834
|
-
);
|
|
3835
|
-
}
|
|
3836
|
-
if (!mutation.recordId) {
|
|
3837
|
-
throw new RouteMutationError(
|
|
3838
|
-
"MISSING_RECORD_ID",
|
|
3839
|
-
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
3840
|
-
);
|
|
3841
|
-
}
|
|
3842
|
-
const current = await store.findRecord(mutation.collection, mutation.recordId);
|
|
3843
|
-
if (mutation.type === "update") {
|
|
3844
|
-
const data = mutation.data ?? {};
|
|
3845
|
-
return (0, import_core13.createOperation)(
|
|
3846
|
-
{
|
|
3847
|
-
nodeId,
|
|
3848
|
-
type: "update",
|
|
3849
|
-
collection: mutation.collection,
|
|
3850
|
-
recordId: mutation.recordId,
|
|
3851
|
-
data,
|
|
3852
|
-
previousData: current ? pickFields(current, Object.keys(data)) : {},
|
|
3853
|
-
sequenceNumber,
|
|
3854
|
-
causalDeps: [],
|
|
3855
|
-
schemaVersion
|
|
3856
|
-
},
|
|
3857
|
-
clock
|
|
3858
|
-
);
|
|
3859
|
-
}
|
|
3860
|
-
return (0, import_core13.createOperation)(
|
|
3861
|
-
{
|
|
3862
|
-
nodeId,
|
|
3863
|
-
type: "delete",
|
|
3864
|
-
collection: mutation.collection,
|
|
3865
|
-
recordId: mutation.recordId,
|
|
3866
|
-
data: null,
|
|
3867
|
-
previousData: current ? { ...current } : null,
|
|
3868
|
-
sequenceNumber,
|
|
3869
|
-
causalDeps: [],
|
|
3870
|
-
schemaVersion
|
|
3871
|
-
},
|
|
3872
|
-
clock
|
|
3873
|
-
);
|
|
3874
|
-
}
|
|
3875
|
-
var RouteMutationError = class extends Error {
|
|
3876
|
-
constructor(code, message) {
|
|
3877
|
-
super(message);
|
|
3878
|
-
this.code = code;
|
|
3879
|
-
this.name = "RouteMutationError";
|
|
3880
|
-
}
|
|
3881
|
-
code;
|
|
3882
|
-
};
|
|
3883
|
-
function createRouteContext(server, store) {
|
|
3884
|
-
let mutationTail = Promise.resolve();
|
|
3885
|
-
function serialize(work) {
|
|
3886
|
-
const run = mutationTail.then(work, work);
|
|
3887
|
-
mutationTail = run.then(
|
|
3888
|
-
() => void 0,
|
|
3889
|
-
() => void 0
|
|
3890
|
-
);
|
|
3891
|
-
return run;
|
|
3892
|
-
}
|
|
3893
|
-
return {
|
|
3894
|
-
apply(mutation, options) {
|
|
3895
|
-
return serialize(async () => {
|
|
3896
|
-
let op;
|
|
3897
|
-
try {
|
|
3898
|
-
op = await buildRouteOperation(store, mutation);
|
|
3899
|
-
} catch (error) {
|
|
3900
|
-
if (error instanceof RouteMutationError) {
|
|
3901
|
-
return { ok: false, code: error.code, message: error.message };
|
|
3902
|
-
}
|
|
3903
|
-
throw error;
|
|
3904
|
-
}
|
|
3905
|
-
if (options?.scope && !operationMatchesScopes(op, options.scope)) {
|
|
3906
|
-
return {
|
|
3907
|
-
ok: false,
|
|
3908
|
-
code: "SCOPE_VIOLATION",
|
|
3909
|
-
message: `Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
3910
|
-
};
|
|
3911
|
-
}
|
|
3912
|
-
const result = await server.applyLocalOperation(op);
|
|
3913
|
-
if (result.result !== "applied") {
|
|
3914
|
-
return {
|
|
3915
|
-
ok: false,
|
|
3916
|
-
code: result.rejection?.code ?? "NOT_APPLIED",
|
|
3917
|
-
message: result.rejection?.message ?? `Operation on "${mutation.collection}" was not applied (${result.result}).`
|
|
3918
|
-
};
|
|
3919
|
-
}
|
|
3920
|
-
const record = await store.findRecord(mutation.collection, op.recordId);
|
|
3921
|
-
return { ok: true, operation: op, record };
|
|
3922
|
-
});
|
|
3923
|
-
},
|
|
3924
|
-
async query(collection, options) {
|
|
3925
|
-
const { scope, ...queryOptions } = options ?? {};
|
|
3926
|
-
const records = await store.queryCollection(collection, queryOptions);
|
|
3927
|
-
if (!scope) {
|
|
3928
|
-
return records;
|
|
3929
|
-
}
|
|
3930
|
-
return records.filter((record) => recordMatchesScope(collection, record, scope));
|
|
3931
|
-
},
|
|
3932
|
-
async findById(collection, id, options) {
|
|
3933
|
-
const record = await store.findRecord(collection, id);
|
|
3934
|
-
if (!record) {
|
|
3935
|
-
return null;
|
|
3936
|
-
}
|
|
3937
|
-
if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
|
|
3938
|
-
return null;
|
|
3939
|
-
}
|
|
3940
|
-
return record;
|
|
3941
|
-
}
|
|
3942
|
-
};
|
|
3943
|
-
}
|
|
3944
|
-
|
|
3945
|
-
// src/server/production-server.ts
|
|
3946
5323
|
var MIME_TYPES = {
|
|
3947
5324
|
".html": "text/html",
|
|
3948
5325
|
".js": "text/javascript",
|
|
@@ -3961,12 +5338,13 @@ function createProductionServer(config) {
|
|
|
3961
5338
|
const port = config.port ?? (Number(process.env.PORT) || 3001);
|
|
3962
5339
|
const staticDir = config.staticDir ?? "./dist";
|
|
3963
5340
|
const syncPath = config.syncPath ?? "/kora-sync";
|
|
5341
|
+
const crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy ?? "credentialless";
|
|
3964
5342
|
const syncServer = new KoraSyncServer({
|
|
3965
5343
|
store: config.store,
|
|
3966
5344
|
enableDashboard: true,
|
|
3967
5345
|
...config.syncOptions
|
|
3968
5346
|
});
|
|
3969
|
-
const routeContext =
|
|
5347
|
+
const routeContext = syncServer.getKoraContext();
|
|
3970
5348
|
let httpServer = null;
|
|
3971
5349
|
function getOperationalToken(kind) {
|
|
3972
5350
|
const auth = config.operationalAuth;
|
|
@@ -4106,6 +5484,10 @@ function createProductionServer(config) {
|
|
|
4106
5484
|
res.end(JSON.stringify(result.body ?? null));
|
|
4107
5485
|
}
|
|
4108
5486
|
return {
|
|
5487
|
+
kora: routeContext,
|
|
5488
|
+
getLiveBlobRefs() {
|
|
5489
|
+
return syncServer.getLiveBlobRefs();
|
|
5490
|
+
},
|
|
4109
5491
|
async start() {
|
|
4110
5492
|
const { createServer } = await import("http");
|
|
4111
5493
|
const { createReadStream, existsSync, statSync } = await import("fs");
|
|
@@ -4127,7 +5509,7 @@ function createProductionServer(config) {
|
|
|
4127
5509
|
});
|
|
4128
5510
|
async function handleRequest(req, res) {
|
|
4129
5511
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
4130
|
-
res.setHeader("Cross-Origin-Embedder-Policy",
|
|
5512
|
+
res.setHeader("Cross-Origin-Embedder-Policy", crossOriginEmbedderPolicy);
|
|
4131
5513
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
4132
5514
|
if (url.pathname === "/health") {
|
|
4133
5515
|
const status = await syncServer.getStatus();
|
|
@@ -4301,8 +5683,13 @@ data: ${JSON.stringify(s)}
|
|
|
4301
5683
|
socket.destroy();
|
|
4302
5684
|
}
|
|
4303
5685
|
});
|
|
4304
|
-
return new Promise((resolve2) => {
|
|
5686
|
+
return new Promise((resolve2, reject) => {
|
|
5687
|
+
const onError = (error) => {
|
|
5688
|
+
reject(error);
|
|
5689
|
+
};
|
|
5690
|
+
httpServer?.once("error", onError);
|
|
4305
5691
|
httpServer?.listen(port, "0.0.0.0", () => {
|
|
5692
|
+
httpServer?.off("error", onError);
|
|
4306
5693
|
resolve2(`http://localhost:${port}`);
|
|
4307
5694
|
});
|
|
4308
5695
|
});
|
|
@@ -4398,6 +5785,7 @@ function formatUptime(ms) {
|
|
|
4398
5785
|
0 && (module.exports = {
|
|
4399
5786
|
AwarenessRelay,
|
|
4400
5787
|
ClientSession,
|
|
5788
|
+
DEFAULT_MAX_SCOPE_PREDICATE_VALUES,
|
|
4401
5789
|
HttpServerTransport,
|
|
4402
5790
|
KoraAuthProvider,
|
|
4403
5791
|
KoraSyncServer,
|
|
@@ -4405,6 +5793,7 @@ function formatUptime(ms) {
|
|
|
4405
5793
|
MixedAuthProvider,
|
|
4406
5794
|
NoAuthProvider,
|
|
4407
5795
|
PostgresServerStore,
|
|
5796
|
+
RETRIABLE_REJECTION_CODES,
|
|
4408
5797
|
SqliteServerStore,
|
|
4409
5798
|
TokenAuthProvider,
|
|
4410
5799
|
WsServerTransport,
|
|
@@ -4415,6 +5804,9 @@ function formatUptime(ms) {
|
|
|
4415
5804
|
createPrettyLogger,
|
|
4416
5805
|
createProductionServer,
|
|
4417
5806
|
createSilentLogger,
|
|
4418
|
-
createSqliteServerStore
|
|
5807
|
+
createSqliteServerStore,
|
|
5808
|
+
isRetriableRejection,
|
|
5809
|
+
normalizeScopeMap,
|
|
5810
|
+
operationMatchesScopes
|
|
4419
5811
|
});
|
|
4420
5812
|
//# sourceMappingURL=index.cjs.map
|