@korajs/server 1.0.0-beta.13 → 1.0.0-beta.14

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 CHANGED
@@ -71,14 +71,14 @@ function reportLegacyPair(op, decision, options) {
71
71
  legacyWriter: decision.legacyWriter
72
72
  });
73
73
  }
74
- var import_core6, SEQUENCE_CONFLICT_CODE, SequenceConflictError, UNSTORABLE_VALUE_CODE, UnstorableValueError, SEQUENCE_ENFORCEMENT_EPOCH_KEY, SEQUENCE_PAIRS_BACKFILLED_KEY, NODE_SEQ_UNIQUE_INDEX, SUPERSEDED_NODE_SEQ_UNIQUE_INDEXES, MAX_RESOLUTION_MESSAGE_LENGTH, RELEASED_NODE_OWNER, MAX_SCOPE_SNAPSHOT_STRING_LENGTH;
74
+ var import_core7, SEQUENCE_CONFLICT_CODE, SequenceConflictError, UNSTORABLE_VALUE_CODE, UnstorableValueError, SEQUENCE_ENFORCEMENT_EPOCH_KEY, SEQUENCE_PAIRS_BACKFILLED_KEY, NODE_SEQ_UNIQUE_INDEX, SUPERSEDED_NODE_SEQ_UNIQUE_INDEXES, MAX_RESOLUTION_MESSAGE_LENGTH, RELEASED_NODE_OWNER, MAX_SCOPE_SNAPSHOT_STRING_LENGTH;
75
75
  var init_server_store = __esm({
76
76
  "src/store/server-store.ts"() {
77
77
  "use strict";
78
78
  init_cjs_shims();
79
- import_core6 = require("@korajs/core");
79
+ import_core7 = require("@korajs/core");
80
80
  SEQUENCE_CONFLICT_CODE = "SEQUENCE_CONFLICT";
81
- SequenceConflictError = class extends import_core6.KoraError {
81
+ SequenceConflictError = class extends import_core7.KoraError {
82
82
  constructor(operation, existingOperationId) {
83
83
  super(
84
84
  `Operation "${operation.id}" claims sequence ${String(operation.sequenceNumber)} of node "${operation.nodeId}", which operation "${existingOperationId}" already holds. A node may never reuse a sequence number for different content.`,
@@ -98,7 +98,7 @@ var init_server_store = __esm({
98
98
  existingOperationId;
99
99
  };
100
100
  UNSTORABLE_VALUE_CODE = "UNSTORABLE_VALUE";
101
- UnstorableValueError = class extends import_core6.KoraError {
101
+ UnstorableValueError = class extends import_core7.KoraError {
102
102
  constructor(operation, detail) {
103
103
  super(
104
104
  `Operation "${operation.id}" on ${operation.collection}/${operation.recordId} holds a value this server's database cannot store (${detail}). It is refused; nothing was written.`,
@@ -129,17 +129,17 @@ function isOperationTimestampValid(op, now = Date.now()) {
129
129
  if (!Number.isInteger(wallTime) || wallTime < 0) {
130
130
  return false;
131
131
  }
132
- if (!Number.isInteger(logical) || logical < 0 || logical > import_core7.MAX_LOGICAL) {
132
+ if (!Number.isInteger(logical) || logical < 0 || logical > import_core8.MAX_LOGICAL) {
133
133
  return false;
134
134
  }
135
135
  return wallTime <= now + SERVER_MAX_TIMESTAMP_FUTURE_MS;
136
136
  }
137
- var import_core7, SERVER_MAX_TIMESTAMP_FUTURE_MS;
137
+ var import_core8, SERVER_MAX_TIMESTAMP_FUTURE_MS;
138
138
  var init_operation_validation = __esm({
139
139
  "src/session/operation-validation.ts"() {
140
140
  "use strict";
141
141
  init_cjs_shims();
142
- import_core7 = require("@korajs/core");
142
+ import_core8 = require("@korajs/core");
143
143
  SERVER_MAX_TIMESTAMP_FUTURE_MS = 6e4;
144
144
  }
145
145
  });
@@ -191,19 +191,19 @@ function assertBackupOperationsIngestible(operations2, now = Date.now()) {
191
191
  }
192
192
  if (invalid.length > 0) throw new BackupValidationError(invalid, operations2.length);
193
193
  }
194
- var import_core8, INVALID_TIMESTAMP_CODE, INVALID_SEQUENCE_NUMBER_CODE, INVALID_IDENTIFIER_CODE, UNSTORABLE_IDENTIFIER, BackupValidationError;
194
+ var import_core9, INVALID_TIMESTAMP_CODE, INVALID_SEQUENCE_NUMBER_CODE, INVALID_IDENTIFIER_CODE, UNSTORABLE_IDENTIFIER, BackupValidationError;
195
195
  var init_ingest_validation = __esm({
196
196
  "src/apply/ingest-validation.ts"() {
197
197
  "use strict";
198
198
  init_cjs_shims();
199
- import_core8 = require("@korajs/core");
199
+ import_core9 = require("@korajs/core");
200
200
  init_operation_validation();
201
201
  INVALID_TIMESTAMP_CODE = "INVALID_TIMESTAMP";
202
202
  INVALID_SEQUENCE_NUMBER_CODE = "INVALID_SEQUENCE_NUMBER";
203
203
  INVALID_IDENTIFIER_CODE = "INVALID_IDENTIFIER";
204
204
  UNSTORABLE_IDENTIFIER = // biome-ignore lint/suspicious/noControlCharactersInRegex: U+0000 is exactly what identifiers may not hold (RT-65)
205
205
  /\u0000|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
206
- BackupValidationError = class extends import_core8.KoraError {
206
+ BackupValidationError = class extends import_core9.KoraError {
207
207
  constructor(invalidOperations, totalOperations) {
208
208
  super(
209
209
  `Backup rejected: ${String(invalidOperations.length)} of ${String(totalOperations)} operations failed ingest validation (first: "${invalidOperations[0]?.operationId ?? ""}", ${invalidOperations[0]?.code ?? ""}). A far-future timestamp usually means the exporting server's clock was wrong; fix the clock and re-export, or repair the operations before restoring.`,
@@ -350,7 +350,7 @@ function parseServerBackup(data) {
350
350
  function parseKeyRecords(value) {
351
351
  if (value === null || value === void 0) return [];
352
352
  const refuse = (reason) => {
353
- throw new import_core9.KoraError(
353
+ throw new import_core10.KoraError(
354
354
  `Backup rejected: its encryption key records are malformed (${reason}).`,
355
355
  "BACKUP_INVALID_KEY_RECORD",
356
356
  { reason }
@@ -464,12 +464,12 @@ async function mergeBackupOperations(operations2, apply) {
464
464
  }
465
465
  return { operationsRestored: restored, success: conflicts.length === 0 };
466
466
  }
467
- var import_core9, import_sync, BACKUP_VERSION, KEY_RESTORE_ATTEMPTS;
467
+ var import_core10, import_sync, BACKUP_VERSION, KEY_RESTORE_ATTEMPTS;
468
468
  var init_server_backup = __esm({
469
469
  "src/store/server-backup.ts"() {
470
470
  "use strict";
471
471
  init_cjs_shims();
472
- import_core9 = require("@korajs/core");
472
+ import_core10 = require("@korajs/core");
473
473
  import_sync = require("@korajs/sync");
474
474
  init_ingest_validation();
475
475
  init_server_store();
@@ -485,6 +485,7 @@ __export(index_exports, {
485
485
  ClientSession: () => ClientSession,
486
486
  DEFAULT_MAX_SCOPE_PREDICATE_VALUES: () => DEFAULT_MAX_SCOPE_PREDICATE_VALUES,
487
487
  HttpServerTransport: () => HttpServerTransport,
488
+ InvalidDiagnosticOptionsError: () => InvalidDiagnosticOptionsError,
488
489
  InvalidScopePredicateError: () => InvalidScopePredicateError,
489
490
  KoraAuthProvider: () => KoraAuthProvider,
490
491
  KoraSyncServer: () => KoraSyncServer,
@@ -493,7 +494,7 @@ __export(index_exports, {
493
494
  NoAuthProvider: () => NoAuthProvider,
494
495
  PostgresServerStore: () => PostgresServerStore,
495
496
  RETRIABLE_REJECTION_CODES: () => RETRIABLE_REJECTION_CODES,
496
- SCOPE_CLAIMS_KEY: () => import_core37.SCOPE_CLAIMS_KEY,
497
+ SCOPE_CLAIMS_KEY: () => import_core38.SCOPE_CLAIMS_KEY,
497
498
  SERVER_NODE_PREFIX: () => SERVER_NODE_PREFIX,
498
499
  ScopePredicateLimitError: () => ScopePredicateLimitError,
499
500
  ScopeRequiredError: () => ScopeRequiredError,
@@ -504,7 +505,7 @@ __export(index_exports, {
504
505
  UplinkAuthorizationError: () => UplinkAuthorizationError,
505
506
  WsServerTransport: () => WsServerTransport,
506
507
  authorizeUplinkWrite: () => authorizeUplinkWrite,
507
- claimScopes: () => import_core37.claimScopes,
508
+ claimScopes: () => import_core38.claimScopes,
508
509
  createDefaultLogger: () => createDefaultLogger,
509
510
  createJsonLogger: () => createJsonLogger,
510
511
  createKoraServer: () => createKoraServer,
@@ -513,6 +514,7 @@ __export(index_exports, {
513
514
  createProductionServer: () => createProductionServer,
514
515
  createSilentLogger: () => createSilentLogger,
515
516
  createSqliteServerStore: () => createSqliteServerStore,
517
+ findJsonStringValues: () => findJsonStringValues,
516
518
  isRetriableRejection: () => isRetriableRejection,
517
519
  isServerNodeId: () => isServerNodeId,
518
520
  normalizeScopeMap: () => normalizeScopeMap,
@@ -523,6 +525,72 @@ __export(index_exports, {
523
525
  module.exports = __toCommonJS(index_exports);
524
526
  init_cjs_shims();
525
527
 
528
+ // src/diagnostics/json-string-values.ts
529
+ init_cjs_shims();
530
+ var import_core = require("@korajs/core");
531
+ var InvalidDiagnosticOptionsError = class extends import_core.KoraError {
532
+ constructor(option, value) {
533
+ super(
534
+ `findJsonStringValues: ${option} must be a ${option === "pageSize" ? "positive" : "non-negative"} integer, got ${String(value)}.`,
535
+ "INVALID_DIAGNOSTIC_OPTIONS",
536
+ { option, value }
537
+ );
538
+ this.name = "InvalidDiagnosticOptionsError";
539
+ }
540
+ };
541
+ async function findJsonStringValues(store, options = {}) {
542
+ const schema = store.getSchema();
543
+ if (!schema) return [];
544
+ const sampleSize = options.sampleSize ?? 10;
545
+ const pageSize = options.pageSize ?? 1e3;
546
+ if (!Number.isInteger(pageSize) || pageSize < 1) {
547
+ throw new InvalidDiagnosticOptionsError("pageSize", pageSize);
548
+ }
549
+ if (!Number.isInteger(sampleSize) || sampleSize < 0) {
550
+ throw new InvalidDiagnosticOptionsError("sampleSize", sampleSize);
551
+ }
552
+ const reports = [];
553
+ for (const [collection, definition] of Object.entries(schema.collections)) {
554
+ const fields = Object.entries(definition.fields).filter(([, descriptor]) => descriptor.kind === "json" || descriptor.kind === "object").map(([name]) => name);
555
+ if (fields.length === 0) continue;
556
+ const found = /* @__PURE__ */ new Map();
557
+ for (let offset = 0; ; offset += pageSize) {
558
+ const rows = await store.queryCollection(collection, {
559
+ orderBy: "id",
560
+ limit: pageSize,
561
+ offset
562
+ });
563
+ for (const row of rows) {
564
+ for (const field of fields) {
565
+ if (!isEncodedJson(row[field])) continue;
566
+ let report = found.get(field);
567
+ if (!report) {
568
+ report = { collection, field, count: 0, sampleIds: [] };
569
+ found.set(field, report);
570
+ }
571
+ report.count += 1;
572
+ if (report.sampleIds.length < sampleSize) report.sampleIds.push(String(row.id));
573
+ }
574
+ }
575
+ if (rows.length < pageSize) break;
576
+ }
577
+ reports.push(...found.values());
578
+ }
579
+ return reports;
580
+ }
581
+ function isEncodedJson(value) {
582
+ if (typeof value !== "string") return false;
583
+ const trimmed = value.trim();
584
+ const first = trimmed[0];
585
+ if (first !== "{" && first !== "[" && first !== '"') return false;
586
+ try {
587
+ JSON.parse(trimmed);
588
+ return true;
589
+ } catch {
590
+ return false;
591
+ }
592
+ }
593
+
526
594
  // src/logging/structured-logger.ts
527
595
  init_cjs_shims();
528
596
  function defaultJsonSerializer(entry) {
@@ -589,17 +657,17 @@ function isRetriableRejection(code) {
589
657
 
590
658
  // src/store/memory-server-store.ts
591
659
  init_cjs_shims();
592
- var import_core10 = require("@korajs/core");
660
+ var import_core11 = require("@korajs/core");
593
661
 
594
662
  // src/scopes/server-scope-filter.ts
595
663
  init_cjs_shims();
596
- var import_core2 = require("@korajs/core");
664
+ var import_core3 = require("@korajs/core");
597
665
  var import_internal = require("@korajs/sync/internal");
598
666
 
599
667
  // src/scopes/scope-predicate-errors.ts
600
668
  init_cjs_shims();
601
- var import_core = require("@korajs/core");
602
- var InvalidScopePredicateError = class extends import_core.KoraError {
669
+ var import_core2 = require("@korajs/core");
670
+ var InvalidScopePredicateError = class extends import_core2.KoraError {
603
671
  constructor(collection, field) {
604
672
  super(
605
673
  `Sync scope predicate for "${collection}.${field}" is undefined or null. A missing value would match every record without "${field}", so the grant is refused.`,
@@ -617,7 +685,7 @@ var InvalidScopePredicateError = class extends import_core.KoraError {
617
685
  collection;
618
686
  field;
619
687
  };
620
- var ScopePredicateLimitError = class extends import_core.KoraError {
688
+ var ScopePredicateLimitError = class extends import_core2.KoraError {
621
689
  constructor(message, context) {
622
690
  super(message, "SCOPE_PREDICATE_LIMIT", context);
623
691
  this.name = "ScopePredicateLimitError";
@@ -656,14 +724,18 @@ function normalizeScopeMap(scopes, maxValues = DEFAULT_MAX_SCOPE_PREDICATE_VALUE
656
724
  collection,
657
725
  field
658
726
  });
659
- const unique = [...new Map(values.map((value) => [stableValueKey(value), value])).values()];
727
+ const unique = [...new Map(values.map((value) => [stableValueKey(value), value])).entries()];
660
728
  if (unique.length > maxValues)
661
729
  throw new ScopePredicateLimitError(
662
730
  `Scope predicate for ${collection}.${field} exceeds the ${maxValues}-value limit`,
663
731
  { collection, field, maxValues }
664
732
  );
665
733
  predicate[field] = {
666
- $in: unique.sort((a, b) => stableValueKey(a).localeCompare(stableValueKey(b)))
734
+ // Frozen: matchers may cache a set of a large list (F17), which is only
735
+ // sound for a list that cannot change.
736
+ $in: Object.freeze(
737
+ unique.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, value]) => value)
738
+ )
667
739
  };
668
740
  } else predicate[field] = expected;
669
741
  }
@@ -734,7 +806,7 @@ function missingScopeFields(op, scopes) {
734
806
  const snapshot = (0, import_internal.buildScopeSnapshot)(op);
735
807
  return Object.keys(collectionScope).filter((field) => !(field in snapshot));
736
808
  }
737
- var UplinkAuthorizationError = class extends import_core2.KoraError {
809
+ var UplinkAuthorizationError = class extends import_core3.KoraError {
738
810
  constructor(rejectionCode, message, context) {
739
811
  super(message, rejectionCode, context);
740
812
  this.rejectionCode = rejectionCode;
@@ -816,7 +888,7 @@ function authorizeRecordWrite(collection, recordId, storedRow, scopes) {
816
888
 
817
889
  // src/store/materialization.ts
818
890
  init_cjs_shims();
819
- var import_core3 = require("@korajs/core");
891
+ var import_core4 = require("@korajs/core");
820
892
  var import_internal2 = require("@korajs/core/internal");
821
893
  function fieldTypeToSql(descriptor, dialect) {
822
894
  switch (descriptor.kind) {
@@ -849,7 +921,7 @@ function generateCollectionDDL(name, collection, dialect) {
849
921
  const columns = ["id TEXT PRIMARY KEY NOT NULL"];
850
922
  for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
851
923
  const sqlType = fieldTypeToSql(descriptor, dialect);
852
- let colDef = `${(0, import_core3.quoteIdent)(fieldName)} ${sqlType}`;
924
+ let colDef = `${(0, import_core4.quoteIdent)(fieldName)} ${sqlType}`;
853
925
  if (descriptor.defaultValue !== void 0) {
854
926
  colDef += ` DEFAULT ${(0, import_internal2.sqlDefaultLiteral)(descriptor.defaultValue)}`;
855
927
  }
@@ -859,25 +931,25 @@ function generateCollectionDDL(name, collection, dialect) {
859
931
  columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
860
932
  columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
861
933
  columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
862
- statements.push(`CREATE TABLE IF NOT EXISTS ${(0, import_core3.quoteIdent)(name)} (
934
+ statements.push(`CREATE TABLE IF NOT EXISTS ${(0, import_core4.quoteIdent)(name)} (
863
935
  ${columns.join(",\n ")}
864
936
  )`);
865
937
  for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
866
938
  const sqlType = fieldTypeToSql(descriptor, dialect);
867
- let colDef = `${(0, import_core3.quoteIdent)(fieldName)} ${sqlType}`;
939
+ let colDef = `${(0, import_core4.quoteIdent)(fieldName)} ${sqlType}`;
868
940
  if (descriptor.defaultValue !== void 0) {
869
941
  colDef += ` DEFAULT ${(0, import_internal2.sqlDefaultLiteral)(descriptor.defaultValue)}`;
870
942
  }
871
943
  statements.push(`--kora:safe-alter
872
- ALTER TABLE ${(0, import_core3.quoteIdent)(name)} ADD COLUMN ${colDef}`);
944
+ ALTER TABLE ${(0, import_core4.quoteIdent)(name)} ADD COLUMN ${colDef}`);
873
945
  }
874
946
  for (const indexField of collection.indexes) {
875
947
  statements.push(
876
- `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${(0, import_core3.quoteIdent)(name)} (${(0, import_core3.quoteIdent)(indexField)})`
948
+ `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${(0, import_core4.quoteIdent)(name)} (${(0, import_core4.quoteIdent)(indexField)})`
877
949
  );
878
950
  }
879
951
  statements.push(
880
- `CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${(0, import_core3.quoteIdent)(name)} (_deleted)`
952
+ `CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${(0, import_core4.quoteIdent)(name)} (_deleted)`
881
953
  );
882
954
  return statements;
883
955
  }
@@ -901,7 +973,7 @@ function base64ToBytes(base64) {
901
973
  for (const char of cleaned) {
902
974
  const value = BASE64_REVERSE.get(char);
903
975
  if (value === void 0) {
904
- throw new import_core3.StorageError(`Invalid base64 character "${char}" in tagged richtext value`, {
976
+ throw new import_core4.StorageError(`Invalid base64 character "${char}" in tagged richtext value`, {
905
977
  char
906
978
  });
907
979
  }
@@ -996,12 +1068,12 @@ function validateFieldName(collectionName, fieldName, schema) {
996
1068
 
997
1069
  // src/store/record-fold.ts
998
1070
  init_cjs_shims();
999
- var import_core5 = require("@korajs/core");
1071
+ var import_core6 = require("@korajs/core");
1000
1072
  var import_merge = require("@korajs/merge");
1001
1073
 
1002
1074
  // src/store/server-identity.ts
1003
1075
  init_cjs_shims();
1004
- var import_core4 = require("@korajs/core");
1076
+ var import_core5 = require("@korajs/core");
1005
1077
  var SERVER_NODE_PREFIX = "kora:server:";
1006
1078
  var RESERVED_NODE_PREFIX = "kora:";
1007
1079
  var SERVER_DEPLOYMENT_ID_KEY = "server_deployment_id";
@@ -1014,7 +1086,7 @@ var SERVER_REVOKED_AUTHORITY_KEY = "server_revoked_authoritative_node_ids";
1014
1086
  var SERVER_LEGACY_SCAN_KEY = "server_legacy_authority_scan_v1";
1015
1087
  var INSTANCE_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
1016
1088
  var SERVER_DERIVED_DOMAIN = "kora/server-derived/v1";
1017
- var ServerIdentityError = class extends import_core4.KoraError {
1089
+ var ServerIdentityError = class extends import_core5.KoraError {
1018
1090
  constructor(message, context) {
1019
1091
  super(message, "INVALID_SERVER_IDENTITY", context);
1020
1092
  this.name = "ServerIdentityError";
@@ -1120,7 +1192,7 @@ function isServerNodeId(nodeId) {
1120
1192
  return nodeId.startsWith(SERVER_NODE_PREFIX);
1121
1193
  }
1122
1194
  function generateDeploymentId() {
1123
- return (0, import_core4.generateUUIDv7)();
1195
+ return (0, import_core5.generateUUIDv7)();
1124
1196
  }
1125
1197
  function generateDerivationSecret() {
1126
1198
  const bytes = new Uint8Array(32);
@@ -1280,19 +1352,19 @@ function serverFoldOptions(explicitAuthorities, transforms = []) {
1280
1352
  function serverOperationView(store, op) {
1281
1353
  const schema = store.getSchema();
1282
1354
  if (schema === null) return op;
1283
- return (0, import_core5.operationSchemaView)(op, schema.version, store.getOperationTransforms?.());
1355
+ return (0, import_core6.operationSchemaView)(op, schema.version, store.getOperationTransforms?.());
1284
1356
  }
1285
1357
  function serverFoldPlanFingerprint(schema, explicitAuthorities, transforms = []) {
1286
1358
  const explicit = normalizeLegacyAuthorities(explicitAuthorities);
1287
- return `${(0, import_core5.foldPlanFingerprint)(schema, transforms)}|auth:${SERVER_NODE_PREFIX}*${explicit.length > 0 ? `,${explicit.join(",")}` : ""}`;
1359
+ return `${(0, import_core6.foldPlanFingerprint)(schema, transforms)}|auth:${SERVER_NODE_PREFIX}*${explicit.length > 0 ? `,${explicit.join(",")}` : ""}`;
1288
1360
  }
1289
1361
  function parseStoredFoldState(json, collection, recordId) {
1290
1362
  if (typeof json !== "string" || json.length === 0) return null;
1291
1363
  try {
1292
- const state = (0, import_core5.deserializeFoldState)(json);
1364
+ const state = (0, import_core6.deserializeFoldState)(json);
1293
1365
  return state.c === collection && state.r === recordId ? state : null;
1294
1366
  } catch (error) {
1295
- if (error instanceof import_core5.FoldStateError) return null;
1367
+ if (error instanceof import_core6.FoldStateError) return null;
1296
1368
  throw error;
1297
1369
  }
1298
1370
  }
@@ -1301,23 +1373,23 @@ function mergeIntoFoldState(state, ops, schema, options) {
1301
1373
  const first = ops[0];
1302
1374
  if (first === void 0) return state;
1303
1375
  try {
1304
- let next = state ?? (0, import_core5.createFoldState)(first.collection, first.recordId);
1305
- for (const op of ops) next = (0, import_core5.mergeOp)(next, op, schema, options).state;
1376
+ let next = state ?? (0, import_core6.createFoldState)(first.collection, first.recordId);
1377
+ for (const op of ops) next = (0, import_core6.mergeOp)(next, op, schema, options).state;
1306
1378
  return next;
1307
1379
  } catch (error) {
1308
- if (error instanceof import_core5.FoldStateError) return REFOLD_REQUIRED;
1380
+ if (error instanceof import_core6.FoldStateError) return REFOLD_REQUIRED;
1309
1381
  throw error;
1310
1382
  }
1311
1383
  }
1312
1384
  function refoldRecord(ops, schema, options) {
1313
- return (0, import_core5.foldRecord)(ops, schema, options).state;
1385
+ return (0, import_core6.foldRecord)(ops, schema, options).state;
1314
1386
  }
1315
1387
  function projectFoldState(state, options) {
1316
1388
  if (state.cr === null) return null;
1317
- const live = (0, import_core5.isFoldStateLive)(state);
1318
- const values = (0, import_core5.materialize)(live ? state : { ...state, d: null }, options) ?? {};
1319
- const createdAt = import_core5.HybridLogicalClock.deserialize(state.cr.t).wallTime;
1320
- const updatedAt = state.u ? import_core5.HybridLogicalClock.deserialize(state.u.t).wallTime : createdAt;
1389
+ const live = (0, import_core6.isFoldStateLive)(state);
1390
+ const values = (0, import_core6.materialize)(live ? state : { ...state, d: null }, options) ?? {};
1391
+ const createdAt = import_core6.HybridLogicalClock.deserialize(state.cr.t).wallTime;
1392
+ const updatedAt = state.u ? import_core6.HybridLogicalClock.deserialize(state.u.t).wallTime : createdAt;
1321
1393
  return { values, createdAt, updatedAt, deleted: !live };
1322
1394
  }
1323
1395
  function materializedFieldValue(values, field, descriptor) {
@@ -1326,7 +1398,7 @@ function materializedFieldValue(values, field, descriptor) {
1326
1398
  }
1327
1399
  function foldFieldVersions(state) {
1328
1400
  if (!state) return null;
1329
- return (0, import_core5.getFoldFieldVersions)(state);
1401
+ return (0, import_core6.getFoldFieldVersions)(state);
1330
1402
  }
1331
1403
  function filterFoldStateFields(state, fields) {
1332
1404
  const keep = new Set(fields);
@@ -1337,7 +1409,7 @@ function filterFoldStateFields(state, fields) {
1337
1409
  return { ...state, f: filtered };
1338
1410
  }
1339
1411
  function serializeServerFoldState(state) {
1340
- return (0, import_core5.serializeFoldState)(state);
1412
+ return (0, import_core6.serializeFoldState)(state);
1341
1413
  }
1342
1414
 
1343
1415
  // src/store/scope-snapshot.ts
@@ -1516,7 +1588,7 @@ var MemoryServerStore = class {
1516
1588
  }
1517
1589
  async setSchema(schema, options = {}) {
1518
1590
  this.assertOpen();
1519
- (0, import_core10.assertOperationTransformCoverage)(
1591
+ (0, import_core11.assertOperationTransformCoverage)(
1520
1592
  this.operations.map((op) => op.schemaVersion),
1521
1593
  schema.version,
1522
1594
  options.operationTransforms ?? this.operationTransforms,
@@ -1546,7 +1618,7 @@ var MemoryServerStore = class {
1546
1618
  async setOperationTransforms(transforms) {
1547
1619
  this.assertOpen();
1548
1620
  if (this.schema) {
1549
- (0, import_core10.assertOperationTransformCoverage)(
1621
+ (0, import_core11.assertOperationTransformCoverage)(
1550
1622
  this.operations.map((op) => op.schemaVersion),
1551
1623
  this.schema.version,
1552
1624
  transforms,
@@ -1684,7 +1756,7 @@ var MemoryServerStore = class {
1684
1756
  this.assertOpen();
1685
1757
  let latest = null;
1686
1758
  for (const op of this.recordOps.get(recordKey(collection, recordId)) ?? []) {
1687
- if (latest === null || import_core10.HybridLogicalClock.compare(op.timestamp, latest) > 0) {
1759
+ if (latest === null || import_core11.HybridLogicalClock.compare(op.timestamp, latest) > 0) {
1688
1760
  latest = op.timestamp;
1689
1761
  }
1690
1762
  }
@@ -1999,6 +2071,16 @@ var MemoryServerStore = class {
1999
2071
  }
2000
2072
  return owner === userId;
2001
2073
  }
2074
+ async claimUnownedNode(nodeId, userId) {
2075
+ this.assertOpen();
2076
+ if (userId === RELEASED_NODE_OWNER) return false;
2077
+ const owner = this.nodeOwners.get(nodeId);
2078
+ if (owner === void 0 || owner === RELEASED_NODE_OWNER) {
2079
+ this.nodeOwners.set(nodeId, userId);
2080
+ return true;
2081
+ }
2082
+ return owner === userId;
2083
+ }
2002
2084
  async getNodeClaimOwner(nodeId) {
2003
2085
  this.assertOpen();
2004
2086
  return this.nodeOwners.get(nodeId) ?? null;
@@ -2218,7 +2300,7 @@ function keyRecordKey(owner, keyring) {
2218
2300
 
2219
2301
  // src/store/postgres-server-store.ts
2220
2302
  init_cjs_shims();
2221
- var import_core14 = require("@korajs/core");
2303
+ var import_core15 = require("@korajs/core");
2222
2304
  var import_internal3 = require("@korajs/core/internal");
2223
2305
  var import_drizzle_orm = require("drizzle-orm");
2224
2306
 
@@ -2309,14 +2391,14 @@ var KEY_ID_SAMPLE_ROWS = 512;
2309
2391
 
2310
2392
  // src/store/legacy-bodies.ts
2311
2393
  init_cjs_shims();
2312
- var import_core11 = require("@korajs/core");
2394
+ var import_core12 = require("@korajs/core");
2313
2395
  var LEGACY_BODIES_META_KEY = "legacy_bodies_canonical";
2314
2396
  async function provenLegacyClears(stored) {
2315
2397
  const changed = [];
2316
2398
  for (const op of stored) {
2317
2399
  if (op.type !== "update" || op.hashVersion !== void 0 || op.encrypted !== void 0) continue;
2318
- if ((0, import_core11.canonicalizeLegacyOperation)(op) === op) continue;
2319
- const canonical = await (0, import_core11.canonicalizeProvenLegacyClear)(op);
2400
+ if ((0, import_core12.canonicalizeLegacyOperation)(op) === op) continue;
2401
+ const canonical = await (0, import_core12.canonicalizeProvenLegacyClear)(op);
2320
2402
  if (canonical !== op) changed.push(canonical);
2321
2403
  }
2322
2404
  return changed;
@@ -2401,7 +2483,7 @@ function isJsonStringArray(value) {
2401
2483
 
2402
2484
  // src/store/quarantine-base.ts
2403
2485
  init_cjs_shims();
2404
- var import_core12 = require("@korajs/core");
2486
+ var import_core13 = require("@korajs/core");
2405
2487
  function quarantineKey(collection, recordId) {
2406
2488
  return JSON.stringify([collection, recordId]);
2407
2489
  }
@@ -2442,7 +2524,7 @@ function buildQuarantineScope(rowJsons) {
2442
2524
  nodeId: typeof row.timestamp_node_id === "string" ? row.timestamp_node_id : ""
2443
2525
  };
2444
2526
  const prior = scope.latest.get(key);
2445
- if (!prior || import_core12.HybridLogicalClock.compare(ts, prior) > 0) scope.latest.set(key, ts);
2527
+ if (!prior || import_core13.HybridLogicalClock.compare(ts, prior) > 0) scope.latest.set(key, ts);
2446
2528
  }
2447
2529
  }
2448
2530
  return scope;
@@ -2454,12 +2536,12 @@ function rebuildFromSnapshot(input, schema, options) {
2454
2536
  }
2455
2537
  if (!input.row) return input.ops.length > 0 ? refoldRecord(input.ops, schema, options) : null;
2456
2538
  const fromLog = input.ops.length > 0 ? refoldRecord(input.ops, schema, options) : null;
2457
- const versions = fromLog ? (0, import_core12.getFoldFieldVersions)(fromLog) : null;
2539
+ const versions = fromLog ? (0, import_core13.getFoldFieldVersions)(fromLog) : null;
2458
2540
  const floor = input.quarantinedLatest;
2459
2541
  const atLeast = (ts) => {
2460
2542
  if (!ts) return floor;
2461
2543
  if (!floor) return ts;
2462
- return import_core12.HybridLogicalClock.compare(ts, floor) >= 0 ? ts : floor;
2544
+ return import_core13.HybridLogicalClock.compare(ts, floor) >= 0 ? ts : floor;
2463
2545
  };
2464
2546
  const rowWall = (wall) => ({ wallTime: wall, logical: 0, nodeId: "" });
2465
2547
  const latest = atLeast(versions?.latest) ?? rowWall(Math.max(input.row.updatedAt, input.row.createdAt));
@@ -2467,7 +2549,7 @@ function rebuildFromSnapshot(input, schema, options) {
2467
2549
  for (const field of Object.keys(input.row.values)) {
2468
2550
  fieldVersions[field] = atLeast(versions?.fields[field]) ?? latest;
2469
2551
  }
2470
- const base = (0, import_core12.createSnapshotState)(
2552
+ const base = (0, import_core13.createSnapshotState)(
2471
2553
  {
2472
2554
  collection: input.collection,
2473
2555
  recordId: input.recordId,
@@ -2488,12 +2570,12 @@ init_server_store();
2488
2570
 
2489
2571
  // src/store/text-codec.ts
2490
2572
  init_cjs_shims();
2491
- var import_core13 = require("@korajs/core");
2573
+ var import_core14 = require("@korajs/core");
2492
2574
  var PG_TEXT_CODEC_MIGRATION_KEY = "pg_text_codec_v1:";
2493
- var encodePgText = import_core13.encodeStoredText;
2494
- var decodePgText = import_core13.decodeStoredText;
2495
- var encodePgJsonValue = import_core13.encodeStoredJsonValue;
2496
- var decodePgJsonValue = import_core13.decodeStoredJsonValue;
2575
+ var encodePgText = import_core14.encodeStoredText;
2576
+ var decodePgText = import_core14.decodeStoredText;
2577
+ var encodePgJsonValue = import_core14.encodeStoredJsonValue;
2578
+ var decodePgJsonValue = import_core14.decodeStoredJsonValue;
2497
2579
  function pgTextCodecMigrationSql(table, textColumns, jsonbColumns) {
2498
2580
  const esc = "U&'\\FFFF'";
2499
2581
  const statements = [];
@@ -2856,14 +2938,14 @@ var PostgresServerStore = class {
2856
2938
  * log has no transform path to `version` (RT-103): those operations would fold as
2857
2939
  * absent, silently erasing them from their records.
2858
2940
  */
2859
- async assertTransformCoverage(version, transforms) {
2941
+ async assertTransformCoverage(version2, transforms) {
2860
2942
  if (transforms.length === 0) return;
2861
2943
  const rows = await this.db.execute(
2862
2944
  import_drizzle_orm.sql`SELECT DISTINCT schema_version AS v FROM operations`
2863
2945
  );
2864
- (0, import_core14.assertOperationTransformCoverage)(
2946
+ (0, import_core15.assertOperationTransformCoverage)(
2865
2947
  rows.map((row) => Number(row.v)),
2866
- version,
2948
+ version2,
2867
2949
  transforms,
2868
2950
  "Postgres server store"
2869
2951
  );
@@ -2945,9 +3027,9 @@ var PostgresServerStore = class {
2945
3027
  if (done.length > 0) return;
2946
3028
  const fields = Object.entries(collection.fields);
2947
3029
  const statements = pgTextCodecMigrationSql(
2948
- (0, import_core14.quoteIdent)(name),
2949
- fields.filter(([, d]) => isPgRawTextKind(d.kind)).map(([f]) => (0, import_core14.quoteIdent)(f)),
2950
- fields.filter(([, d]) => isPgJsonbKind(d.kind)).map(([f]) => (0, import_core14.quoteIdent)(f))
3030
+ (0, import_core15.quoteIdent)(name),
3031
+ fields.filter(([, d]) => isPgRawTextKind(d.kind)).map(([f]) => (0, import_core15.quoteIdent)(f)),
3032
+ fields.filter(([, d]) => isPgJsonbKind(d.kind)).map(([f]) => (0, import_core15.quoteIdent)(f))
2951
3033
  );
2952
3034
  for (const statement of statements) await tx.execute(import_drizzle_orm.sql.raw(statement));
2953
3035
  await tx.execute(
@@ -3094,20 +3176,20 @@ var PostgresServerStore = class {
3094
3176
  await tx.execute(import_drizzle_orm.sql`SELECT value FROM delivery_counter WHERE id = 1 FOR UPDATE`);
3095
3177
  if (input.idempotencyKey) {
3096
3178
  const existing = await tx.execute(
3097
- import_drizzle_orm.sql`SELECT 1 FROM ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(input.idempotencyKey.collection))} WHERE id = ${input.idempotencyKey.id} AND _deleted = 0 LIMIT 1`
3179
+ import_drizzle_orm.sql`SELECT 1 FROM ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(input.idempotencyKey.collection))} WHERE id = ${input.idempotencyKey.id} AND _deleted = 0 LIMIT 1`
3098
3180
  );
3099
3181
  if (existing.length > 0) {
3100
3182
  return { admitted: true, idempotent: true, applied: [] };
3101
3183
  }
3102
3184
  }
3103
3185
  const rows = await tx.execute(
3104
- import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(input.target.collection))} WHERE id = ${input.target.id} AND _deleted = 0`
3186
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(input.target.collection))} WHERE id = ${input.target.id} AND _deleted = 0`
3105
3187
  );
3106
3188
  const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
3107
3189
  if (!input.admit(current)) {
3108
3190
  return { admitted: false, idempotent: false, applied: [] };
3109
3191
  }
3110
- const clock = new import_core14.HybridLogicalClock(this.nodeId, this.timeSource);
3192
+ const clock = new import_core15.HybridLogicalClock(this.nodeId, this.timeSource);
3111
3193
  const latest = await this.latestOperationTimestamp(
3112
3194
  tx,
3113
3195
  input.target.collection,
@@ -3295,7 +3377,7 @@ var PostgresServerStore = class {
3295
3377
  const collectionDef = this.schema?.collections[collection];
3296
3378
  if (!collectionDef) return null;
3297
3379
  const rows = await txOrDb.execute(
3298
- import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(collection))} WHERE id = ${recordId} LIMIT 1`
3380
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE id = ${recordId} LIMIT 1`
3299
3381
  );
3300
3382
  const row = rows[0];
3301
3383
  if (!row) return null;
@@ -3484,7 +3566,7 @@ var PostgresServerStore = class {
3484
3566
  const chunk = ids.slice(i, i + 1e3);
3485
3567
  if (chunk.length === 0) continue;
3486
3568
  const rows = await this.db.execute(
3487
- import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(collection))} WHERE id IN (${import_drizzle_orm.sql.join(
3569
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE id IN (${import_drizzle_orm.sql.join(
3488
3570
  chunk.map((id) => import_drizzle_orm.sql`${id}`),
3489
3571
  import_drizzle_orm.sql.raw(", ")
3490
3572
  )})`
@@ -3522,7 +3604,7 @@ var PostgresServerStore = class {
3522
3604
  this.assertCollection(collection);
3523
3605
  const schema = this.schema;
3524
3606
  const collectionDef = schema.collections[collection];
3525
- const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
3607
+ const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
3526
3608
  const rows = await this.db.execute(query);
3527
3609
  if (rows.length === 0) return null;
3528
3610
  return this.deserializeRow(rows[0], collectionDef);
@@ -3539,7 +3621,7 @@ var PostgresServerStore = class {
3539
3621
  }
3540
3622
  }
3541
3623
  const whereClause = this.buildWhereClause(where ?? {}, false, schema.collections[collection]);
3542
- const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(collection))} WHERE ${whereClause}`;
3624
+ const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE ${whereClause}`;
3543
3625
  const rows = await this.db.execute(query);
3544
3626
  const cnt = rows[0]?.cnt;
3545
3627
  return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
@@ -3601,7 +3683,7 @@ var PostgresServerStore = class {
3601
3683
  await this.db.transaction(async (tx) => {
3602
3684
  await tx.execute(import_drizzle_orm.sql`DELETE FROM kora_fold_state`);
3603
3685
  for (const collection of Object.keys(this.schema?.collections ?? {})) {
3604
- await tx.execute(import_drizzle_orm.sql.raw(`DELETE FROM ${(0, import_core14.quoteIdent)(collection)}`));
3686
+ await tx.execute(import_drizzle_orm.sql.raw(`DELETE FROM ${(0, import_core15.quoteIdent)(collection)}`));
3605
3687
  }
3606
3688
  });
3607
3689
  if (this.schema) this.foldMigration = await this.rematerialize();
@@ -3711,6 +3793,21 @@ var PostgresServerStore = class {
3711
3793
  );
3712
3794
  return rows[0]?.user_id === userId;
3713
3795
  }
3796
+ async claimUnownedNode(nodeId, userId) {
3797
+ this.assertOpen();
3798
+ await this.ready;
3799
+ if (userId === RELEASED_NODE_OWNER) return false;
3800
+ await this.db.execute(
3801
+ import_drizzle_orm.sql`INSERT INTO node_claims (node_id, user_id, claimed_at)
3802
+ VALUES (${nodeId}, ${userId}, ${Date.now()})
3803
+ ON CONFLICT (node_id) DO UPDATE SET user_id = EXCLUDED.user_id, claimed_at = EXCLUDED.claimed_at
3804
+ WHERE node_claims.user_id = ${RELEASED_NODE_OWNER}`
3805
+ );
3806
+ const rows = await this.db.execute(
3807
+ import_drizzle_orm.sql`SELECT user_id FROM node_claims WHERE node_id = ${nodeId} LIMIT 1`
3808
+ );
3809
+ return rows[0]?.user_id === userId;
3810
+ }
3714
3811
  async getNodeClaimOwner(nodeId) {
3715
3812
  this.assertOpen();
3716
3813
  await this.ready;
@@ -3752,7 +3849,7 @@ var PostgresServerStore = class {
3752
3849
  const collectionDef = this.schema?.collections[collection];
3753
3850
  if (collectionDef) {
3754
3851
  const rows = await txOrDb.execute(
3755
- import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(collection))} WHERE id = ${recordId} LIMIT 1`
3852
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE id = ${recordId} LIMIT 1`
3756
3853
  );
3757
3854
  const row = rows[0];
3758
3855
  return row ? this.deserializeRow(row, collectionDef) : null;
@@ -3830,7 +3927,7 @@ var PostgresServerStore = class {
3830
3927
  const schema = this.schema ?? EMPTY_FOLD_SCHEMA;
3831
3928
  const collectionDef = schema.collections[collection];
3832
3929
  const raw = collectionDef ? (await txOrDb.execute(
3833
- import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(collection))} WHERE id = ${recordId} LIMIT 1`
3930
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE id = ${recordId} LIMIT 1`
3834
3931
  ))[0] : void 0;
3835
3932
  let row = null;
3836
3933
  if (raw && collectionDef) {
@@ -3928,7 +4025,7 @@ var PostgresServerStore = class {
3928
4025
  const row = state ? projectFoldState(state, this.foldOptions) : null;
3929
4026
  if (!row) {
3930
4027
  await txOrDb.execute(
3931
- import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(collection))} SET _deleted = 1 WHERE id = ${recordId}`
4028
+ import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(collection))} SET _deleted = 1 WHERE id = ${recordId}`
3932
4029
  );
3933
4030
  return;
3934
4031
  }
@@ -3959,16 +4056,16 @@ var PostgresServerStore = class {
3959
4056
  updatedAt,
3960
4057
  deleted ? 1 : 0
3961
4058
  ];
3962
- const columnsSql = import_drizzle_orm.sql.raw(allColumns.map((c) => (0, import_core14.quoteIdent)(c)).join(", "));
4059
+ const columnsSql = import_drizzle_orm.sql.raw(allColumns.map((c) => (0, import_core15.quoteIdent)(c)).join(", "));
3963
4060
  const valuesSql = import_drizzle_orm.sql.join(
3964
4061
  values.map((v) => import_drizzle_orm.sql`${v}`),
3965
4062
  import_drizzle_orm.sql.raw(", ")
3966
4063
  );
3967
4064
  const updateSet = import_drizzle_orm.sql.raw(
3968
- allColumns.slice(1).map((c) => `${(0, import_core14.quoteIdent)(c)} = excluded.${(0, import_core14.quoteIdent)(c)}`).join(", ")
4065
+ allColumns.slice(1).map((c) => `${(0, import_core15.quoteIdent)(c)} = excluded.${(0, import_core15.quoteIdent)(c)}`).join(", ")
3969
4066
  );
3970
4067
  await txOrDb.execute(
3971
- import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
4068
+ import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
3972
4069
  );
3973
4070
  }
3974
4071
  /**
@@ -4024,9 +4121,9 @@ var PostgresServerStore = class {
4024
4121
  ]);
4025
4122
  }
4026
4123
  const perStatement = Math.max(1, Math.floor(3e4 / columns.length));
4027
- const columnsSql = import_drizzle_orm.sql.raw(columns.map((c) => (0, import_core14.quoteIdent)(c)).join(", "));
4124
+ const columnsSql = import_drizzle_orm.sql.raw(columns.map((c) => (0, import_core15.quoteIdent)(c)).join(", "));
4028
4125
  const updateSet = import_drizzle_orm.sql.raw(
4029
- columns.slice(1).map((c) => `${(0, import_core14.quoteIdent)(c)} = excluded.${(0, import_core14.quoteIdent)(c)}`).join(", ")
4126
+ columns.slice(1).map((c) => `${(0, import_core15.quoteIdent)(c)} = excluded.${(0, import_core15.quoteIdent)(c)}`).join(", ")
4030
4127
  );
4031
4128
  for (let i = 0; i < rows.length; i += perStatement) {
4032
4129
  const tuples = rows.slice(i, i + perStatement).map(
@@ -4036,12 +4133,12 @@ var PostgresServerStore = class {
4036
4133
  )})`
4037
4134
  );
4038
4135
  await tx.execute(
4039
- import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(collection))} (${columnsSql}) VALUES ${import_drizzle_orm.sql.join(tuples, import_drizzle_orm.sql.raw(", "))} ON CONFLICT (id) DO UPDATE SET ${updateSet}`
4136
+ import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(collection))} (${columnsSql}) VALUES ${import_drizzle_orm.sql.join(tuples, import_drizzle_orm.sql.raw(", "))} ON CONFLICT (id) DO UPDATE SET ${updateSet}`
4040
4137
  );
4041
4138
  }
4042
4139
  if (hidden.length > 0) {
4043
4140
  await tx.execute(
4044
- import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(collection))} SET _deleted = 1 WHERE id IN (${import_drizzle_orm.sql.join(
4141
+ import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(collection))} SET _deleted = 1 WHERE id IN (${import_drizzle_orm.sql.join(
4045
4142
  hidden.map((id) => import_drizzle_orm.sql`${id}`),
4046
4143
  import_drizzle_orm.sql.raw(", ")
4047
4144
  )})`
@@ -4133,7 +4230,7 @@ var PostgresServerStore = class {
4133
4230
  GROUP BY record_id ORDER BY record_id LIMIT ${FOLD_MIGRATION_BATCH}
4134
4231
  )
4135
4232
  SELECT r.record_id, r.max_seq, f.covered_seq AS covered,
4136
- EXISTS (SELECT 1 FROM ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(collection))} c WHERE c.id = r.record_id) AS has_row,
4233
+ EXISTS (SELECT 1 FROM ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(collection))} c WHERE c.id = r.record_id) AS has_row,
4137
4234
  EXISTS (
4138
4235
  SELECT 1 FROM operations x
4139
4236
  WHERE x.id = f.covered_op_id AND x.delivery_seq = f.covered_seq
@@ -4205,7 +4302,7 @@ var PostgresServerStore = class {
4205
4302
  await this.db.transaction(async (tx) => {
4206
4303
  await tx.execute(import_drizzle_orm.sql`SELECT value FROM delivery_counter WHERE id = 1 FOR UPDATE`);
4207
4304
  const orphans = await tx.execute(
4208
- import_drizzle_orm.sql`SELECT c.id AS id FROM ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(collection))} c
4305
+ import_drizzle_orm.sql`SELECT c.id AS id FROM ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(collection))} c
4209
4306
  WHERE NOT EXISTS (SELECT 1 FROM kora_fold_state f WHERE f.collection = ${collection} AND f.record_id = c.id)
4210
4307
  AND NOT EXISTS (SELECT 1 FROM operations o WHERE o.collection = ${collection} AND o.record_id = c.id)`
4211
4308
  );
@@ -4239,11 +4336,11 @@ var PostgresServerStore = class {
4239
4336
  this.schema?.collections[collection]
4240
4337
  );
4241
4338
  const parts = [
4242
- import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(collection))} WHERE ${whereClause}`
4339
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE ${whereClause}`
4243
4340
  ];
4244
4341
  if (options?.orderBy) {
4245
4342
  const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
4246
- parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${(0, import_core14.quoteIdent)(options.orderBy)} ${dir}`));
4343
+ parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${(0, import_core15.quoteIdent)(options.orderBy)} ${dir}`));
4247
4344
  }
4248
4345
  if (options?.limit !== void 0) {
4249
4346
  parts.push(import_drizzle_orm.sql` LIMIT ${options.limit}`);
@@ -4261,7 +4358,7 @@ var PostgresServerStore = class {
4261
4358
  for (const [key, value] of Object.entries(where)) {
4262
4359
  const kind = collectionDef?.fields[key]?.kind;
4263
4360
  const param = kind && isPgRawTextKind(kind) && typeof value === "string" ? encodePgText(value) : value;
4264
- conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw((0, import_core14.quoteIdent)(key))} = ${param}`);
4361
+ conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw((0, import_core15.quoteIdent)(key))} = ${param}`);
4265
4362
  }
4266
4363
  if (conditions.length === 0) {
4267
4364
  return import_drizzle_orm.sql.raw("1 = 1");
@@ -4486,7 +4583,7 @@ var PostgresServerStore = class {
4486
4583
  for (const column of narrowColumns) {
4487
4584
  await tx.execute(
4488
4585
  import_drizzle_orm.sql.raw(
4489
- `ALTER TABLE ${(0, import_core14.quoteIdent)(column.table_name)} ALTER COLUMN ${(0, import_core14.quoteIdent)(column.column_name)} TYPE BIGINT`
4586
+ `ALTER TABLE ${(0, import_core15.quoteIdent)(column.table_name)} ALTER COLUMN ${(0, import_core15.quoteIdent)(column.column_name)} TYPE BIGINT`
4490
4587
  )
4491
4588
  );
4492
4589
  }
@@ -4695,7 +4792,7 @@ async function createPostgresServerStore(options) {
4695
4792
  }
4696
4793
  return store;
4697
4794
  }
4698
- var ServerStoreUnavailableError = class extends import_core14.KoraError {
4795
+ var ServerStoreUnavailableError = class extends import_core15.KoraError {
4699
4796
  constructor(connectionString, cause) {
4700
4797
  const reason = cause instanceof Error ? cause.message : String(cause);
4701
4798
  super(
@@ -4753,8 +4850,10 @@ var POSTGRES_DRIVER_SPECIFIERS = [
4753
4850
 
4754
4851
  // src/store/sqlite-server-store.ts
4755
4852
  init_cjs_shims();
4853
+ var import_node_fs = require("fs");
4756
4854
  var import_node_module = require("module");
4757
- var import_core15 = require("@korajs/core");
4855
+ var import_node_path = require("path");
4856
+ var import_core16 = require("@korajs/core");
4758
4857
  var import_internal4 = require("@korajs/core/internal");
4759
4858
  var import_drizzle_orm2 = require("drizzle-orm");
4760
4859
 
@@ -5096,14 +5195,14 @@ var SqliteServerStore = class {
5096
5195
  * log has no transform path to `version` (RT-103): those operations would fold as
5097
5196
  * absent, silently erasing them from their records.
5098
5197
  */
5099
- assertTransformCoverage(version, transforms) {
5198
+ assertTransformCoverage(version2, transforms) {
5100
5199
  if (transforms.length === 0) return;
5101
5200
  const rows = this.db.all(
5102
5201
  import_drizzle_orm2.sql`SELECT DISTINCT schema_version AS v FROM operations`
5103
5202
  );
5104
- (0, import_core15.assertOperationTransformCoverage)(
5203
+ (0, import_core16.assertOperationTransformCoverage)(
5105
5204
  rows.map((row) => Number(row.v)),
5106
- version,
5205
+ version2,
5107
5206
  transforms,
5108
5207
  "SQLite server store"
5109
5208
  );
@@ -5329,7 +5428,7 @@ var SqliteServerStore = class {
5329
5428
  const collectionDef = this.schema?.collections[collection];
5330
5429
  if (!collectionDef) return null;
5331
5430
  const rows = txOrDb.all(
5332
- import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE id = ${recordId} LIMIT 1`
5431
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core16.quoteIdent)(collection))} WHERE id = ${recordId} LIMIT 1`
5333
5432
  );
5334
5433
  const row = rows[0];
5335
5434
  if (!row) return null;
@@ -5467,7 +5566,7 @@ var SqliteServerStore = class {
5467
5566
  const chunk = ids.slice(i, i + 500);
5468
5567
  if (chunk.length === 0) continue;
5469
5568
  const rows = this.db.all(
5470
- import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE id IN (${import_drizzle_orm2.sql.join(
5569
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core16.quoteIdent)(collection))} WHERE id IN (${import_drizzle_orm2.sql.join(
5471
5570
  chunk.map((id) => import_drizzle_orm2.sql`${id}`),
5472
5571
  import_drizzle_orm2.sql.raw(", ")
5473
5572
  )})`
@@ -5503,7 +5602,7 @@ var SqliteServerStore = class {
5503
5602
  this.assertCollection(collection);
5504
5603
  const schema = this.schema;
5505
5604
  const collectionDef = schema.collections[collection];
5506
- const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
5605
+ const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core16.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
5507
5606
  const rows = this.db.all(query);
5508
5607
  if (rows.length === 0) return null;
5509
5608
  return this.deserializeRow(rows[0], collectionDef);
@@ -5519,7 +5618,7 @@ var SqliteServerStore = class {
5519
5618
  }
5520
5619
  }
5521
5620
  const whereClause = this.buildWhereClause(where ?? {}, false, schema.collections[collection]);
5522
- const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE ${whereClause}`;
5621
+ const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw((0, import_core16.quoteIdent)(collection))} WHERE ${whereClause}`;
5523
5622
  const rows = this.db.all(query);
5524
5623
  return rows[0]?.cnt ?? 0;
5525
5624
  }
@@ -5591,6 +5690,20 @@ var SqliteServerStore = class {
5591
5690
  );
5592
5691
  return rows[0]?.user_id === userId;
5593
5692
  }
5693
+ async claimUnownedNode(nodeId, userId) {
5694
+ this.assertOpen();
5695
+ if (userId === RELEASED_NODE_OWNER) return false;
5696
+ this.db.run(
5697
+ import_drizzle_orm2.sql`INSERT INTO node_claims (node_id, user_id, claimed_at)
5698
+ VALUES (${nodeId}, ${userId}, ${Date.now()})
5699
+ ON CONFLICT (node_id) DO UPDATE SET user_id = excluded.user_id, claimed_at = excluded.claimed_at
5700
+ WHERE node_claims.user_id = ${RELEASED_NODE_OWNER}`
5701
+ );
5702
+ const rows = this.db.all(
5703
+ import_drizzle_orm2.sql`SELECT user_id FROM node_claims WHERE node_id = ${nodeId} LIMIT 1`
5704
+ );
5705
+ return rows[0]?.user_id === userId;
5706
+ }
5594
5707
  async getNodeClaimOwner(nodeId) {
5595
5708
  this.assertOpen();
5596
5709
  const rows = this.db.all(
@@ -5629,7 +5742,7 @@ var SqliteServerStore = class {
5629
5742
  const collectionDef = this.schema?.collections[collection];
5630
5743
  if (collectionDef) {
5631
5744
  const rows = txOrDb.all(
5632
- import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE id = ${recordId} LIMIT 1`
5745
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core16.quoteIdent)(collection))} WHERE id = ${recordId} LIMIT 1`
5633
5746
  );
5634
5747
  const row = rows[0];
5635
5748
  return row ? this.deserializeRow(row, collectionDef) : null;
@@ -5649,7 +5762,7 @@ var SqliteServerStore = class {
5649
5762
  const schema = this.schema ?? EMPTY_FOLD_SCHEMA;
5650
5763
  const collectionDef = schema.collections[collection];
5651
5764
  const raw = collectionDef ? txOrDb.all(
5652
- import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE id = ${recordId} LIMIT 1`
5765
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core16.quoteIdent)(collection))} WHERE id = ${recordId} LIMIT 1`
5653
5766
  )[0] : void 0;
5654
5767
  let row = null;
5655
5768
  if (raw && collectionDef) {
@@ -5720,7 +5833,7 @@ var SqliteServerStore = class {
5720
5833
  this.db.transaction((tx) => {
5721
5834
  tx.run(import_drizzle_orm2.sql.raw("DELETE FROM kora_fold_state"));
5722
5835
  for (const collection of Object.keys(this.schema?.collections ?? {})) {
5723
- tx.run(import_drizzle_orm2.sql.raw(`DELETE FROM ${(0, import_core15.quoteIdent)(collection)}`));
5836
+ tx.run(import_drizzle_orm2.sql.raw(`DELETE FROM ${(0, import_core16.quoteIdent)(collection)}`));
5724
5837
  }
5725
5838
  });
5726
5839
  if (this.schema) this.foldMigration = this.rematerialize();
@@ -5842,7 +5955,7 @@ var SqliteServerStore = class {
5842
5955
  const row = state ? projectFoldState(state, this.foldOptions) : null;
5843
5956
  if (!row) {
5844
5957
  txOrDb.run(
5845
- import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw((0, import_core15.quoteIdent)(collection))} SET _deleted = 1 WHERE id = ${recordId}`
5958
+ import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw((0, import_core16.quoteIdent)(collection))} SET _deleted = 1 WHERE id = ${recordId}`
5846
5959
  );
5847
5960
  return;
5848
5961
  }
@@ -5874,16 +5987,16 @@ var SqliteServerStore = class {
5874
5987
  updatedAt,
5875
5988
  deleted ? 1 : 0
5876
5989
  ];
5877
- const columnsSql = import_drizzle_orm2.sql.raw(allColumns.map((c) => (0, import_core15.quoteIdent)(c)).join(", "));
5990
+ const columnsSql = import_drizzle_orm2.sql.raw(allColumns.map((c) => (0, import_core16.quoteIdent)(c)).join(", "));
5878
5991
  const valuesSql = import_drizzle_orm2.sql.join(
5879
5992
  values.map((v) => import_drizzle_orm2.sql`${v}`),
5880
5993
  import_drizzle_orm2.sql.raw(", ")
5881
5994
  );
5882
5995
  const updateSet = import_drizzle_orm2.sql.raw(
5883
- allColumns.slice(1).map((c) => `${(0, import_core15.quoteIdent)(c)} = excluded.${(0, import_core15.quoteIdent)(c)}`).join(", ")
5996
+ allColumns.slice(1).map((c) => `${(0, import_core16.quoteIdent)(c)} = excluded.${(0, import_core16.quoteIdent)(c)}`).join(", ")
5884
5997
  );
5885
5998
  txOrDb.run(
5886
- import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw((0, import_core15.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
5999
+ import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw((0, import_core16.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
5887
6000
  );
5888
6001
  }
5889
6002
  /**
@@ -5914,8 +6027,8 @@ var SqliteServerStore = class {
5914
6027
  const key = `${PG_TEXT_CODEC_MIGRATION_KEY}${name}`;
5915
6028
  this.db.transaction((tx) => {
5916
6029
  if (tx.all(import_drizzle_orm2.sql`SELECT 1 AS one FROM kora_server_meta WHERE key = ${key}`).length > 0) return;
5917
- const textColumns = Object.entries(collection.fields).filter(([, descriptor]) => isPgRawTextKind(descriptor.kind)).map(([field]) => (0, import_core15.quoteIdent)(field));
5918
- for (const statement of sqliteTextCodecMigrationSql((0, import_core15.quoteIdent)(name), textColumns)) {
6030
+ const textColumns = Object.entries(collection.fields).filter(([, descriptor]) => isPgRawTextKind(descriptor.kind)).map(([field]) => (0, import_core16.quoteIdent)(field));
6031
+ for (const statement of sqliteTextCodecMigrationSql((0, import_core16.quoteIdent)(name), textColumns)) {
5919
6032
  tx.run(import_drizzle_orm2.sql.raw(statement));
5920
6033
  }
5921
6034
  tx.run(
@@ -5987,7 +6100,7 @@ var SqliteServerStore = class {
5987
6100
  import_drizzle_orm2.sql`SELECT o.record_id AS record_id, MAX(o.delivery_seq) AS max_seq,
5988
6101
  (SELECT f.covered_seq FROM kora_fold_state f WHERE f.collection = o.collection AND f.record_id = o.record_id) AS covered,
5989
6102
  (SELECT f.covered_op_id FROM kora_fold_state f WHERE f.collection = o.collection AND f.record_id = o.record_id) AS covered_op_id,
5990
- EXISTS (SELECT 1 FROM ${import_drizzle_orm2.sql.raw((0, import_core15.quoteIdent)(collection))} c WHERE c.id = o.record_id) AS has_row
6103
+ EXISTS (SELECT 1 FROM ${import_drizzle_orm2.sql.raw((0, import_core16.quoteIdent)(collection))} c WHERE c.id = o.record_id) AS has_row
5991
6104
  FROM operations o
5992
6105
  WHERE o.collection = ${collection} AND o.record_id > ${cursor}
5993
6106
  GROUP BY o.record_id
@@ -6053,7 +6166,7 @@ var SqliteServerStore = class {
6053
6166
  if (!clean) {
6054
6167
  this.db.transaction((tx) => {
6055
6168
  const orphans = tx.all(
6056
- import_drizzle_orm2.sql`SELECT c.id AS id FROM ${import_drizzle_orm2.sql.raw((0, import_core15.quoteIdent)(collection))} c
6169
+ import_drizzle_orm2.sql`SELECT c.id AS id FROM ${import_drizzle_orm2.sql.raw((0, import_core16.quoteIdent)(collection))} c
6057
6170
  WHERE NOT EXISTS (SELECT 1 FROM kora_fold_state f WHERE f.collection = ${collection} AND f.record_id = c.id)
6058
6171
  AND NOT EXISTS (SELECT 1 FROM operations o WHERE o.collection = ${collection} AND o.record_id = c.id)`
6059
6172
  );
@@ -6085,11 +6198,11 @@ var SqliteServerStore = class {
6085
6198
  this.schema?.collections[collection]
6086
6199
  );
6087
6200
  const parts = [
6088
- import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core15.quoteIdent)(collection))} WHERE ${whereClause}`
6201
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core16.quoteIdent)(collection))} WHERE ${whereClause}`
6089
6202
  ];
6090
6203
  if (options?.orderBy) {
6091
6204
  const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
6092
- parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${(0, import_core15.quoteIdent)(options.orderBy)} ${dir}`));
6205
+ parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${(0, import_core16.quoteIdent)(options.orderBy)} ${dir}`));
6093
6206
  }
6094
6207
  if (options?.limit !== void 0) {
6095
6208
  parts.push(import_drizzle_orm2.sql` LIMIT ${options.limit}`);
@@ -6107,7 +6220,7 @@ var SqliteServerStore = class {
6107
6220
  for (const [key, value] of Object.entries(where)) {
6108
6221
  const kind = collectionDef?.fields[key]?.kind;
6109
6222
  const param = kind && isPgRawTextKind(kind) && typeof value === "string" ? encodePgText(value) : value;
6110
- conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw((0, import_core15.quoteIdent)(key))} = ${param}`);
6223
+ conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw((0, import_core16.quoteIdent)(key))} = ${param}`);
6111
6224
  }
6112
6225
  if (conditions.length === 0) {
6113
6226
  return import_drizzle_orm2.sql.raw("1 = 1");
@@ -6593,6 +6706,7 @@ function createSqliteServerStore(options) {
6593
6706
  const Database = esmRequire("better-sqlite3");
6594
6707
  const { drizzle } = esmRequire("drizzle-orm/better-sqlite3");
6595
6708
  const filename = options.filename ?? ":memory:";
6709
+ ensureDatabaseDirectory(filename);
6596
6710
  const sqlite = new Database(filename);
6597
6711
  sqlite.pragma("journal_mode = WAL");
6598
6712
  const db = drizzle(sqlite);
@@ -6602,10 +6716,14 @@ function createSqliteServerStore(options) {
6602
6716
  ...options.instanceId !== void 0 ? { instanceId: options.instanceId } : {}
6603
6717
  });
6604
6718
  }
6719
+ function ensureDatabaseDirectory(filename) {
6720
+ if (filename === "" || filename === ":memory:" || filename.startsWith("file:")) return;
6721
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(filename), { recursive: true });
6722
+ }
6605
6723
 
6606
6724
  // src/transport/http-server-transport.ts
6607
6725
  init_cjs_shims();
6608
- var import_core16 = require("@korajs/core");
6726
+ var import_core17 = require("@korajs/core");
6609
6727
  var DEFAULT_HTTP_MAX_QUEUED_BYTES = 32 * 1024 * 1024;
6610
6728
  var HttpServerTransport = class {
6611
6729
  serializer;
@@ -6677,7 +6795,7 @@ var HttpServerTransport = class {
6677
6795
  }
6678
6796
  receive(payload) {
6679
6797
  if (!this.connected) {
6680
- throw new import_core16.SyncError("HTTP server transport is closed");
6798
+ throw new import_core17.SyncError("HTTP server transport is closed");
6681
6799
  }
6682
6800
  try {
6683
6801
  const message = this.serializer.decode(payload);
@@ -6718,7 +6836,7 @@ var HttpServerTransport = class {
6718
6836
 
6719
6837
  // src/transport/ws-server-transport.ts
6720
6838
  init_cjs_shims();
6721
- var import_core17 = require("@korajs/core");
6839
+ var import_core18 = require("@korajs/core");
6722
6840
  var import_sync2 = require("@korajs/sync");
6723
6841
  var WS_OPEN = 1;
6724
6842
  var DEFAULT_WS_HEARTBEAT_INTERVAL_MS = 25e3;
@@ -6746,7 +6864,7 @@ var WsServerTransport = class {
6746
6864
  }
6747
6865
  send(message) {
6748
6866
  if (this.ws.readyState !== WS_OPEN) {
6749
- throw new import_core17.SyncError("Cannot send message: WebSocket is not open", {
6867
+ throw new import_core18.SyncError("Cannot send message: WebSocket is not open", {
6750
6868
  readyState: this.ws.readyState,
6751
6869
  messageType: message.type
6752
6870
  });
@@ -6825,7 +6943,7 @@ var WsServerTransport = class {
6825
6943
  this.missedPongs = 0;
6826
6944
  try {
6827
6945
  if (typeof data !== "string" && !(data instanceof Uint8Array) && !(data instanceof ArrayBuffer)) {
6828
- throw new import_core17.SyncError("Unsupported WebSocket payload type", {
6946
+ throw new import_core18.SyncError("Unsupported WebSocket payload type", {
6829
6947
  payloadType: typeof data
6830
6948
  });
6831
6949
  }
@@ -6850,8 +6968,8 @@ var WsServerTransport = class {
6850
6968
 
6851
6969
  // src/session/client-session.ts
6852
6970
  init_cjs_shims();
6853
- var import_core29 = require("@korajs/core");
6854
6971
  var import_core30 = require("@korajs/core");
6972
+ var import_core31 = require("@korajs/core");
6855
6973
  var import_internal7 = require("@korajs/core/internal");
6856
6974
  var import_sync4 = require("@korajs/sync");
6857
6975
  var import_sync5 = require("@korajs/sync");
@@ -6859,22 +6977,22 @@ var import_internal8 = require("@korajs/sync/internal");
6859
6977
 
6860
6978
  // src/apply/apply-server-operation.ts
6861
6979
  init_cjs_shims();
6862
- var import_core21 = require("@korajs/core");
6980
+ var import_core22 = require("@korajs/core");
6863
6981
  var import_merge4 = require("@korajs/merge");
6864
6982
 
6865
6983
  // src/constraints/constraint-authority.ts
6866
6984
  init_cjs_shims();
6867
- var import_core19 = require("@korajs/core");
6985
+ var import_core20 = require("@korajs/core");
6868
6986
  var import_merge2 = require("@korajs/merge");
6869
6987
 
6870
6988
  // src/apply/derive-server-op-id.ts
6871
6989
  init_cjs_shims();
6872
- var import_core18 = require("@korajs/core");
6990
+ var import_core19 = require("@korajs/core");
6873
6991
  async function deriveServerOpId(store, parentOpId, ruleId, targetRecordId) {
6874
6992
  if (store.deriveServerOperationId) {
6875
6993
  return store.deriveServerOperationId(parentOpId, ruleId, targetRecordId);
6876
6994
  }
6877
- return (0, import_core18.deriveSideEffectOpId)(parentOpId, ruleId, targetRecordId);
6995
+ return (0, import_core19.deriveSideEffectOpId)(parentOpId, ruleId, targetRecordId);
6878
6996
  }
6879
6997
 
6880
6998
  // src/constraints/server-constraint-context.ts
@@ -6920,12 +7038,12 @@ async function buildCorrection(store, spec, schemaVersion, nextSequence) {
6920
7038
  };
6921
7039
  }
6922
7040
  function stampTimestamp(stamp) {
6923
- return stamp ? import_core19.HybridLogicalClock.deserialize(stamp.t) : null;
7041
+ return stamp ? import_core20.HybridLogicalClock.deserialize(stamp.t) : null;
6924
7042
  }
6925
7043
  function maxTimestamp(values) {
6926
7044
  let best = null;
6927
7045
  for (const value of values) {
6928
- if (value && (best === null || import_core19.HybridLogicalClock.compare(value, best) > 0)) best = value;
7046
+ if (value && (best === null || import_core20.HybridLogicalClock.compare(value, best) > 0)) best = value;
6929
7047
  }
6930
7048
  return best;
6931
7049
  }
@@ -6939,7 +7057,7 @@ function comparePriority(a, b) {
6939
7057
  return left < right ? -1 : left > right ? 1 : 0;
6940
7058
  }
6941
7059
  function chooseWinner(members, constraint) {
6942
- const byStamp = (a, b) => import_core19.HybridLogicalClock.compare(a.key, b.key) || (a.row.id < b.row.id ? -1 : 1);
7060
+ const byStamp = (a, b) => import_core20.HybridLogicalClock.compare(a.key, b.key) || (a.row.id < b.row.id ? -1 : 1);
6943
7061
  const sorted = [...members].sort(byStamp);
6944
7062
  if (constraint.onConflict === "last-write-wins") return sorted[sorted.length - 1];
6945
7063
  if (constraint.onConflict === "priority-field" && constraint.priorityField) {
@@ -6952,8 +7070,11 @@ function chooseWinner(members, constraint) {
6952
7070
  }
6953
7071
  return sorted[0];
6954
7072
  }
7073
+ function groupKeys(constraint) {
7074
+ return [.../* @__PURE__ */ new Set([...constraint.fields, ...Object.keys(constraint.where ?? {})])];
7075
+ }
6955
7076
  function groupWhere(constraint, row) {
6956
- const where = constraint.type === "capacity" ? { ...constraint.where } : {};
7077
+ const where = { ...constraint.where ?? {} };
6957
7078
  for (const field of constraint.fields) {
6958
7079
  const value = row[field];
6959
7080
  if (value === null || value === void 0) return null;
@@ -6962,22 +7083,25 @@ function groupWhere(constraint, row) {
6962
7083
  return where;
6963
7084
  }
6964
7085
  async function undoConstrainedWrite(store, schema, collection, constraintIndex, constraint, loser, winner) {
7086
+ const keys = groupKeys(constraint);
6965
7087
  const ops = await store.getRecordOperations?.(collection, loser.row.id) ?? [];
6966
7088
  const state = await store.getRecordFoldState?.(collection, loser.row.id);
6967
7089
  const newest = stampTimestamp(state?.u ?? null) ?? loser.key;
6968
- const parent = `hlc:${import_core19.HybridLogicalClock.serialize(loser.key)}`;
7090
+ const parent = `hlc:${import_core20.HybridLogicalClock.serialize(loser.key)}`;
6969
7091
  const rule = `constraint:${collection}:${constraintIndex}:${constraint.type}`;
6970
7092
  const transforms = store.getOperationTransforms?.() ?? [];
6971
7093
  const losing = new Set(
6972
7094
  ops.filter((stored) => {
6973
- if (import_core19.HybridLogicalClock.compare(stored.timestamp, loser.key) !== 0) return false;
6974
- const op = (0, import_core19.operationSchemaView)(stored, schema.version, transforms);
6975
- return op !== null && op.data !== null && constraint.fields.some((field) => op.data !== null && field in op.data);
7095
+ if (import_core20.HybridLogicalClock.compare(stored.timestamp, loser.key) !== 0) return false;
7096
+ const op = (0, import_core20.operationSchemaView)(stored, schema.version, transforms);
7097
+ return op !== null && op.data !== null && keys.some((field) => op.data !== null && field in op.data);
6976
7098
  }).map((op) => op.id)
6977
7099
  );
6978
- const without = (0, import_core19.foldRecord)(ops, schema, { exclude: losing, transforms }).state;
6979
- const previous = without && (0, import_core19.isFoldStateLive)(without) ? (0, import_core19.materialize)(without) : null;
6980
- const collides = previous !== null && constraint.fields.every((field) => sameValue(previous[field], winner.row[field]));
7100
+ const without = (0, import_core20.foldRecord)(ops, schema, { exclude: losing, transforms }).state;
7101
+ const previous = without && (0, import_core20.isFoldStateLive)(without) ? (0, import_core20.materialize)(without) : null;
7102
+ const collides = previous !== null && constraint.fields.every((field) => sameValue(previous[field], winner.row[field])) && Object.entries(constraint.where ?? {}).every(
7103
+ ([field, value]) => sameValue(previous[field], value)
7104
+ );
6981
7105
  if (previous === null || collides) {
6982
7106
  return {
6983
7107
  parent,
@@ -6992,10 +7116,17 @@ async function undoConstrainedWrite(store, schema, collection, constraintIndex,
6992
7116
  }
6993
7117
  const data = {};
6994
7118
  const previousData = {};
6995
- for (const field of constraint.fields) {
7119
+ for (const field of keys) {
7120
+ if (sameValue(previous[field], loser.row[field])) continue;
6996
7121
  data[field] = previous[field] ?? null;
6997
7122
  previousData[field] = loser.row[field] ?? null;
6998
7123
  }
7124
+ if (Object.keys(data).length === 0) {
7125
+ for (const field of constraint.fields) {
7126
+ data[field] = previous[field] ?? null;
7127
+ previousData[field] = loser.row[field] ?? null;
7128
+ }
7129
+ }
6999
7130
  return {
7000
7131
  parent,
7001
7132
  rule,
@@ -7014,7 +7145,7 @@ async function uniqueAndCapacityCorrections(store, schema, op) {
7014
7145
  const definition = schema.collections[op.collection];
7015
7146
  if (!definition || op.type === "delete") return [];
7016
7147
  const crossRecord = definition.constraints.filter(
7017
- (constraint) => (constraint.type === "unique" || constraint.type === "capacity") && (op.type === "insert" || constraint.fields.some((field) => op.data !== null && field in op.data))
7148
+ (constraint) => (constraint.type === "unique" || constraint.type === "capacity") && (op.type === "insert" || groupKeys(constraint).some((field) => op.data !== null && field in op.data))
7018
7149
  );
7019
7150
  if (crossRecord.length === 0 || !store.getRecordFieldVersions) return [];
7020
7151
  const row = await store.findRecord(op.collection, op.recordId);
@@ -7040,7 +7171,7 @@ async function uniqueAndCapacityCorrections(store, schema, op) {
7040
7171
  members.push({
7041
7172
  row: candidate,
7042
7173
  versions,
7043
- key: constrainedVersion(versions, constraint.fields)
7174
+ key: constrainedVersion(versions, groupKeys(constraint))
7044
7175
  });
7045
7176
  }
7046
7177
  if (members.length < 2) continue;
@@ -7074,7 +7205,7 @@ async function referentialCorrections(store, schema, op) {
7074
7205
  const parentId = child[relation.field];
7075
7206
  if (typeof parentId !== "string" || parentId.length === 0) continue;
7076
7207
  const parent = await store.getRecordFoldState(relation.to, parentId);
7077
- if (!parent || parent.cr === null || (0, import_core19.isFoldStateLive)(parent)) continue;
7208
+ if (!parent || parent.cr === null || (0, import_core20.isFoldStateLive)(parent)) continue;
7078
7209
  const deletedAt = stampTimestamp(parent.d);
7079
7210
  const childState = await store.getRecordFoldState(op.collection, op.recordId);
7080
7211
  const after = maxTimestamp([deletedAt, stampTimestamp(childState?.u ?? null)]);
@@ -7121,7 +7252,7 @@ async function referentialCorrections(store, schema, op) {
7121
7252
  return specs;
7122
7253
  }
7123
7254
  const deleted = await store.getRecordFoldState(op.collection, op.recordId);
7124
- if (!deleted || deleted.cr === null || (0, import_core19.isFoldStateLive)(deleted) || !deleted.d) return specs;
7255
+ if (!deleted || deleted.cr === null || (0, import_core20.isFoldStateLive)(deleted) || !deleted.d) return specs;
7125
7256
  for (const [name, relation] of relations) {
7126
7257
  if (relation.to !== op.collection || relation.onDelete !== "restrict") continue;
7127
7258
  const children = await store.queryCollection(relation.from, {
@@ -7243,7 +7374,7 @@ init_ingest_validation();
7243
7374
 
7244
7375
  // src/apply/server-side-effect-operation.ts
7245
7376
  init_cjs_shims();
7246
- var import_core20 = require("@korajs/core");
7377
+ var import_core21 = require("@korajs/core");
7247
7378
  async function createServerSideEffectOperation(store, parentOp, effect, schemaVersion, sequenceNumber) {
7248
7379
  const nodeId = store.getNodeId();
7249
7380
  const policy = effect.type === "delete" ? "cascade" : "set-null";
@@ -7282,7 +7413,7 @@ var serverClocks = /* @__PURE__ */ new WeakMap();
7282
7413
  function serverClock(store, after) {
7283
7414
  let clock = serverClocks.get(store);
7284
7415
  if (!clock) {
7285
- clock = new import_core20.HybridLogicalClock(store.getNodeId());
7416
+ clock = new import_core21.HybridLogicalClock(store.getNodeId());
7286
7417
  serverClocks.set(store, clock);
7287
7418
  }
7288
7419
  if (after) clock.advanceTo(after);
@@ -7565,7 +7696,7 @@ function validateOperationShape(op, schema) {
7565
7696
  for (const [field, value] of Object.entries(op.data)) {
7566
7697
  const descriptor = collection.fields[field];
7567
7698
  if (!descriptor) continue;
7568
- const violation2 = (0, import_core21.operationValueViolation)(descriptor, value);
7699
+ const violation2 = (0, import_core22.operationValueViolation)(descriptor, value);
7569
7700
  if (violation2 !== null) {
7570
7701
  return {
7571
7702
  valid: false,
@@ -7614,9 +7745,152 @@ var NoAuthProvider = class {
7614
7745
  }
7615
7746
  };
7616
7747
 
7748
+ // src/awareness/presence-records.ts
7749
+ init_cjs_shims();
7750
+ var CACHE_TTL_MS = 1e3;
7751
+ var MAX_TRACKED_RECORDS = 1e4;
7752
+ var MAX_CONCURRENT_READS = 16;
7753
+ var PresenceRecords = class {
7754
+ constructor(store, now = Date.now) {
7755
+ this.store = store;
7756
+ this.now = now;
7757
+ }
7758
+ store;
7759
+ now;
7760
+ clock = 0;
7761
+ /** The clock value of each tracked record's last touch, oldest first. */
7762
+ lastTouch = /* @__PURE__ */ new Map();
7763
+ /** Touches forgotten to bound memory count as having happened at this clock. */
7764
+ forgottenTouchesAt = 0;
7765
+ inFlight = /* @__PURE__ */ new Map();
7766
+ cache = /* @__PURE__ */ new Map();
7767
+ running = 0;
7768
+ waiting = [];
7769
+ /**
7770
+ * Record that writes to these records committed: reads that began before are no
7771
+ * longer current. Null means any record may have changed.
7772
+ *
7773
+ * @param keys - Record keys from {@link presenceRecordKey}, or null for every record
7774
+ */
7775
+ touch(keys) {
7776
+ this.clock += 1;
7777
+ if (keys === null) {
7778
+ this.forgottenTouchesAt = this.clock;
7779
+ this.lastTouch.clear();
7780
+ this.cache.clear();
7781
+ return;
7782
+ }
7783
+ for (const key of keys) {
7784
+ this.lastTouch.delete(key);
7785
+ this.lastTouch.set(key, this.clock);
7786
+ this.cache.delete(key);
7787
+ }
7788
+ this.forget(this.lastTouch, (value) => {
7789
+ this.forgottenTouchesAt = Math.max(this.forgottenTouchesAt, value);
7790
+ });
7791
+ }
7792
+ /**
7793
+ * True when a write touched the record after a read that began at `asOf`.
7794
+ *
7795
+ * @param key - Record key from {@link presenceRecordKey}
7796
+ * @param asOf - The read's {@link PresenceRecordRead.asOf}
7797
+ */
7798
+ touchedSince(key, asOf) {
7799
+ return (this.lastTouch.get(key) ?? this.forgottenTouchesAt) > asOf;
7800
+ }
7801
+ /**
7802
+ * A current read of the record that is still fresh enough to reuse, without a store
7803
+ * read; undefined when the record must be read.
7804
+ *
7805
+ * @param collection - The record's collection
7806
+ * @param recordId - The record's id
7807
+ */
7808
+ peek(collection, recordId) {
7809
+ const key = presenceRecordKey(collection, recordId);
7810
+ const cached = this.cache.get(key);
7811
+ if (!cached) return void 0;
7812
+ if (this.now() - cached.atMs >= CACHE_TTL_MS || this.touchedSince(key, cached.startedAt)) {
7813
+ this.cache.delete(key);
7814
+ return void 0;
7815
+ }
7816
+ return { stored: cached.stored, asOf: cached.startedAt };
7817
+ }
7818
+ /**
7819
+ * Read the record from the store, joining a read of it already running when no
7820
+ * write touched the record since that read began. Rejects when the store read fails.
7821
+ *
7822
+ * @param collection - The record's collection
7823
+ * @param recordId - The record's id
7824
+ */
7825
+ async read(collection, recordId) {
7826
+ const key = presenceRecordKey(collection, recordId);
7827
+ let entry = this.inFlight.get(key);
7828
+ if (!entry || this.touchedSince(key, entry.startedAt)) {
7829
+ const startedAt = this.clock;
7830
+ const created = {
7831
+ startedAt,
7832
+ promise: this.readStore(collection, recordId)
7833
+ };
7834
+ entry = created;
7835
+ this.inFlight.set(key, created);
7836
+ void created.promise.then(
7837
+ (stored2) => {
7838
+ if (this.inFlight.get(key) === created) this.inFlight.delete(key);
7839
+ if (this.touchedSince(key, startedAt)) return;
7840
+ this.cache.delete(key);
7841
+ this.cache.set(key, { stored: stored2, startedAt, atMs: this.now() });
7842
+ this.forget(this.cache, () => {
7843
+ });
7844
+ },
7845
+ () => {
7846
+ if (this.inFlight.get(key) === created) this.inFlight.delete(key);
7847
+ }
7848
+ );
7849
+ }
7850
+ const stored = await entry.promise;
7851
+ return { stored, asOf: entry.startedAt };
7852
+ }
7853
+ /** Forget every read and touch (the server stopped). */
7854
+ clear() {
7855
+ this.touch(null);
7856
+ this.inFlight.clear();
7857
+ }
7858
+ async readStore(collection, recordId) {
7859
+ if (this.running < MAX_CONCURRENT_READS) {
7860
+ this.running += 1;
7861
+ } else {
7862
+ await new Promise((resolve2) => this.waiting.push(resolve2));
7863
+ }
7864
+ try {
7865
+ const rows = await this.store.queryCollection(collection, {
7866
+ where: { id: recordId },
7867
+ includeDeleted: true,
7868
+ limit: 1
7869
+ });
7870
+ return rows[0] ?? null;
7871
+ } finally {
7872
+ const next = this.waiting.shift();
7873
+ if (next) next();
7874
+ else this.running -= 1;
7875
+ }
7876
+ }
7877
+ forget(map, onForget) {
7878
+ while (map.size > MAX_TRACKED_RECORDS) {
7879
+ const oldest = map.keys().next();
7880
+ if (oldest.done) return;
7881
+ const value = map.get(oldest.value);
7882
+ map.delete(oldest.value);
7883
+ if (value !== void 0) onForget(value);
7884
+ }
7885
+ }
7886
+ };
7887
+ function presenceRecordKey(collection, recordId) {
7888
+ return `${collection}\0${recordId}`;
7889
+ }
7890
+
7617
7891
  // src/encryption/key-record-service.ts
7618
7892
  init_cjs_shims();
7619
- var import_core22 = require("@korajs/core");
7893
+ var import_core23 = require("@korajs/core");
7620
7894
  var import_sync3 = require("@korajs/sync");
7621
7895
  var ANONYMOUS_KEY_OWNER = "*";
7622
7896
  function userKeyOwner(userId) {
@@ -7646,7 +7920,7 @@ var EncryptionKeyService = class {
7646
7920
  const reply = (status, record, text3) => ({
7647
7921
  response: {
7648
7922
  type: "encryption-key-response",
7649
- messageId: (0, import_core22.generateUUIDv7)(),
7923
+ messageId: (0, import_core23.generateUUIDv7)(),
7650
7924
  requestId: message.requestId,
7651
7925
  keyring: message.keyring,
7652
7926
  status,
@@ -7756,7 +8030,7 @@ init_cjs_shims();
7756
8030
 
7757
8031
  // src/richtext/blob-access-index.ts
7758
8032
  init_cjs_shims();
7759
- var import_core23 = require("@korajs/core");
8033
+ var import_core24 = require("@korajs/core");
7760
8034
  var DEFAULT_BLOB_ACCESS_CACHE_TTL_MS = 5e3;
7761
8035
  var MAX_KNOWN_MANIFESTS = 1e4;
7762
8036
  var MAX_MEMORY_BLOB_OWNERS = 1e5;
@@ -7970,7 +8244,7 @@ var BlobAccessIndex = class {
7970
8244
  bytes = null;
7971
8245
  }
7972
8246
  if (bytes === null) continue;
7973
- if (await (0, import_core23.hashBlob)(bytes) !== manifestHash) continue;
8247
+ if (await (0, import_core24.hashBlob)(bytes) !== manifestHash) continue;
7974
8248
  this.observeVerifiedBytes(manifestHash, bytes);
7975
8249
  }
7976
8250
  }
@@ -8050,7 +8324,7 @@ function asBlobRef(value) {
8050
8324
  return null;
8051
8325
  }
8052
8326
  }
8053
- return (0, import_core23.isBlobRef)(candidate) ? candidate : null;
8327
+ return (0, import_core24.isBlobRef)(candidate) ? candidate : null;
8054
8328
  }
8055
8329
  function parseManifestChunks(bytes) {
8056
8330
  if (bytes.byteLength > 4 * 1024 * 1024) return null;
@@ -8151,8 +8425,8 @@ function asRecord2(value) {
8151
8425
 
8152
8426
  // src/scopes/resolve-session-scopes.ts
8153
8427
  init_cjs_shims();
8154
- var import_core24 = require("@korajs/core");
8155
- var ScopeRequiredError = class extends import_core24.KoraError {
8428
+ var import_core25 = require("@korajs/core");
8429
+ var ScopeRequiredError = class extends import_core25.KoraError {
8156
8430
  constructor(denied) {
8157
8431
  super(
8158
8432
  `Sync scope required: ${denied.map((d) => `"${d.collection}" needs ${d.missingKeys.map((k) => `"${k}"`).join(", ")}`).join("; ")}. The server could not derive these values from the verified token.`,
@@ -8176,7 +8450,7 @@ function resolveSessionScopeGrant(schema, options) {
8176
8450
  const denied = [];
8177
8451
  let grant;
8178
8452
  if (authScopes !== void 0 || scopeValues !== void 0) {
8179
- const claims = mergeClaims((0, import_core24.getScopeClaims)(authScopes), scopeValues);
8453
+ const claims = mergeClaims((0, import_core25.getScopeClaims)(authScopes), scopeValues);
8180
8454
  const explicit = withoutReservedKeys(authScopes ?? {});
8181
8455
  assertScopeValuesDefined(explicit);
8182
8456
  if (!schema && claims && Object.keys(explicit).length === 0) {
@@ -8212,10 +8486,10 @@ function resolveSessionScopeGrant(schema, options) {
8212
8486
  }
8213
8487
  function bindClaimsToSchema(schema, claims, denied) {
8214
8488
  const result = {};
8215
- const partialSync = (0, import_core24.hasSchemaSyncRules)(schema);
8489
+ const partialSync = (0, import_core25.hasSchemaSyncRules)(schema);
8216
8490
  for (const collection of Object.keys(schema.collections)) {
8217
- if (partialSync && !(0, import_core24.isCollectionSyncScoped)(schema, collection)) continue;
8218
- const bindings = (0, import_core24.getCollectionScopeBindings)(schema, collection);
8491
+ if (partialSync && !(0, import_core25.isCollectionSyncScoped)(schema, collection)) continue;
8492
+ const bindings = (0, import_core25.getCollectionScopeBindings)(schema, collection);
8219
8493
  if (!bindings) {
8220
8494
  result[collection] = {};
8221
8495
  continue;
@@ -8271,7 +8545,7 @@ function mergeClaims(fromGrant, scopeValues) {
8271
8545
  function withoutReservedKeys(scopes) {
8272
8546
  const result = {};
8273
8547
  for (const [collection, predicate] of Object.entries(scopes)) {
8274
- if (collection === import_core24.SCOPE_CLAIMS_KEY) continue;
8548
+ if (collection === import_core25.SCOPE_CLAIMS_KEY) continue;
8275
8549
  result[collection] = { ...predicate ?? {} };
8276
8550
  }
8277
8551
  return result;
@@ -8301,7 +8575,7 @@ init_server_store();
8301
8575
 
8302
8576
  // src/session/duplicate-identity.ts
8303
8577
  init_cjs_shims();
8304
- var import_core25 = require("@korajs/core");
8578
+ var import_core26 = require("@korajs/core");
8305
8579
  var import_internal5 = require("@korajs/core/internal");
8306
8580
  var import_internal6 = require("@korajs/sync/internal");
8307
8581
  var FORGED_DUPLICATE_CODE = "FORGED_DUPLICATE";
@@ -8313,11 +8587,11 @@ function isSameStoredOperation(upload, stored) {
8313
8587
  const b = contentKey(stored, storedVersion);
8314
8588
  return a !== null && a === b;
8315
8589
  }
8316
- function contentKey(op, version) {
8590
+ function contentKey(op, version2) {
8317
8591
  let body;
8318
8592
  try {
8319
- body = (0, import_core25.canonicalizeOperationBody)(
8320
- version === 1 ? (0, import_core25.canonicalizeLegacyOperation)({ ...op, hashVersion: void 0 }) : op
8593
+ body = (0, import_core26.canonicalizeOperationBody)(
8594
+ version2 === 1 ? (0, import_core26.canonicalizeLegacyOperation)({ ...op, hashVersion: void 0 }) : op
8321
8595
  );
8322
8596
  } catch {
8323
8597
  return null;
@@ -8328,12 +8602,12 @@ function contentKey(op, version) {
8328
8602
  collection: op.collection,
8329
8603
  recordId: op.recordId,
8330
8604
  data: body.data,
8331
- timestamp: import_core25.HybridLogicalClock.serialize(op.timestamp),
8605
+ timestamp: import_core26.HybridLogicalClock.serialize(op.timestamp),
8332
8606
  nodeId: op.nodeId,
8333
8607
  atomicOps,
8334
8608
  sealed: op.encrypted !== void 0
8335
8609
  };
8336
- if (version === 2) {
8610
+ if (version2 === 2) {
8337
8611
  key.previousData = body.previousData;
8338
8612
  key.sequenceNumber = op.sequenceNumber;
8339
8613
  key.causalDeps = [...op.causalDeps].sort();
@@ -8354,7 +8628,7 @@ async function isRewrittenEcho(upload, stored, schema) {
8354
8628
  return !await (0, import_internal6.operationIdMatches)(upload, schema);
8355
8629
  }
8356
8630
  function sameHeader(a, b) {
8357
- return a.nodeId === b.nodeId && a.type === b.type && a.collection === b.collection && a.recordId === b.recordId && import_core25.HybridLogicalClock.serialize(a.timestamp) === import_core25.HybridLogicalClock.serialize(b.timestamp);
8631
+ return a.nodeId === b.nodeId && a.type === b.type && a.collection === b.collection && a.recordId === b.recordId && import_core26.HybridLogicalClock.serialize(a.timestamp) === import_core26.HybridLogicalClock.serialize(b.timestamp);
8358
8632
  }
8359
8633
 
8360
8634
  // src/session/client-session.ts
@@ -8362,14 +8636,14 @@ init_operation_validation();
8362
8636
 
8363
8637
  // src/session/scope-entry.ts
8364
8638
  init_cjs_shims();
8365
- var import_core26 = require("@korajs/core");
8639
+ var import_core27 = require("@korajs/core");
8366
8640
  var SCOPE_ENTRY_NODE_ID = "kora:scope-entry";
8367
8641
  async function buildScopeEntryOperation(input) {
8368
8642
  const { trigger, row, schema } = input;
8369
8643
  const definition = schema.collections[trigger.collection];
8370
8644
  const data = {};
8371
8645
  const foldState = input.foldState ?? null;
8372
- const versioned = input.fieldVersions ?? (foldState ? (0, import_core26.getFoldFieldVersions)(foldState) : null) ?? null;
8646
+ const versioned = input.fieldVersions ?? (foldState ? (0, import_core27.getFoldFieldVersions)(foldState) : null) ?? null;
8373
8647
  const fieldVersions = {};
8374
8648
  for (const field of Object.keys(definition?.fields ?? {})) {
8375
8649
  if (!(field in row)) continue;
@@ -8380,7 +8654,7 @@ async function buildScopeEntryOperation(input) {
8380
8654
  fieldVersions[field] = { ...versioned.fields[field] ?? versioned.created };
8381
8655
  }
8382
8656
  }
8383
- const id = await (0, import_core26.hashBlob)(
8657
+ const id = await (0, import_core27.hashBlob)(
8384
8658
  new TextEncoder().encode(
8385
8659
  `scope-entry\0${trigger.collection}\0${trigger.recordId}\0${trigger.id}`
8386
8660
  )
@@ -8413,15 +8687,15 @@ function toWireValue(value) {
8413
8687
 
8414
8688
  // src/session/session-operation-limits.ts
8415
8689
  init_cjs_shims();
8416
- var import_core27 = require("@korajs/core");
8417
8690
  var import_core28 = require("@korajs/core");
8418
- var DEFAULT_MAX_OPERATION_BYTES = import_core27.DEFAULT_MAX_OPERATION_BYTES;
8691
+ var import_core29 = require("@korajs/core");
8692
+ var DEFAULT_MAX_OPERATION_BYTES = import_core28.DEFAULT_MAX_OPERATION_BYTES;
8419
8693
  var DEFAULT_MAX_OPS_PER_MINUTE = 600;
8420
8694
  var DEFAULT_MAX_BLOB_REQUESTS_PER_MINUTE = 6e3;
8421
8695
  var BATCH_LOOKUP_RATE_COST = 1;
8422
8696
  var DEFAULT_MAX_OPS_PER_BATCH = 1e3;
8423
8697
  function measureOperationBytes(op) {
8424
- return (0, import_core28.measureOperationBytes)(op);
8698
+ return (0, import_core29.measureOperationBytes)(op);
8425
8699
  }
8426
8700
  function validateOperationSize(op, maxBytes = DEFAULT_MAX_OPERATION_BYTES) {
8427
8701
  const bytes = measureOperationBytes(op);
@@ -8542,7 +8816,7 @@ function generateNodeToken() {
8542
8816
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
8543
8817
  }
8544
8818
  async function nodeTokenHash(nodeToken) {
8545
- return (0, import_core30.hashBlob)(new TextEncoder().encode(nodeToken));
8819
+ return (0, import_core31.hashBlob)(new TextEncoder().encode(nodeToken));
8546
8820
  }
8547
8821
  function samePrincipal(a, b) {
8548
8822
  if (a.anonymous === true || b.anonymous === true)
@@ -8558,24 +8832,24 @@ function revocationMatches(principal, filter) {
8558
8832
  return true;
8559
8833
  }
8560
8834
  var warnedUnscopedProviders = /* @__PURE__ */ new WeakSet();
8561
- function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
8562
- if (!auth || auth instanceof NoAuthProvider) {
8563
- return;
8564
- }
8565
- if (resolvedScopes) {
8566
- return;
8567
- }
8568
- if (!schema || Object.keys(schema.collections).length === 0) {
8569
- return;
8570
- }
8835
+ function warnIfMultiTenantWithoutScopes(auth) {
8571
8836
  if (warnedUnscopedProviders.has(auth)) {
8572
8837
  return;
8573
8838
  }
8574
8839
  warnedUnscopedProviders.add(auth);
8575
8840
  console.warn(
8576
- "[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.)"
8841
+ "[kora] An authenticated session resolved to no sync scopes: its grant restricts no collection, so every user will sync every other user's data. Return per-user sync scopes from your auth provider (for example KoraAuthProvider's resolveScopes or scopeValues) and declare schema sync rules that bind them, to isolate tenants. If all signed-in users are meant to share the same data, set unscopedSharing: 'allow' in the sync server options to silence this; 'refuse' turns it into a handshake refusal."
8577
8842
  );
8578
8843
  }
8844
+ function sharesEveryUsersData(auth, principal, downlink, schema) {
8845
+ if (downlink === null) return false;
8846
+ if (!auth || auth instanceof NoAuthProvider) return false;
8847
+ if (!principal || principal.anonymous === true) return false;
8848
+ if (!schema || Object.keys(schema.collections).length === 0) return false;
8849
+ if (downlink === void 0) return true;
8850
+ const collections = Object.values(downlink);
8851
+ return collections.length > 0 && collections.every((predicate) => Object.keys(predicate).length === 0);
8852
+ }
8579
8853
  function stableStringify(value) {
8580
8854
  if (Array.isArray(value)) {
8581
8855
  return `[${value.map((item) => stableStringify(item)).join(",")}]`;
@@ -8587,10 +8861,16 @@ function stableStringify(value) {
8587
8861
  }
8588
8862
  return JSON.stringify(value);
8589
8863
  }
8590
- function sameScopeMap(a, b) {
8591
- const normalize = (value) => value && typeof value === "object" ? normalizeScopeMap(value) : value;
8864
+ function sameNormalizedScopeMap(a, b) {
8865
+ if (a === b) return true;
8866
+ return stableStringify(a ?? null) === stableStringify(b ?? null);
8867
+ }
8868
+ function sameScopeMap(a, b, maxValues) {
8869
+ const normalize = (value) => value && typeof value === "object" ? normalizeScopeMap(value, maxValues) : value;
8592
8870
  return stableStringify(normalize(a) ?? null) === stableStringify(normalize(b) ?? null);
8593
8871
  }
8872
+ var MAX_PRESENCE_LOOKUPS_PER_MINUTE = 1200;
8873
+ var MAX_PRESENCE_REREADS = 2;
8594
8874
  var DEFAULT_MAX_BLOB_CHUNK_BYTES = 1024 * 1024;
8595
8875
  var DEFAULT_MAX_BLOB_BYTES_PER_SESSION = 256 * 1024 * 1024;
8596
8876
  var ClientSession = class _ClientSession {
@@ -8614,6 +8894,8 @@ var ClientSession = class _ClientSession {
8614
8894
  /** A provisional anonymous claim awaiting the device's confirmation (RT-21). */
8615
8895
  pendingNodeClaim = null;
8616
8896
  allowLegacyAnonymousClaims;
8897
+ deviceNodeHandover;
8898
+ unscopedSharing;
8617
8899
  anonymousClaimTtlMs;
8618
8900
  isNodeLive;
8619
8901
  /** The credential presented at handshake, kept to re-validate the session (RT-18). */
@@ -8686,7 +8968,6 @@ var ClientSession = class _ClientSession {
8686
8968
  * `collection\u0000id`; null marks a record known to be absent. Only set while a
8687
8969
  * chunk is filtered.
8688
8970
  */
8689
- recordLookupCache = null;
8690
8971
  deliveryHighWaterBytes;
8691
8972
  handshakeTimeoutMs;
8692
8973
  handshakeTimer = null;
@@ -8737,6 +9018,15 @@ var ClientSession = class _ClientSession {
8737
9018
  maxOperationBytes;
8738
9019
  maxOpsPerMinute;
8739
9020
  maxOpsPerBatch;
9021
+ maxScopePredicateValues;
9022
+ /** {@link getScopePartitionKey}, cached for the scope map it was computed from. */
9023
+ scopePartitionKeyCache = null;
9024
+ /** Store reads for presence cursors (F16), per minute. */
9025
+ presenceLookupLimiter = new SessionRateLimiter(MAX_PRESENCE_LOOKUPS_PER_MINUTE);
9026
+ /** Reads the records presence cursors name (shared by the server's sessions). */
9027
+ presenceRecords;
9028
+ /** A {@link refreshScopes} request arrived during the handshake: re-check once streaming. */
9029
+ scopeRefreshPending = false;
8740
9030
  rateLimiter;
8741
9031
  /** Separate budget for blob chunk requests (RT-24). */
8742
9032
  blobRateLimiter;
@@ -8789,6 +9079,7 @@ var ClientSession = class _ClientSession {
8789
9079
  this.operationTransforms = options.operationTransforms ?? [];
8790
9080
  this.onRelay = options.onRelay ?? null;
8791
9081
  this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
9082
+ this.presenceRecords = options.presenceRecords ?? new PresenceRecords(options.store);
8792
9083
  this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
8793
9084
  this.onBlobChunkRequest = options.onBlobChunkRequest ?? null;
8794
9085
  this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
@@ -8815,10 +9106,13 @@ var ClientSession = class _ClientSession {
8815
9106
  options.blobRequestWindowMs ?? 6e4
8816
9107
  );
8817
9108
  this.maxOpsPerBatch = options.maxOpsPerBatch ?? DEFAULT_MAX_OPS_PER_BATCH;
9109
+ this.maxScopePredicateValues = options.maxScopePredicateValues ?? DEFAULT_MAX_SCOPE_PREDICATE_VALUES;
8818
9110
  this.validateOperation = options.validateOperation ?? null;
8819
9111
  this.koraContext = options.koraContext ?? null;
8820
9112
  this.blobAccess = options.blobAccess ?? null;
8821
9113
  this.allowLegacyAnonymousClaims = options.allowLegacyAnonymousClaims ?? true;
9114
+ this.deviceNodeHandover = options.deviceNodeHandover ?? true;
9115
+ this.unscopedSharing = options.unscopedSharing ?? "warn";
8822
9116
  this.anonymousClaimTtlMs = options.anonymousClaimTtlMs ?? DEFAULT_ANONYMOUS_CLAIM_TTL_MS;
8823
9117
  this.isNodeLive = options.isNodeLive ?? null;
8824
9118
  this.deliveryHighWaterBytes = options.deliveryHighWaterBytes ?? DEFAULT_DELIVERY_HIGH_WATER_BYTES;
@@ -8919,7 +9213,7 @@ var ClientSession = class _ClientSession {
8919
9213
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
8920
9214
  const msg = {
8921
9215
  type: "operation-batch",
8922
- messageId: (0, import_core30.generateUUIDv7)(),
9216
+ messageId: (0, import_core31.generateUUIDv7)(),
8923
9217
  operations: serializedOps,
8924
9218
  ...retractions.length > 0 ? { retractions } : {},
8925
9219
  isFinal: true,
@@ -9218,6 +9512,44 @@ var ClientSession = class _ClientSession {
9218
9512
  this.terminate(expired ? "AUTH_EXPIRED" : "AUTH_REVOKED");
9219
9513
  return "terminated";
9220
9514
  }
9515
+ /**
9516
+ * Re-resolve this session's grant on request (`KoraSyncServer.refreshScopes`), for
9517
+ * the user named. A streaming session whose principal is that user re-authenticates
9518
+ * now and ends with a retriable `SCOPE_CHANGED` when its grant changed (or
9519
+ * `AUTH_REVOKED` when the credential is no longer accepted). A session still in its
9520
+ * handshake may have resolved its grant from membership read before the change, so
9521
+ * it re-checks as soon as it reaches streaming; a session whose principal is not
9522
+ * known yet does the same (the re-check is a no-op for a grant that did not change).
9523
+ *
9524
+ * @param userId - The user whose grant changed
9525
+ * @returns The revalidation outcome, `'deferred'` when the check waits for the
9526
+ * handshake, or `'skipped'` for another user's (or a closed) session
9527
+ */
9528
+ async refreshScopes(userId) {
9529
+ if (this.state === "closed") return "skipped";
9530
+ const principal = this.principal;
9531
+ if (principal && principal.userId !== userId) return "skipped";
9532
+ if (!principal || this.state !== "streaming" && this.state !== "syncing") {
9533
+ this.scopeRefreshPending = true;
9534
+ return "deferred";
9535
+ }
9536
+ return this.revalidateCredential();
9537
+ }
9538
+ /** Run a re-check deferred by {@link refreshScopes} once the session streams. */
9539
+ runDeferredScopeRefresh() {
9540
+ if (!this.scopeRefreshPending) return;
9541
+ this.scopeRefreshPending = false;
9542
+ void this.revalidateCredential().then((outcome) => {
9543
+ if (outcome !== "error") return;
9544
+ this.logger?.log({
9545
+ timestamp: Date.now(),
9546
+ level: "warn",
9547
+ event: "session.revalidation_failed",
9548
+ sessionId: this.sessionId,
9549
+ details: { reason: "deferred scope refresh" }
9550
+ });
9551
+ });
9552
+ }
9221
9553
  /**
9222
9554
  * True when `context` (a fresh authentication of this session's principal)
9223
9555
  * resolves to download or upload scopes other than the ones this session holds.
@@ -9227,7 +9559,7 @@ var ClientSession = class _ClientSession {
9227
9559
  if (this.state !== "streaming" && this.state !== "syncing") return false;
9228
9560
  const resolution = this.computeSessionScopes(context, this.handshakeScope);
9229
9561
  if (!resolution.ok) return true;
9230
- return !sameScopeMap(resolution.downlink, this.authContext?.downlinkScopes) || !sameScopeMap(resolution.uplink, this.authContext?.uplinkScopes);
9562
+ return !sameNormalizedScopeMap(resolution.downlink, this.authContext?.downlinkScopes) || !sameNormalizedScopeMap(resolution.uplink, this.authContext?.uplinkScopes);
9231
9563
  }
9232
9564
  /**
9233
9565
  * End this session because an admin released its node id (RT-5). Retriable: a
@@ -9277,7 +9609,7 @@ var ClientSession = class _ClientSession {
9277
9609
  this.appHeartbeatTimer = setInterval(() => {
9278
9610
  if (this.state === "closed") return;
9279
9611
  if (Date.now() - this.lastClientSendAtMs < interval / 2) return;
9280
- this.sendToClient({ type: "heartbeat", messageId: (0, import_core30.generateUUIDv7)() });
9612
+ this.sendToClient({ type: "heartbeat", messageId: (0, import_core31.generateUUIDv7)() });
9281
9613
  }, interval);
9282
9614
  this.appHeartbeatTimer.unref?.();
9283
9615
  }
@@ -9370,18 +9702,35 @@ var ClientSession = class _ClientSession {
9370
9702
  const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
9371
9703
  return recordMatchesScopes(collection, { ...storedRecord ?? {}, id: recordId }, scopes);
9372
9704
  }
9373
- /**
9374
- * A stable key for this session's download scope. Presence (awareness) is only
9375
- * relayed between sessions that share exactly the same key, so it never crosses a
9376
- * tenant boundary. Unscoped sessions share the key `"*"`.
9377
- */
9378
9705
  /** This session's download scope, or undefined when it is unscoped. */
9379
9706
  getDownlinkScopes() {
9380
9707
  return this.authContext?.downlinkScopes ?? this.authContext?.scopes;
9381
9708
  }
9709
+ /**
9710
+ * A stable key for this session's download scope: sessions with the same key see
9711
+ * exactly the same data. Unscoped sessions share the key `"*"`. Cached per scope
9712
+ * map, since a large `$in` grant makes the key costly and the relays read it often.
9713
+ */
9382
9714
  getScopePartitionKey() {
9383
9715
  const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
9384
- return scopes ? stableStringify(normalizeScopeMap(scopes)) : "*";
9716
+ if (!scopes) return "*";
9717
+ if (this.scopePartitionKeyCache?.scopes !== scopes) {
9718
+ this.scopePartitionKeyCache = {
9719
+ scopes,
9720
+ key: stableStringify(normalizeScopeMap(scopes, this.maxScopePredicateValues))
9721
+ };
9722
+ }
9723
+ return this.scopePartitionKeyCache.key;
9724
+ }
9725
+ /**
9726
+ * Partition for presence without a cursor (a "who is online" state that names no
9727
+ * record): only sessions with the same key see each other's. Signed-in sessions
9728
+ * use their download scope key; every anonymous session is its own partition,
9729
+ * because sessions holding the same anonymous grant are unrelated people.
9730
+ */
9731
+ getPresencePartitionKey() {
9732
+ const key = this.getScopePartitionKey();
9733
+ return this.principal?.anonymous === true ? `anonymous:${this.sessionId}|${key}` : key;
9385
9734
  }
9386
9735
  /**
9387
9736
  * Get the transport for this session.
@@ -9446,7 +9795,7 @@ var ClientSession = class _ClientSession {
9446
9795
  case "heartbeat":
9447
9796
  break;
9448
9797
  case "awareness-update":
9449
- this.handleAwarenessUpdate(message);
9798
+ await this.handleAwarenessUpdate(message);
9450
9799
  break;
9451
9800
  case "yjs-doc-update":
9452
9801
  await this.handleYjsDocUpdate(message);
@@ -9497,7 +9846,7 @@ var ClientSession = class _ClientSession {
9497
9846
  if (this.state !== "syncing" && this.state !== "streaming") return;
9498
9847
  this.sendToClient({
9499
9848
  type: "encryption-key-response",
9500
- messageId: (0, import_core30.generateUUIDv7)(),
9849
+ messageId: (0, import_core31.generateUUIDv7)(),
9501
9850
  keyring,
9502
9851
  status: "ok",
9503
9852
  record
@@ -9507,7 +9856,7 @@ var ClientSession = class _ClientSession {
9507
9856
  const refuse = (status, text3) => {
9508
9857
  this.sendToClient({
9509
9858
  type: "encryption-key-response",
9510
- messageId: (0, import_core30.generateUUIDv7)(),
9859
+ messageId: (0, import_core31.generateUUIDv7)(),
9511
9860
  requestId: message.requestId,
9512
9861
  keyring: message.keyring,
9513
9862
  status,
@@ -9557,7 +9906,7 @@ var ClientSession = class _ClientSession {
9557
9906
  );
9558
9907
  return;
9559
9908
  }
9560
- const actual = await (0, import_core30.hashBlob)(bytes);
9909
+ const actual = await (0, import_core31.hashBlob)(bytes);
9561
9910
  if (actual !== message.hash) {
9562
9911
  return;
9563
9912
  }
@@ -9622,10 +9971,15 @@ var ClientSession = class _ClientSession {
9622
9971
  if (!store.getNodeClaimOwner || !store.replaceNodeClaim) return { ok: false };
9623
9972
  const current = await store.getNodeClaimOwner(nodeId);
9624
9973
  if (current === null) {
9625
- if (!this.allowLegacyAnonymousClaims || !store.releaseNodeClaim) return { ok: false };
9974
+ if (!this.allowLegacyAnonymousClaims) return { ok: false };
9626
9975
  if (this.isNodeLive?.(nodeId, this.sessionId) === true) return { ok: false };
9627
- if (!await store.releaseNodeClaim(nodeId)) return { ok: false };
9628
- if (!await store.claimNode(nodeId, owner)) return { ok: false };
9976
+ if (store.claimUnownedNode) {
9977
+ if (!await store.claimUnownedNode(nodeId, owner)) return { ok: false };
9978
+ } else {
9979
+ if (!store.releaseNodeClaim) return { ok: false };
9980
+ if (!await store.releaseNodeClaim(nodeId)) return { ok: false };
9981
+ if (!await store.claimNode(nodeId, owner)) return { ok: false };
9982
+ }
9629
9983
  this.warnLegacyAnonymousClaim(nodeId);
9630
9984
  return issued;
9631
9985
  }
@@ -9647,6 +10001,68 @@ var ClientSession = class _ClientSession {
9647
10001
  if (legacy) this.warnLegacyAnonymousClaim(nodeId);
9648
10002
  return issued;
9649
10003
  }
10004
+ /**
10005
+ * Automatic device handover after an upgrade from beta.12 (F1). A node with
10006
+ * history but no owner (written before node claims existed) or released by an
10007
+ * admin is claimed for the signed-in user when the node id IS the device id the
10008
+ * auth provider verified for this credential (`metadata.deviceId`; the built-in
10009
+ * provider takes it from the token's `dev` claim, which names a device registered
10010
+ * to that user). `@korajs/auth` clients use that device id as their node id and
10011
+ * cannot change it, so without this they stay refused until an operator binds the
10012
+ * node. The claim is one atomic store step; a node another principal owns is never
10013
+ * taken, and another user presenting the node id is still refused.
10014
+ *
10015
+ * The claim decides ownership from now on only. History written under the node on
10016
+ * beta.12 may include operations another user forged (beta.12 did not verify node
10017
+ * ids); the operation log integrity scan is the tool for auditing it.
10018
+ */
10019
+ async handOverDeviceNode(nodeId, context) {
10020
+ if (!this.deviceNodeHandover || context.anonymous === true) return false;
10021
+ if (!this.store.claimUnownedNode) return false;
10022
+ const deviceId = context.metadata?.deviceId;
10023
+ if (typeof deviceId !== "string" || deviceId.length === 0 || deviceId !== nodeId) return false;
10024
+ const previousOwner = await this.store.getNodeClaimOwner?.(nodeId) ?? null;
10025
+ if (previousOwner !== null && previousOwner !== RELEASED_NODE_OWNER) return false;
10026
+ if (!await this.store.claimUnownedNode(nodeId, context.userId)) return false;
10027
+ this.logger?.log({
10028
+ timestamp: Date.now(),
10029
+ level: "info",
10030
+ event: "node_claim.handover",
10031
+ sessionId: this.sessionId,
10032
+ nodeId,
10033
+ details: {
10034
+ userId: context.userId,
10035
+ previousOwner: previousOwner === null ? "none (pre-claims history)" : "released",
10036
+ message: "A signed-in device took over its own node id, which had history but no owner (a database upgraded from beta.12, or a node an administrator released). The node id equals the device id the auth provider verified for this user."
10037
+ }
10038
+ });
10039
+ return true;
10040
+ }
10041
+ /**
10042
+ * The download grant the server itself gives this session, before the client's
10043
+ * handshake scope narrows it (F4). A client may narrow its own view, but that does
10044
+ * not isolate tenants: another client of the same user set can ask for everything.
10045
+ * `null` when the grant cannot be resolved here (the handshake already refused or
10046
+ * accepted it on the full path); then nothing is judged.
10047
+ */
10048
+ grantWithoutHandshakeNarrowing(downlinkAuthScopes, authenticated) {
10049
+ try {
10050
+ return resolveSessionScopes(this.store.getSchema(), {
10051
+ handshakeScope: void 0,
10052
+ authScopes: downlinkAuthScopes,
10053
+ authenticated,
10054
+ onUnresolved: "deny"
10055
+ });
10056
+ } catch {
10057
+ return null;
10058
+ }
10059
+ }
10060
+ /** Who holds a refused node id, as `NODE_ID_CLAIMED` reports it (F9). */
10061
+ async nodeOwnershipOf(nodeId) {
10062
+ if (!this.store.getNodeClaimOwner) return void 0;
10063
+ const owner = await this.store.getNodeClaimOwner(nodeId);
10064
+ return owner === null || owner === RELEASED_NODE_OWNER ? "unowned" : "other-principal";
10065
+ }
9650
10066
  /** The deprecation warning for an anonymous device adopted under a legacy claim (RT-21, RT-91). */
9651
10067
  warnLegacyAnonymousClaim(nodeId) {
9652
10068
  this.logger?.log({
@@ -9656,7 +10072,7 @@ var ClientSession = class _ClientSession {
9656
10072
  sessionId: this.sessionId,
9657
10073
  nodeId,
9658
10074
  details: {
9659
- message: "An anonymous device re-claimed a node id under a legacy claim (no confirmed node token, or history from before node claims). Upgrade the client; allowLegacyAnonymousClaims will default to false in the next release."
10075
+ message: "An anonymous device re-claimed a node id under a legacy claim (no confirmed node token, or history from before node claims). Upgrade the client; allowLegacyAnonymousClaims will default to false in a later release, announced in its release notes."
9660
10076
  }
9661
10077
  });
9662
10078
  }
@@ -9800,14 +10216,19 @@ var ClientSession = class _ClientSession {
9800
10216
  } else {
9801
10217
  this.nodeOwnerKey = context.userId;
9802
10218
  claimed = await this.store.claimNode(msg.nodeId, context.userId);
10219
+ if (!claimed) claimed = await this.handOverDeviceNode(msg.nodeId, context);
9803
10220
  }
9804
10221
  if (!claimed) {
9805
10222
  this.issuedNodeToken = null;
9806
- this.sendError(
9807
- "NODE_ID_CLAIMED",
9808
- `Node id "${msg.nodeId}" belongs to another principal, or has operation history with no recorded owner (an administrator can release it with KoraSyncServer.releaseNodeClaim). Use a fresh node id per signed-in user.`,
9809
- false
9810
- );
10223
+ const nodeOwnership = context.anonymous === true ? void 0 : await this.nodeOwnershipOf(msg.nodeId);
10224
+ this.sendToClient({
10225
+ type: "error",
10226
+ messageId: (0, import_core31.generateUUIDv7)(),
10227
+ code: "NODE_ID_CLAIMED",
10228
+ message: `Node id "${msg.nodeId}" belongs to another principal, or has operation history with no recorded owner (an administrator can release it with KoraSyncServer.releaseNodeClaim). Use a fresh node id per signed-in user.`,
10229
+ retriable: false,
10230
+ ...nodeOwnership ? { nodeOwnership } : {}
10231
+ });
9811
10232
  this.close("node id claimed by another user");
9812
10233
  return;
9813
10234
  }
@@ -9873,11 +10294,23 @@ var ClientSession = class _ClientSession {
9873
10294
  } = this.authContext;
9874
10295
  this.authContext = rest;
9875
10296
  }
9876
- warnIfMultiTenantWithoutScopes(
10297
+ if (this.unscopedSharing !== "allow" && this.auth && sharesEveryUsersData(
9877
10298
  this.auth,
9878
- authenticated ? downlinkAuthScopes : resolvedDownlinkScopes,
10299
+ this.principal,
10300
+ this.grantWithoutHandshakeNarrowing(downlinkAuthScopes, authenticated),
9879
10301
  this.store.getSchema()
9880
- );
10302
+ )) {
10303
+ if (this.unscopedSharing === "refuse") {
10304
+ this.sendError(
10305
+ "UNSCOPED_SHARING_REFUSED",
10306
+ "This server refuses signed-in sessions whose grant restricts no collection (unscopedSharing: 'refuse'). Return per-user sync scopes from the auth provider.",
10307
+ false
10308
+ );
10309
+ this.close("unscoped sharing refused");
10310
+ return;
10311
+ }
10312
+ warnIfMultiTenantWithoutScopes(this.auth);
10313
+ }
9881
10314
  if (msg.syncQueries && msg.syncQueries.length > 0) {
9882
10315
  this.syncQuerySubsets = (0, import_sync5.dedupeQuerySubsets)(msg.syncQueries);
9883
10316
  } else {
@@ -9885,7 +10318,7 @@ var ClientSession = class _ClientSession {
9885
10318
  }
9886
10319
  this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync5.decodeDeltaCursor)(msg.deltaCursor) : null;
9887
10320
  this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
9888
- if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.downlinkScopes)) {
10321
+ if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.downlinkScopes, this.maxScopePredicateValues)) {
9889
10322
  const acceptedWatermark = msg.acceptedScopeWatermark;
9890
10323
  const resumable = typeof msg.acceptedScopeKey === "string" && typeof acceptedWatermark === "number" && Number.isSafeInteger(acceptedWatermark) && acceptedWatermark >= 0 && msg.acceptedScopeKey === (0, import_internal8.scopeViewKey)(this.authContext?.downlinkScopes);
9891
10324
  this.clientDeliveryWatermark = resumable ? acceptedWatermark : 0;
@@ -9911,7 +10344,7 @@ var ClientSession = class _ClientSession {
9911
10344
  });
9912
10345
  const response2 = {
9913
10346
  type: "handshake-response",
9914
- messageId: (0, import_core30.generateUUIDv7)(),
10347
+ messageId: (0, import_core31.generateUUIDv7)(),
9915
10348
  nodeId: this.store.getNodeId(),
9916
10349
  versionVector: {},
9917
10350
  schemaVersion: this.schemaVersion,
@@ -9952,7 +10385,7 @@ var ClientSession = class _ClientSession {
9952
10385
  const heartbeat = msg.supportsHeartbeat === true && this.appHeartbeatIntervalMs > 0;
9953
10386
  const response = {
9954
10387
  type: "handshake-response",
9955
- messageId: (0, import_core30.generateUUIDv7)(),
10388
+ messageId: (0, import_core31.generateUUIDv7)(),
9956
10389
  nodeId: this.store.getNodeId(),
9957
10390
  versionVector: (0, import_sync5.versionVectorToWire)(visibleServerVector),
9958
10391
  schemaVersion: this.schemaVersion,
@@ -10009,6 +10442,7 @@ var ClientSession = class _ClientSession {
10009
10442
  }
10010
10443
  this.onReady?.(this.sessionId);
10011
10444
  if (heartbeat) this.startAppHeartbeat();
10445
+ this.runDeferredScopeRefresh();
10012
10446
  if (this.takeOrphanedRelays && this.clientNodeId) {
10013
10447
  const buffered = this.takeOrphanedRelays(this.clientNodeId);
10014
10448
  if (buffered.length > 0) {
@@ -10115,8 +10549,8 @@ var ClientSession = class _ClientSession {
10115
10549
  };
10116
10550
  }
10117
10551
  try {
10118
- const downlink = rawDownlink ? normalizeScopeMap(rawDownlink) : directionalScopesConfigured ? {} : void 0;
10119
- const uplink = rawUplink ? normalizeScopeMap(rawUplink) : directionalScopesConfigured ? {} : void 0;
10552
+ const downlink = rawDownlink ? normalizeScopeMap(rawDownlink, this.maxScopePredicateValues) : directionalScopesConfigured ? {} : void 0;
10553
+ const uplink = rawUplink !== void 0 && rawUplink === rawDownlink ? downlink : rawUplink ? normalizeScopeMap(rawUplink, this.maxScopePredicateValues) : directionalScopesConfigured ? {} : void 0;
10120
10554
  return { ok: true, downlink, uplink, downlinkAuthScopes, authenticated };
10121
10555
  } catch (error) {
10122
10556
  return {
@@ -10410,7 +10844,7 @@ var ClientSession = class _ClientSession {
10410
10844
  const lastOp = operations2[operations2.length - 1];
10411
10845
  const ack = {
10412
10846
  type: "acknowledgment",
10413
- messageId: (0, import_core30.generateUUIDv7)(),
10847
+ messageId: (0, import_core31.generateUUIDv7)(),
10414
10848
  acknowledgedMessageId: msg.messageId,
10415
10849
  lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
10416
10850
  };
@@ -10510,7 +10944,7 @@ var ClientSession = class _ClientSession {
10510
10944
  schemaView(op) {
10511
10945
  let view;
10512
10946
  try {
10513
- view = (0, import_core29.operationSchemaView)(op, this.schemaVersion, this.operationTransforms);
10947
+ view = (0, import_core30.operationSchemaView)(op, this.schemaVersion, this.operationTransforms);
10514
10948
  } catch (error) {
10515
10949
  return {
10516
10950
  ok: false,
@@ -10571,7 +11005,7 @@ var ClientSession = class _ClientSession {
10571
11005
  if (integrity.ok && integrity.restoredData !== void 0) {
10572
11006
  this.restoredLegacyData.set(op, integrity.restoredData);
10573
11007
  }
10574
- if (integrity.ok && declared === void 0 && integrity.restoredData === void 0 && (0, import_core29.canonicalizeLegacyOperation)(op) !== op) {
11008
+ if (integrity.ok && declared === void 0 && integrity.restoredData === void 0 && (0, import_core30.canonicalizeLegacyOperation)(op) !== op) {
10575
11009
  return {
10576
11010
  code: import_sync5.INVALID_OPERATION_ID,
10577
11011
  message: `Operation "${op.id}" is a version-1 update whose previousData names fields its data lacks, but its id does not cover clearing them. No Kora client writes this shape; it is refused and never stored or relayed.`
@@ -10659,12 +11093,12 @@ var ClientSession = class _ClientSession {
10659
11093
  /** The handshake's explicit revocations (RT-81), when the deployment has any. */
10660
11094
  advertisedRevocations() {
10661
11095
  const revoked = (this.store.getRevokedAuthoritativeNodeIds?.() ?? []).filter(
10662
- (id) => !(0, import_core29.isServerNodeId)(id)
11096
+ (id) => !(0, import_core30.isServerNodeId)(id)
10663
11097
  );
10664
11098
  return revoked.length > 0 ? { revokedAuthoritativeNodeIds: revoked } : {};
10665
11099
  }
10666
11100
  advertisedAuthoritativeNodeIds() {
10667
- return [...new Set(this.serverAuthoritativeNodeIds())].filter((id) => !(0, import_core29.isServerNodeId)(id));
11101
+ return [...new Set(this.serverAuthoritativeNodeIds())].filter((id) => !(0, import_core30.isServerNodeId)(id));
10668
11102
  }
10669
11103
  /** Node ids this server authors operations under (protocol v2 handshake response). */
10670
11104
  serverAuthoritativeNodeIds() {
@@ -10677,7 +11111,7 @@ var ClientSession = class _ClientSession {
10677
11111
  * payloads have no envelope binding.
10678
11112
  */
10679
11113
  warnLegacyProtocol(nodeId) {
10680
- const message = `Client node "${nodeId}" speaks sync protocol ${String(this.clientProtocolVersion)}; this server speaks ${String(import_sync5.SYNC_PROTOCOL_VERSION)}. Protocol 1 clients (Kora <= beta.12) are accepted in beta.13 only and will be refused by the next release. Upgrade the client.`;
11114
+ const message = `Client node "${nodeId}" speaks sync protocol ${String(this.clientProtocolVersion)}; this server speaks ${String(import_sync5.SYNC_PROTOCOL_VERSION)}. Protocol 1 clients (Kora <= beta.12) are deprecated and will be refused by a later release, announced in its release notes. Upgrade the client.`;
10681
11115
  this.logger?.log({
10682
11116
  timestamp: Date.now(),
10683
11117
  level: "warn",
@@ -10727,7 +11161,7 @@ var ClientSession = class _ClientSession {
10727
11161
  if (missing.length === 0) {
10728
11162
  const emptyBatch = {
10729
11163
  type: "operation-batch",
10730
- messageId: (0, import_core30.generateUUIDv7)(),
11164
+ messageId: (0, import_core31.generateUUIDv7)(),
10731
11165
  operations: [],
10732
11166
  isFinal: true,
10733
11167
  batchIndex: 0,
@@ -10742,7 +11176,7 @@ var ClientSession = class _ClientSession {
10742
11176
  if (afterCursor.length === 0) {
10743
11177
  const emptyBatch = {
10744
11178
  type: "operation-batch",
10745
- messageId: (0, import_core30.generateUUIDv7)(),
11179
+ messageId: (0, import_core31.generateUUIDv7)(),
10746
11180
  operations: [],
10747
11181
  isFinal: true,
10748
11182
  batchIndex: this.resumeDeltaCursor?.batchIndex ?? 0,
@@ -10758,7 +11192,7 @@ var ClientSession = class _ClientSession {
10758
11192
  const batchCursor = (0, import_sync5.createDeltaCursorFromBatch)(batchOps, i);
10759
11193
  const batchMsg = {
10760
11194
  type: "operation-batch",
10761
- messageId: (0, import_core30.generateUUIDv7)(),
11195
+ messageId: (0, import_core31.generateUUIDv7)(),
10762
11196
  operations: serializedOps,
10763
11197
  isFinal: i === totalBatches - 1,
10764
11198
  batchIndex: i,
@@ -10868,26 +11302,22 @@ var ClientSession = class _ClientSession {
10868
11302
  !(clientNodeId !== null && delivered.operation.nodeId === clientNodeId && delivered.deliverySequence > this.ownOperationsIncludedThrough)
10869
11303
  )
10870
11304
  );
10871
- this.recordLookupCache = await this.prefetchRecordsFor(candidates);
10872
- try {
10873
- for (const delivered of candidates) {
10874
- const snapshot = delivered.scopeSnapshot ?? null;
10875
- if (await this.operationVisibleToClient(delivered.operation, snapshot)) {
10876
- const entry = await this.scopeEntryFor(delivered.operation, snapshot);
10877
- if (entry) {
10878
- deliverable.push({
10879
- operation: entry,
10880
- deliverySequence: delivered.deliverySequence,
10881
- scopeEntry: true
10882
- });
10883
- }
10884
- deliverable.push(delivered);
10885
- } else if (await this.scopeRetractionFor(delivered.operation, snapshot)) {
10886
- deliverable.push({ ...delivered, retraction: true });
11305
+ const rows = await this.prefetchRecordsFor(candidates);
11306
+ for (const delivered of candidates) {
11307
+ const snapshot = delivered.scopeSnapshot ?? null;
11308
+ if (await this.operationVisibleToClient(delivered.operation, snapshot, rows)) {
11309
+ const entry = await this.scopeEntryFor(delivered.operation, snapshot, rows);
11310
+ if (entry) {
11311
+ deliverable.push({
11312
+ operation: entry,
11313
+ deliverySequence: delivered.deliverySequence,
11314
+ scopeEntry: true
11315
+ });
10887
11316
  }
11317
+ deliverable.push(delivered);
11318
+ } else if (await this.scopeRetractionFor(delivered.operation, snapshot, rows)) {
11319
+ deliverable.push({ ...delivered, retraction: true });
10888
11320
  }
10889
- } finally {
10890
- this.recordLookupCache = null;
10891
11321
  }
10892
11322
  return deliverable;
10893
11323
  }
@@ -10930,7 +11360,7 @@ var ClientSession = class _ClientSession {
10930
11360
  sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
10931
11361
  const batchMsg = {
10932
11362
  type: "operation-batch",
10933
- messageId: (0, import_core30.generateUUIDv7)(),
11363
+ messageId: (0, import_core31.generateUUIDv7)(),
10934
11364
  operations: slice.filter((delivered) => !delivered.retraction).map((delivered) => this.serializer.encodeOperation(delivered.operation)),
10935
11365
  ...slice.some((delivered) => delivered.retraction) ? {
10936
11366
  retractions: slice.filter((delivered) => delivered.retraction).map((delivered) => ({
@@ -10972,7 +11402,7 @@ var ClientSession = class _ClientSession {
10972
11402
  * row. Operations without a snapshot (legacy rows, custom stores) fall back to the
10973
11403
  * current row plus `op.data`.
10974
11404
  */
10975
- async operationVisibleToClient(stored, snapshot = null) {
11405
+ async operationVisibleToClient(stored, snapshot = null, rows = null) {
10976
11406
  const judged = this.schemaView(stored);
10977
11407
  const op = judged.ok ? judged.op : stored;
10978
11408
  const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
@@ -10981,18 +11411,18 @@ var ClientSession = class _ClientSession {
10981
11411
  let current;
10982
11412
  let post = snapshot.post;
10983
11413
  if (snapshotLacksScopeFields(op.collection, { pre: null, post }, scopes)) {
10984
- current = await this.lookupRecordFields(op.collection, op.recordId);
11414
+ current = await this.lookupRecordFields(op.collection, op.recordId, rows);
10985
11415
  post = snapshotValuesWithFallback(op.collection, post, scopes, current);
10986
11416
  }
10987
11417
  if (!post || !recordMatchesScopes(op.collection, { ...post, id: op.recordId }, scopes)) {
10988
11418
  return false;
10989
11419
  }
10990
11420
  if (subsets.length === 0) return true;
10991
- current ??= await this.lookupRecordFields(op.collection, op.recordId);
11421
+ current ??= await this.lookupRecordFields(op.collection, op.recordId, rows);
10992
11422
  return (0, import_sync5.operationMatchesQuerySubsets)(op, subsets, current);
10993
11423
  }
10994
11424
  const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
10995
- const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
11425
+ const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId, rows) : void 0;
10996
11426
  if (!operationMatchesScopes(op, scopes, fullRecord)) {
10997
11427
  return false;
10998
11428
  }
@@ -11218,11 +11648,11 @@ var ClientSession = class _ClientSession {
11218
11648
  * operation without a snapshot never produces a retraction (fail quiet: the
11219
11649
  * client keeps what it already had, and no other tenant's record id leaks).
11220
11650
  */
11221
- async scopeRetractionFor(op, snapshot = null) {
11651
+ async scopeRetractionFor(op, snapshot = null, rows = null) {
11222
11652
  if (this.scopeExitPolicy !== "retract") return false;
11223
11653
  const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
11224
11654
  if (!scopes || !snapshot) return false;
11225
- const current = snapshotLacksScopeFields(op.collection, snapshot, scopes) ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
11655
+ const current = snapshotLacksScopeFields(op.collection, snapshot, scopes) ? await this.lookupRecordFields(op.collection, op.recordId, rows) : void 0;
11226
11656
  return snapshotExitsScopes(op, snapshot, scopes, current);
11227
11657
  }
11228
11658
  /**
@@ -11233,14 +11663,14 @@ var ClientSession = class _ClientSession {
11233
11663
  * deleted, gets no entry: its later operations retract or delete it anyway).
11234
11664
  * Operations without a snapshot (legacy rows, custom stores) never produce one.
11235
11665
  */
11236
- async scopeEntryFor(op, snapshot) {
11666
+ async scopeEntryFor(op, snapshot, rows = null) {
11237
11667
  const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
11238
11668
  const schema = this.store.getSchema();
11239
11669
  if (!scopes || !snapshot || !schema || op.type !== "update") return null;
11240
11670
  if (!snapshotLacksScopeFields(op.collection, snapshot, scopes) && !snapshotEntersScopes(op, snapshot, scopes)) {
11241
11671
  return null;
11242
11672
  }
11243
- const current = await this.lookupRecordFields(op.collection, op.recordId);
11673
+ const current = await this.lookupRecordFields(op.collection, op.recordId, rows);
11244
11674
  if (!current || current._deleted === 1 || current._deleted === true) return null;
11245
11675
  if (!snapshotEntersScopes(op, snapshot, scopes, current)) return null;
11246
11676
  if (!recordMatchesScopes(op.collection, { ...current, id: op.recordId }, scopes)) return null;
@@ -11274,23 +11704,65 @@ var ClientSession = class _ClientSession {
11274
11704
  * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
11275
11705
  * carries no fields) is still judged against the record's actual scope. Returns
11276
11706
  * undefined when the record cannot be read (never materialized, or no schema).
11707
+ * `rows` are the rows one delivery pass prefetched for its own decisions.
11277
11708
  */
11278
- async lookupRecordFields(collection, recordId) {
11279
- const cached = this.recordLookupCache?.get(recordCacheKey(collection, recordId));
11709
+ async lookupRecordFields(collection, recordId, rows = null) {
11710
+ const cached = rows?.get(recordCacheKey(collection, recordId));
11280
11711
  if (cached !== void 0) return cached ?? void 0;
11281
11712
  try {
11282
- const rows = await this.store.queryCollection(collection, {
11713
+ const rows2 = await this.store.queryCollection(collection, {
11283
11714
  where: { id: recordId },
11284
11715
  includeDeleted: true,
11285
11716
  limit: 1
11286
11717
  });
11287
- return rows[0];
11718
+ return rows2[0];
11288
11719
  } catch {
11289
11720
  return void 0;
11290
11721
  }
11291
11722
  }
11292
- handleAwarenessUpdate(msg) {
11293
- this.onAwarenessUpdate?.(this.sessionId, msg);
11723
+ /**
11724
+ * Hand an awareness update to the server's relay, with the record its cursor
11725
+ * names, read from the store, so the relay can deliver it only to sessions whose
11726
+ * download scope contains that record (the rule of the Yjs doc channel).
11727
+ * Awareness is purely ephemeral: nothing is persisted.
11728
+ */
11729
+ async handleAwarenessUpdate(msg) {
11730
+ if (!this.onAwarenessUpdate) return;
11731
+ const state = msg.states[String(msg.clientId)];
11732
+ const cursor = state ? state.cursor : void 0;
11733
+ if (cursor === void 0 || cursor === null) {
11734
+ this.onAwarenessUpdate(this.sessionId, msg);
11735
+ return;
11736
+ }
11737
+ const collection = cursor.collection;
11738
+ const recordId = cursor.recordId;
11739
+ if (typeof cursor !== "object" || typeof collection !== "string" || typeof recordId !== "string" || !isStorableIdentifier(collection) || !isStorableIdentifier(recordId)) {
11740
+ this.onAwarenessUpdate(this.sessionId, msg, { invalid: true });
11741
+ return;
11742
+ }
11743
+ let read = this.presenceRecords.peek(collection, recordId);
11744
+ if (read === void 0) {
11745
+ if (!this.presenceLookupLimiter.allow(1)) {
11746
+ this.onAwarenessUpdate(this.sessionId, msg, { invalid: true });
11747
+ return;
11748
+ }
11749
+ try {
11750
+ read = await this.presenceRecords.read(collection, recordId);
11751
+ const key = presenceRecordKey(collection, recordId);
11752
+ for (let attempt = 0; attempt < MAX_PRESENCE_REREADS && this.presenceRecords.touchedSince(key, read.asOf); attempt++) {
11753
+ read = await this.presenceRecords.read(collection, recordId);
11754
+ }
11755
+ } catch {
11756
+ this.onAwarenessUpdate(this.sessionId, msg, { invalid: true });
11757
+ return;
11758
+ }
11759
+ }
11760
+ this.onAwarenessUpdate(this.sessionId, msg, {
11761
+ collection,
11762
+ recordId,
11763
+ stored: read.stored,
11764
+ asOf: read.asOf
11765
+ });
11294
11766
  }
11295
11767
  /**
11296
11768
  * A Yjs doc-channel update is a write to a richtext field: the sender must be
@@ -11336,7 +11808,7 @@ var ClientSession = class _ClientSession {
11336
11808
  sendError(code, message, retriable) {
11337
11809
  const errorMsg = {
11338
11810
  type: "error",
11339
- messageId: (0, import_core30.generateUUIDv7)(),
11811
+ messageId: (0, import_core31.generateUUIDv7)(),
11340
11812
  code,
11341
11813
  message,
11342
11814
  retriable
@@ -11352,7 +11824,7 @@ var ClientSession = class _ClientSession {
11352
11824
  sendOperationRejected(op, code, message, retriable) {
11353
11825
  const rejectedMsg = {
11354
11826
  type: "operation-rejected",
11355
- messageId: (0, import_core30.generateUUIDv7)(),
11827
+ messageId: (0, import_core31.generateUUIDv7)(),
11356
11828
  operationId: op.id,
11357
11829
  collection: op.collection,
11358
11830
  recordId: op.recordId,
@@ -11385,13 +11857,16 @@ function operationIdentifiersStorable(op) {
11385
11857
 
11386
11858
  // src/server/kora-sync-server.ts
11387
11859
  init_cjs_shims();
11388
- var import_core34 = require("@korajs/core");
11860
+ var import_core35 = require("@korajs/core");
11389
11861
  var import_internal9 = require("@korajs/core/internal");
11390
11862
  var import_sync7 = require("@korajs/sync");
11391
11863
 
11864
+ // package.json
11865
+ var version = "1.0.0-beta.14";
11866
+
11392
11867
  // src/awareness/awareness-relay.ts
11393
11868
  init_cjs_shims();
11394
- var import_core31 = require("@korajs/core");
11869
+ var import_core32 = require("@korajs/core");
11395
11870
  var DEFAULT_PARTITION = "*";
11396
11871
  var AwarenessRelay = class {
11397
11872
  clients = /* @__PURE__ */ new Map();
@@ -11400,34 +11875,41 @@ var AwarenessRelay = class {
11400
11875
  * calls for an already-registered session are ignored, so a client cannot rebind
11401
11876
  * itself to another client's id.
11402
11877
  *
11878
+ * The new client is sent every existing state it may see (catch-up): a state with
11879
+ * an audience when the audience admits the new session, otherwise a state of the
11880
+ * same partition.
11881
+ *
11403
11882
  * @param sessionId - Unique session identifier
11404
11883
  * @param clientId - Client-assigned awareness ID
11405
11884
  * @param transport - Transport for sending messages to this client
11406
- * @param partition - Presence partition (clients only see their own partition)
11885
+ * @param partition - Presence partition for states relayed without an audience
11407
11886
  */
11408
11887
  addClient(sessionId, clientId, transport, partition = DEFAULT_PARTITION) {
11409
11888
  if (this.clients.has(sessionId)) return;
11410
- this.clients.set(sessionId, {
11889
+ const joining = {
11411
11890
  sessionId,
11412
11891
  clientId,
11413
11892
  transport,
11414
11893
  state: null,
11415
- partition
11416
- });
11894
+ partition,
11895
+ audience: null,
11896
+ deliveredTo: /* @__PURE__ */ new Set()
11897
+ };
11898
+ this.clients.set(sessionId, joining);
11417
11899
  const existingStates = {};
11418
11900
  let hasStates = false;
11419
11901
  for (const [, client] of this.clients) {
11420
11902
  if (client.sessionId === sessionId) continue;
11421
- if (client.partition !== partition) continue;
11422
- if (client.state) {
11423
- existingStates[String(client.clientId)] = client.state;
11424
- hasStates = true;
11425
- }
11903
+ if (!client.state) continue;
11904
+ if (!this.mayReceive(client, joining)) continue;
11905
+ existingStates[String(client.clientId)] = client.state;
11906
+ client.deliveredTo.add(sessionId);
11907
+ hasStates = true;
11426
11908
  }
11427
11909
  if (hasStates) {
11428
11910
  const catchUpMsg = {
11429
11911
  type: "awareness-update",
11430
- messageId: (0, import_core31.generateUUIDv7)(),
11912
+ messageId: (0, import_core32.generateUUIDv7)(),
11431
11913
  clientId: 0,
11432
11914
  // Server-sourced
11433
11915
  states: existingStates
@@ -11444,7 +11926,7 @@ var AwarenessRelay = class {
11444
11926
  return this.clients.has(sessionId);
11445
11927
  }
11446
11928
  /**
11447
- * Remove a client and broadcast its removal to the remaining clients in its partition.
11929
+ * Remove a client and send its removal to every client that was shown its state.
11448
11930
  *
11449
11931
  * @param sessionId - Session ID of the disconnecting client
11450
11932
  */
@@ -11452,17 +11934,26 @@ var AwarenessRelay = class {
11452
11934
  const client = this.clients.get(sessionId);
11453
11935
  if (!client) return;
11454
11936
  this.clients.delete(sessionId);
11455
- if (client.state === null) return;
11456
- const removalStates = {
11457
- [String(client.clientId)]: null
11458
- };
11459
- const msg = {
11460
- type: "awareness-update",
11461
- messageId: (0, import_core31.generateUUIDv7)(),
11462
- clientId: client.clientId,
11463
- states: removalStates
11464
- };
11465
- this.broadcastExcept(sessionId, client.partition, msg);
11937
+ for (const [, other] of this.clients) other.deliveredTo.delete(sessionId);
11938
+ if (client.deliveredTo.size === 0) return;
11939
+ const msg = this.removalMessage(client);
11940
+ for (const targetId of client.deliveredTo) {
11941
+ const target = this.clients.get(targetId);
11942
+ if (target?.transport.isConnected()) target.transport.send(msg);
11943
+ }
11944
+ client.deliveredTo.clear();
11945
+ }
11946
+ /**
11947
+ * True when {@link handleUpdate} would take this update: the session is registered,
11948
+ * the update is stamped with its bound `clientId` and carries that client's entry.
11949
+ *
11950
+ * @param sessionId - Session ID of the sending client
11951
+ * @param message - The awareness update message
11952
+ */
11953
+ accepts(sessionId, message) {
11954
+ const sender = this.clients.get(sessionId);
11955
+ if (!sender || message.clientId !== sender.clientId) return false;
11956
+ return String(sender.clientId) in message.states;
11466
11957
  }
11467
11958
  /**
11468
11959
  * Handle an incoming awareness update from a registered client.
@@ -11470,12 +11961,17 @@ var AwarenessRelay = class {
11470
11961
  * Only the sender's own entry is accepted: the message must be stamped with the
11471
11962
  * sender's bound `clientId`, and only `states[clientId]` is stored and relayed.
11472
11963
  * Entries for other clients are dropped, so a sender cannot overwrite or remove
11473
- * anyone else's presence. The sanitized update reaches only the sender's partition.
11964
+ * anyone else's presence.
11965
+ *
11966
+ * The state is sent to every other client the audience admits (without an
11967
+ * audience: every client of the sender's partition). A client that was shown an
11968
+ * earlier state of the sender but is not admitted now receives a removal.
11474
11969
  *
11475
11970
  * @param sessionId - Session ID of the sending client
11476
11971
  * @param message - The awareness update message
11972
+ * @param audience - Who may see this state; omitted means the sender's partition
11477
11973
  */
11478
- handleUpdate(sessionId, message) {
11974
+ handleUpdate(sessionId, message, audience) {
11479
11975
  const sender = this.clients.get(sessionId);
11480
11976
  if (!sender) return;
11481
11977
  if (message.clientId !== sender.clientId) return;
@@ -11483,13 +11979,66 @@ var AwarenessRelay = class {
11483
11979
  if (!(key in message.states)) return;
11484
11980
  const senderState = message.states[key] ?? null;
11485
11981
  sender.state = senderState;
11982
+ sender.audience = audience ?? null;
11486
11983
  const sanitized = {
11487
11984
  type: "awareness-update",
11488
11985
  messageId: message.messageId,
11489
11986
  clientId: sender.clientId,
11490
11987
  states: { [key]: senderState }
11491
11988
  };
11492
- this.broadcastExcept(sessionId, sender.partition, sanitized);
11989
+ let removal = null;
11990
+ for (const [, client] of this.clients) {
11991
+ if (client.sessionId === sessionId) continue;
11992
+ const visible = senderState !== null && this.mayReceive(sender, client);
11993
+ if (visible) {
11994
+ if (!client.transport.isConnected()) continue;
11995
+ client.transport.send(sanitized);
11996
+ sender.deliveredTo.add(client.sessionId);
11997
+ } else if (sender.deliveredTo.delete(client.sessionId)) {
11998
+ if (!client.transport.isConnected()) continue;
11999
+ removal ??= senderState === null ? sanitized : this.removalMessage(sender);
12000
+ client.transport.send(removal);
12001
+ }
12002
+ }
12003
+ }
12004
+ /**
12005
+ * Re-decide who may see a client's current state after the record its cursor names
12006
+ * changed (F16): it moved into or out of a session's grant. Sessions that may no
12007
+ * longer see the state get a removal; sessions that may see it now and were not
12008
+ * shown it get the state. Later catch-ups use the new audience.
12009
+ *
12010
+ * @param sessionId - The client whose audience changed
12011
+ * @param audience - The new audience
12012
+ */
12013
+ updateAudience(sessionId, audience) {
12014
+ const sender = this.clients.get(sessionId);
12015
+ if (!sender) return;
12016
+ sender.audience = audience;
12017
+ if (sender.state === null) return;
12018
+ const key = String(sender.clientId);
12019
+ let current = null;
12020
+ let removal = null;
12021
+ for (const [, client] of this.clients) {
12022
+ if (client.sessionId === sessionId) continue;
12023
+ const visible = this.mayReceive(sender, client);
12024
+ const shown = sender.deliveredTo.has(client.sessionId);
12025
+ if (visible && !shown) {
12026
+ if (!client.transport.isConnected()) continue;
12027
+ current ??= {
12028
+ type: "awareness-update",
12029
+ messageId: (0, import_core32.generateUUIDv7)(),
12030
+ clientId: sender.clientId,
12031
+ states: { [key]: sender.state }
12032
+ };
12033
+ client.transport.send(current);
12034
+ sender.deliveredTo.add(client.sessionId);
12035
+ } else if (!visible && shown) {
12036
+ sender.deliveredTo.delete(client.sessionId);
12037
+ if (!client.transport.isConnected()) continue;
12038
+ removal ??= this.removalMessage(sender);
12039
+ client.transport.send(removal);
12040
+ }
12041
+ }
11493
12042
  }
11494
12043
  /**
11495
12044
  * Get the number of registered awareness clients.
@@ -11504,14 +12053,23 @@ var AwarenessRelay = class {
11504
12053
  this.clients.clear();
11505
12054
  }
11506
12055
  // --- Private ---
11507
- broadcastExcept(excludeSessionId, partition, message) {
11508
- for (const [, client] of this.clients) {
11509
- if (client.sessionId === excludeSessionId) continue;
11510
- if (client.partition !== partition) continue;
11511
- if (!client.transport.isConnected()) continue;
11512
- client.transport.send(message);
12056
+ /** True when `target` may see `source`'s current state. Fails closed. */
12057
+ mayReceive(source, target) {
12058
+ if (source.audience === null) return source.partition === target.partition;
12059
+ try {
12060
+ return source.audience(target.sessionId);
12061
+ } catch {
12062
+ return false;
11513
12063
  }
11514
12064
  }
12065
+ removalMessage(client) {
12066
+ return {
12067
+ type: "awareness-update",
12068
+ messageId: (0, import_core32.generateUUIDv7)(),
12069
+ clientId: client.clientId,
12070
+ states: { [String(client.clientId)]: null }
12071
+ };
12072
+ }
11515
12073
  };
11516
12074
 
11517
12075
  // src/diagnostics/server-metrics-collector.ts
@@ -11626,8 +12184,8 @@ var ServerMetricsCollector = class {
11626
12184
  this.errorCount++;
11627
12185
  }
11628
12186
  /** Set the schema version. */
11629
- setSchemaVersion(version) {
11630
- this.schemaVersion = version;
12187
+ setSchemaVersion(version2) {
12188
+ this.schemaVersion = version2;
11631
12189
  }
11632
12190
  /** Return a full snapshot of current metrics. */
11633
12191
  getSnapshot(totalOperations) {
@@ -11675,7 +12233,7 @@ var ServerMetricsCollector = class {
11675
12233
 
11676
12234
  // src/richtext/blob-chunk-relay.ts
11677
12235
  init_cjs_shims();
11678
- var import_core32 = require("@korajs/core");
12236
+ var import_core33 = require("@korajs/core");
11679
12237
  var import_sync6 = require("@korajs/sync");
11680
12238
  var DEFAULT_MAX_PENDING_BLOB_REQUESTS_PER_SESSION = 256;
11681
12239
  var DEFAULT_BLOB_REQUEST_TTL_MS = 6e4;
@@ -11766,7 +12324,7 @@ var BlobChunkRelay = class {
11766
12324
  } catch {
11767
12325
  return;
11768
12326
  }
11769
- if (await (0, import_core32.hashBlob)(decoded) !== entry.hash) {
12327
+ if (await (0, import_core33.hashBlob)(decoded) !== entry.hash) {
11770
12328
  return;
11771
12329
  }
11772
12330
  if (this.pending.get(message.requestId) !== entry) {
@@ -11904,7 +12462,7 @@ var YjsDocRelay = class {
11904
12462
 
11905
12463
  // src/server/route-context.ts
11906
12464
  init_cjs_shims();
11907
- var import_core33 = require("@korajs/core");
12465
+ var import_core34 = require("@korajs/core");
11908
12466
  init_ingest_validation();
11909
12467
 
11910
12468
  // src/apply/route-predicate.ts
@@ -11990,12 +12548,12 @@ async function buildRouteOperation(store, mutation, currentOverride, clockOverri
11990
12548
  const schemaVersion = store.getSchema()?.version ?? 1;
11991
12549
  const sequenceNumber = nextServerSequenceNumber(store);
11992
12550
  if (mutation.type === "insert") {
11993
- return (0, import_core33.createOperation)(
12551
+ return (0, import_core34.createOperation)(
11994
12552
  {
11995
12553
  nodeId,
11996
12554
  type: "insert",
11997
12555
  collection: mutation.collection,
11998
- recordId: mutation.recordId ?? (0, import_core33.generateUUIDv7)(),
12556
+ recordId: mutation.recordId ?? (0, import_core34.generateUUIDv7)(),
11999
12557
  data: mutation.data ?? {},
12000
12558
  previousData: null,
12001
12559
  sequenceNumber,
@@ -12018,14 +12576,14 @@ async function buildRouteOperation(store, mutation, currentOverride, clockOverri
12018
12576
  const atomicOps = {};
12019
12577
  for (const key of Object.keys(rawData)) {
12020
12578
  const value = rawData[key];
12021
- if ((0, import_core33.isAtomicOp)(value)) {
12022
- data[key] = (0, import_core33.resolveAtomicOp)(current ? current[key] : void 0, value);
12023
- atomicOps[key] = (0, import_core33.toAtomicOp)(value);
12579
+ if ((0, import_core34.isAtomicOp)(value)) {
12580
+ data[key] = (0, import_core34.resolveAtomicOp)(current ? current[key] : void 0, value);
12581
+ atomicOps[key] = (0, import_core34.toAtomicOp)(value);
12024
12582
  } else {
12025
12583
  data[key] = value;
12026
12584
  }
12027
12585
  }
12028
- return (0, import_core33.createOperation)(
12586
+ return (0, import_core34.createOperation)(
12029
12587
  {
12030
12588
  nodeId,
12031
12589
  type: "update",
@@ -12041,7 +12599,7 @@ async function buildRouteOperation(store, mutation, currentOverride, clockOverri
12041
12599
  clock
12042
12600
  );
12043
12601
  }
12044
- return (0, import_core33.createOperation)(
12602
+ return (0, import_core34.createOperation)(
12045
12603
  {
12046
12604
  nodeId,
12047
12605
  type: "delete",
@@ -12411,7 +12969,7 @@ function resolvePerMessageDeflate(option) {
12411
12969
  }
12412
12970
  function validateIntervalOption(name, value) {
12413
12971
  if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
12414
- throw new import_core34.SyncError(`${name} must be a non-negative integer number of milliseconds`, {
12972
+ throw new import_core35.SyncError(`${name} must be a non-negative integer number of milliseconds`, {
12415
12973
  [name]: value
12416
12974
  });
12417
12975
  }
@@ -12437,6 +12995,18 @@ var KoraSyncServer = class _KoraSyncServer {
12437
12995
  logger;
12438
12996
  metrics;
12439
12997
  awarenessRelay = new AwarenessRelay();
12998
+ /** Reads the records presence cursors name, for every session (F16). */
12999
+ presenceRecords;
13000
+ /** The record each session's current presence cursor names (F16). */
13001
+ presenceTargets = /* @__PURE__ */ new Map();
13002
+ /** The sessions whose presence cursor names each record, by record key. */
13003
+ presenceTargetsByKey = /* @__PURE__ */ new Map();
13004
+ /**
13005
+ * Audience re-decisions running, one per record: a write while one runs marks it
13006
+ * dirty, so it reads again and the last decision applied is always from a read that
13007
+ * began after the last write (never an older read finishing late).
13008
+ */
13009
+ presenceRefreshes = /* @__PURE__ */ new Map();
12440
13010
  yjsDocRelay = new YjsDocRelay();
12441
13011
  blobChunkRelay;
12442
13012
  /** Wrapped encryption key records (ENC-1, D4b), persisted in the server store. */
@@ -12449,8 +13019,11 @@ var KoraSyncServer = class _KoraSyncServer {
12449
13019
  /** Per-principal ingest budget per minute; 0 when disabled. */
12450
13020
  maxOpsPerMinutePerUser;
12451
13021
  allowLegacyAnonymousClaims;
13022
+ deviceNodeHandover;
13023
+ unscopedSharing;
12452
13024
  anonymousClaimTtlMs;
12453
13025
  maxOpsPerBatch;
13026
+ maxScopePredicateValues;
12454
13027
  maxMessageBytes;
12455
13028
  heartbeatIntervalMs;
12456
13029
  appHeartbeatIntervalMs;
@@ -12482,9 +13055,10 @@ var KoraSyncServer = class _KoraSyncServer {
12482
13055
  /** Internal ClientSession id -> HTTP session id, for cleanup on close. */
12483
13056
  httpSessionIdBySession = /* @__PURE__ */ new Map();
12484
13057
  httpSessionIdleTimeoutMs;
12485
- // Informational value reported by getStatus(). Keep in sync with the
12486
- // @korajs/server package version on release; it is not used for protocol negotiation.
12487
- serverVersion = "1.0.0-beta.0";
13058
+ // The @korajs/server package version, reported by getStatus() and /health so
13059
+ // operators can tell which release is deployed. Read from package.json (inlined by
13060
+ // the build), never hand-maintained. Not used for protocol negotiation.
13061
+ serverVersion = version;
12488
13062
  wsServer = null;
12489
13063
  running = false;
12490
13064
  /**
@@ -12517,9 +13091,10 @@ var KoraSyncServer = class _KoraSyncServer {
12517
13091
  static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
12518
13092
  constructor(config) {
12519
13093
  this.store = config.store;
13094
+ this.presenceRecords = new PresenceRecords(config.store);
12520
13095
  const storeSchemaVersion = this.store.getSchema()?.version;
12521
13096
  if (config.schemaVersion !== void 0 && storeSchemaVersion !== void 0 && config.schemaVersion !== storeSchemaVersion) {
12522
- throw new import_core34.SyncError("Sync schema version does not match the configured store schema.", {
13097
+ throw new import_core35.SyncError("Sync schema version does not match the configured store schema.", {
12523
13098
  syncSchemaVersion: config.schemaVersion,
12524
13099
  storeSchemaVersion
12525
13100
  });
@@ -12610,13 +13185,21 @@ var KoraSyncServer = class _KoraSyncServer {
12610
13185
  }
12611
13186
  this.maxOpsPerMinutePerUser = config.maxOpsPerMinutePerUser ?? (config.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE) * DEFAULT_USER_BUDGET_MULTIPLIER;
12612
13187
  this.allowLegacyAnonymousClaims = config.allowLegacyAnonymousClaims;
13188
+ this.deviceNodeHandover = config.deviceNodeHandover;
13189
+ this.unscopedSharing = config.unscopedSharing;
12613
13190
  this.anonymousClaimTtlMs = config.anonymousClaimTtlMs;
12614
13191
  if (config.maxOpsPerBatch !== void 0 && (!Number.isInteger(config.maxOpsPerBatch) || config.maxOpsPerBatch < 1)) {
12615
- throw new import_core34.SyncError("maxOpsPerBatch must be a positive integer", {
13192
+ throw new import_core35.SyncError("maxOpsPerBatch must be a positive integer", {
12616
13193
  maxOpsPerBatch: config.maxOpsPerBatch
12617
13194
  });
12618
13195
  }
12619
13196
  this.maxOpsPerBatch = config.maxOpsPerBatch;
13197
+ if (config.maxScopePredicateValues !== void 0 && (!Number.isInteger(config.maxScopePredicateValues) || config.maxScopePredicateValues < 1)) {
13198
+ throw new import_core35.SyncError("maxScopePredicateValues must be a positive integer", {
13199
+ maxScopePredicateValues: config.maxScopePredicateValues
13200
+ });
13201
+ }
13202
+ this.maxScopePredicateValues = config.maxScopePredicateValues;
12620
13203
  this.validateOperation = config.validateOperation;
12621
13204
  this.koraContext = createRouteContext(this, this.store);
12622
13205
  if (!this.emitter) {
@@ -12661,7 +13244,7 @@ var KoraSyncServer = class _KoraSyncServer {
12661
13244
  */
12662
13245
  terminateSessions(filter) {
12663
13246
  if (filter.userId === void 0 && filter.deviceId === void 0) {
12664
- throw new import_core34.KoraError(
13247
+ throw new import_core35.KoraError(
12665
13248
  "terminateSessions needs a userId or a deviceId; refusing to end every session.",
12666
13249
  "INVALID_TERMINATE_FILTER",
12667
13250
  { fix: "Pass { userId } to end a user, or { userId, deviceId } to end one device." }
@@ -12799,6 +13382,65 @@ var KoraSyncServer = class _KoraSyncServer {
12799
13382
  });
12800
13383
  return run;
12801
13384
  }
13385
+ /**
13386
+ * Re-resolve one user's grant on every live session of that user now, instead of
13387
+ * waiting for the next revalidation pass (`sessionRevalidationIntervalMs`). Call it
13388
+ * after a membership change the auth provider's grant depends on: an invitation
13389
+ * accepted, a collaborator removed, a role changed.
13390
+ *
13391
+ * Each of the user's sessions re-authenticates its credential with the provider. A
13392
+ * session whose resolved download or upload scope changed ends with a retriable
13393
+ * `SCOPE_CHANGED`; its client reconnects at once and receives the new grant. A
13394
+ * narrowed grant applies the client's `scopeExit` policy at that handshake
13395
+ * (`'retract'` hides the rows that left the scope), and the device's unsynced writes
13396
+ * outside the new upload scope are refused. A session still in its handshake
13397
+ * re-checks as soon as it is established, so a grant read before the change never
13398
+ * outlives this call. Sessions whose grant did not change are kept.
13399
+ *
13400
+ * Only this process's sessions are refreshed. Other instances apply the change at
13401
+ * their next revalidation pass, or call this too (for example from a pub/sub
13402
+ * message).
13403
+ *
13404
+ * @param userId - The user whose grant changed (the auth provider's `userId`)
13405
+ * @returns The number of sessions this call ended
13406
+ *
13407
+ * @example
13408
+ * ```typescript
13409
+ * await removeCollaborator(documentId, bobId)
13410
+ * await server.refreshScopes(bobId)
13411
+ * ```
13412
+ */
13413
+ async refreshScopes(userId) {
13414
+ if (typeof userId !== "string" || userId.length === 0) {
13415
+ throw new import_core35.KoraError(
13416
+ "refreshScopes needs the user id whose grant changed.",
13417
+ "INVALID_REFRESH_SCOPES_USER",
13418
+ { fix: "Pass the userId your auth provider returns for that user." }
13419
+ );
13420
+ }
13421
+ let terminated = 0;
13422
+ for (const session of [...this.sessions.values()]) {
13423
+ const outcome = await session.refreshScopes(userId);
13424
+ if (outcome === "terminated") terminated++;
13425
+ if (outcome === "error") {
13426
+ this.logger.log({
13427
+ timestamp: Date.now(),
13428
+ level: "warn",
13429
+ event: "session.revalidation_failed",
13430
+ sessionId: session.getSessionId(),
13431
+ details: { reason: "refreshScopes", userId }
13432
+ });
13433
+ }
13434
+ }
13435
+ this.logger.log({
13436
+ timestamp: Date.now(),
13437
+ level: "info",
13438
+ event: "sessions.scopes_refreshed",
13439
+ count: terminated,
13440
+ details: { userId }
13441
+ });
13442
+ return terminated;
13443
+ }
12802
13444
  /** Run {@link revalidateSessions} from the delivery poll tick once the interval elapsed. */
12803
13445
  maybeRevalidateSessions(now = Date.now()) {
12804
13446
  if (this.sessionRevalidationIntervalMs <= 0) return;
@@ -12904,13 +13546,17 @@ var KoraSyncServer = class _KoraSyncServer {
12904
13546
  const MAX_SCANNED = 1e3;
12905
13547
  if (to - from > MAX_SCANNED) {
12906
13548
  this.blobAccess.invalidate();
13549
+ this.refreshPresence(null);
12907
13550
  return;
12908
13551
  }
12909
13552
  try {
12910
13553
  const delivered = await this.store.getOperationsAfterDelivery(from, MAX_SCANNED);
12911
- this.blobAccess.invalidate(collectionsOf(delivered.map((d) => d.operation)));
13554
+ const operations2 = delivered.map((d) => d.operation);
13555
+ this.blobAccess.invalidate(collectionsOf(operations2));
13556
+ this.refreshPresence(operations2);
12912
13557
  } catch {
12913
13558
  this.blobAccess.invalidate();
13559
+ this.refreshPresence(null);
12914
13560
  }
12915
13561
  }
12916
13562
  /**
@@ -13057,11 +13703,11 @@ var KoraSyncServer = class _KoraSyncServer {
13057
13703
  */
13058
13704
  async start(wsServerImpl) {
13059
13705
  if (this.running) {
13060
- throw new import_core34.SyncError("Server is already running", { port: this.port });
13706
+ throw new import_core35.SyncError("Server is already running", { port: this.port });
13061
13707
  }
13062
13708
  await this.storeTransformsReady;
13063
13709
  if (!wsServerImpl && this.port === void 0) {
13064
- throw new import_core34.SyncError(
13710
+ throw new import_core35.SyncError(
13065
13711
  "Port is required for standalone mode. Provide port in config or use handleConnection() for attach mode.",
13066
13712
  {}
13067
13713
  );
@@ -13126,6 +13772,10 @@ var KoraSyncServer = class _KoraSyncServer {
13126
13772
  this.revocationUnsubscribe?.();
13127
13773
  this.revocationUnsubscribe = null;
13128
13774
  this.awarenessRelay.clear();
13775
+ this.presenceTargets.clear();
13776
+ this.presenceTargetsByKey.clear();
13777
+ this.presenceRefreshes.clear();
13778
+ this.presenceRecords.clear();
13129
13779
  this.yjsDocRelay.clear();
13130
13780
  this.blobChunkRelay.clear();
13131
13781
  for (const session of this.sessions.values()) {
@@ -13271,7 +13921,7 @@ var KoraSyncServer = class _KoraSyncServer {
13271
13921
  if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
13272
13922
  transport.send({
13273
13923
  type: "error",
13274
- messageId: (0, import_core34.generateUUIDv7)(),
13924
+ messageId: (0, import_core35.generateUUIDv7)(),
13275
13925
  code: "MAX_CONNECTIONS",
13276
13926
  message: `Server has reached maximum connections (${this.maxConnections})`,
13277
13927
  retriable: true
@@ -13284,13 +13934,13 @@ var KoraSyncServer = class _KoraSyncServer {
13284
13934
  event: "connection.rejected",
13285
13935
  details: { reason: "max_connections", max: this.maxConnections }
13286
13936
  });
13287
- throw new import_core34.SyncError("Maximum connections reached", {
13937
+ throw new import_core35.SyncError("Maximum connections reached", {
13288
13938
  current: this.sessions.size,
13289
13939
  max: this.maxConnections
13290
13940
  });
13291
13941
  }
13292
13942
  this.ensureBackgroundTimersStarted();
13293
- const sessionId = (0, import_core34.generateUUIDv7)();
13943
+ const sessionId = (0, import_core35.generateUUIDv7)();
13294
13944
  this.metrics.recordConnection(sessionId);
13295
13945
  const sessionEmitter = new import_internal9.SimpleEventEmitter();
13296
13946
  sessionEmitter.on("sync:connected", (event) => {
@@ -13390,9 +14040,10 @@ var KoraSyncServer = class _KoraSyncServer {
13390
14040
  onRelay: (sourceSessionId, operations2) => {
13391
14041
  this.handleRelay(sourceSessionId, operations2);
13392
14042
  },
13393
- onAwarenessUpdate: (sourceSessionId, message) => {
13394
- this.handleAwarenessRelay(sourceSessionId, message);
14043
+ onAwarenessUpdate: (sourceSessionId, message, cursorTarget) => {
14044
+ this.handleAwarenessRelay(sourceSessionId, message, cursorTarget);
13395
14045
  },
14046
+ presenceRecords: this.presenceRecords,
13396
14047
  onYjsDocUpdate: (sourceSessionId, message, storedRecord) => {
13397
14048
  this.handleYjsDocRelay(sourceSessionId, message, storedRecord);
13398
14049
  },
@@ -13425,8 +14076,11 @@ var KoraSyncServer = class _KoraSyncServer {
13425
14076
  ...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
13426
14077
  ...this.blobLimits.maxRequestsPerMinute !== void 0 ? { maxBlobRequestsPerMinute: this.blobLimits.maxRequestsPerMinute } : {},
13427
14078
  ...this.maxOpsPerBatch !== void 0 ? { maxOpsPerBatch: this.maxOpsPerBatch } : {},
14079
+ ...this.maxScopePredicateValues !== void 0 ? { maxScopePredicateValues: this.maxScopePredicateValues } : {},
13428
14080
  ...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
13429
14081
  blobAccess: this.blobAccess,
14082
+ ...this.deviceNodeHandover !== void 0 ? { deviceNodeHandover: this.deviceNodeHandover } : {},
14083
+ ...this.unscopedSharing !== void 0 ? { unscopedSharing: this.unscopedSharing } : {},
13430
14084
  ...this.allowLegacyAnonymousClaims !== void 0 ? { allowLegacyAnonymousClaims: this.allowLegacyAnonymousClaims } : {},
13431
14085
  ...this.anonymousClaimTtlMs !== void 0 ? { anonymousClaimTtlMs: this.anonymousClaimTtlMs } : {},
13432
14086
  isNodeLive: (nodeId, exceptSessionId) => this.isNodeLive(nodeId, exceptSessionId),
@@ -13496,6 +14150,7 @@ var KoraSyncServer = class _KoraSyncServer {
13496
14150
  const result = await applyServerOperation(this.store, op, void 0, options);
13497
14151
  if (result.result === "applied" && result.appliedOperations.length > 0) {
13498
14152
  this.blobAccess.invalidate(collectionsOf(result.appliedOperations));
14153
+ this.refreshPresence(result.appliedOperations);
13499
14154
  for (const session of this.sessions.values()) {
13500
14155
  session.relayOperations(result.appliedOperations);
13501
14156
  }
@@ -13515,6 +14170,7 @@ var KoraSyncServer = class _KoraSyncServer {
13515
14170
  return;
13516
14171
  }
13517
14172
  this.blobAccess.invalidate(collectionsOf(operations2));
14173
+ this.refreshPresence(operations2);
13518
14174
  for (const session of this.sessions.values()) {
13519
14175
  session.relayOperations(operations2);
13520
14176
  }
@@ -13543,7 +14199,7 @@ var KoraSyncServer = class _KoraSyncServer {
13543
14199
  const records = await this.store.materializeCollection(name);
13544
14200
  for (const record of records) {
13545
14201
  for (const value of Object.values(record)) {
13546
- if ((0, import_core34.isBlobRef)(value)) {
14202
+ if ((0, import_core35.isBlobRef)(value)) {
13547
14203
  refs.push(value);
13548
14204
  }
13549
14205
  }
@@ -13611,6 +14267,7 @@ var KoraSyncServer = class _KoraSyncServer {
13611
14267
  }
13612
14268
  handleRelay(sourceSessionId, operations2) {
13613
14269
  this.blobAccess.invalidate(collectionsOf(operations2));
14270
+ this.refreshPresence(operations2);
13614
14271
  const targetCount = this.sessions.size - 1;
13615
14272
  const byteSize = estimateOperationByteSize(operations2);
13616
14273
  this.metrics.recordSent(
@@ -13635,6 +14292,7 @@ var KoraSyncServer = class _KoraSyncServer {
13635
14292
  handleSessionClose(sessionId) {
13636
14293
  this.metrics.recordDisconnection(sessionId);
13637
14294
  this.awarenessRelay.removeClient(sessionId);
14295
+ this.setPresenceTarget(sessionId, null);
13638
14296
  this.yjsDocRelay.removeClient(sessionId);
13639
14297
  this.blobChunkRelay.removeClient(sessionId);
13640
14298
  this.sessions.delete(sessionId);
@@ -13646,7 +14304,7 @@ var KoraSyncServer = class _KoraSyncServer {
13646
14304
  }
13647
14305
  }
13648
14306
  }
13649
- handleAwarenessRelay(sourceSessionId, message) {
14307
+ handleAwarenessRelay(sourceSessionId, message, cursorTarget) {
13650
14308
  const session = this.sessions.get(sourceSessionId);
13651
14309
  if (!session || !session.isStreaming()) return;
13652
14310
  if (!this.awarenessRelay.hasClient(sourceSessionId)) {
@@ -13654,10 +14312,143 @@ var KoraSyncServer = class _KoraSyncServer {
13654
14312
  sourceSessionId,
13655
14313
  message.clientId,
13656
14314
  session.getTransport(),
13657
- session.getScopePartitionKey()
14315
+ session.getPresencePartitionKey()
13658
14316
  );
13659
14317
  }
13660
- this.awarenessRelay.handleUpdate(sourceSessionId, message);
14318
+ if (!this.awarenessRelay.accepts(sourceSessionId, message)) return;
14319
+ let audience = this.awarenessAudience(session, cursorTarget);
14320
+ if (cursorTarget !== void 0 && cursorTarget.invalid !== true) {
14321
+ const key = presenceRecordKey(cursorTarget.collection, cursorTarget.recordId);
14322
+ this.setPresenceTarget(sourceSessionId, {
14323
+ collection: cursorTarget.collection,
14324
+ recordId: cursorTarget.recordId,
14325
+ key
14326
+ });
14327
+ if (cursorTarget.asOf !== void 0 && this.presenceRecords.touchedSince(key, cursorTarget.asOf)) {
14328
+ audience = () => false;
14329
+ this.schedulePresenceRefresh(key);
14330
+ }
14331
+ } else {
14332
+ this.setPresenceTarget(sourceSessionId, null);
14333
+ }
14334
+ this.awarenessRelay.handleUpdate(sourceSessionId, message, audience);
14335
+ }
14336
+ /** Point a session's presence at a record (or at none), keeping the index in step. */
14337
+ setPresenceTarget(sessionId, target) {
14338
+ const previous = this.presenceTargets.get(sessionId);
14339
+ if (previous) {
14340
+ const sessions2 = this.presenceTargetsByKey.get(previous.key);
14341
+ sessions2?.delete(sessionId);
14342
+ if (sessions2?.size === 0) this.presenceTargetsByKey.delete(previous.key);
14343
+ }
14344
+ if (target === null) {
14345
+ this.presenceTargets.delete(sessionId);
14346
+ return;
14347
+ }
14348
+ this.presenceTargets.set(sessionId, target);
14349
+ let sessions = this.presenceTargetsByKey.get(target.key);
14350
+ if (!sessions) {
14351
+ sessions = /* @__PURE__ */ new Set();
14352
+ this.presenceTargetsByKey.set(target.key, sessions);
14353
+ }
14354
+ sessions.add(sessionId);
14355
+ }
14356
+ /**
14357
+ * Records changed (`operations`, or anything when null): re-decide the audience of
14358
+ * every presence cursor on one of them (F16). A record that moved out of a session's
14359
+ * grant takes the presence on it out of that session, and one that moved in brings
14360
+ * it, without waiting for the sender's next update; catch-ups use the new audience.
14361
+ * Every write is noted first, so a presence read that began before it is never
14362
+ * taken as current.
14363
+ */
14364
+ refreshPresence(operations2) {
14365
+ if (operations2 === null) {
14366
+ this.presenceRecords.touch(null);
14367
+ for (const key of this.presenceTargetsByKey.keys()) this.schedulePresenceRefresh(key);
14368
+ return;
14369
+ }
14370
+ const keys = new Set(operations2.map((op) => presenceRecordKey(op.collection, op.recordId)));
14371
+ this.presenceRecords.touch(keys);
14372
+ for (const key of keys) {
14373
+ if (this.presenceTargetsByKey.has(key)) this.schedulePresenceRefresh(key);
14374
+ }
14375
+ }
14376
+ /** Re-decide the presence on one record, coalesced with a re-decision already running. */
14377
+ schedulePresenceRefresh(key) {
14378
+ const running = this.presenceRefreshes.get(key);
14379
+ if (running) {
14380
+ running.dirty = true;
14381
+ return;
14382
+ }
14383
+ const state = { dirty: true };
14384
+ this.presenceRefreshes.set(key, state);
14385
+ void this.runPresenceRefresh(key, state);
14386
+ }
14387
+ async runPresenceRefresh(key, state) {
14388
+ let staleReads = 0;
14389
+ try {
14390
+ while (state.dirty) {
14391
+ state.dirty = false;
14392
+ const sessions = this.presenceTargetsByKey.get(key);
14393
+ const first = sessions?.values().next();
14394
+ const target = first && !first.done ? this.presenceTargets.get(first.value) : void 0;
14395
+ if (!target) return;
14396
+ let stored;
14397
+ try {
14398
+ const read = await this.presenceRecords.read(target.collection, target.recordId);
14399
+ if (this.presenceRecords.touchedSince(key, read.asOf)) {
14400
+ state.dirty = true;
14401
+ if (staleReads < 2) {
14402
+ staleReads += 1;
14403
+ continue;
14404
+ }
14405
+ stored = void 0;
14406
+ } else {
14407
+ stored = read.stored;
14408
+ }
14409
+ } catch {
14410
+ stored = void 0;
14411
+ }
14412
+ staleReads = 0;
14413
+ for (const sessionId of this.presenceTargetsByKey.get(key) ?? []) {
14414
+ const session = this.sessions.get(sessionId);
14415
+ if (!session) continue;
14416
+ this.awarenessRelay.updateAudience(
14417
+ sessionId,
14418
+ stored === void 0 ? () => false : this.awarenessAudience(session, {
14419
+ collection: target.collection,
14420
+ recordId: target.recordId,
14421
+ stored
14422
+ })
14423
+ );
14424
+ }
14425
+ }
14426
+ } finally {
14427
+ if (this.presenceRefreshes.get(key) === state) this.presenceRefreshes.delete(key);
14428
+ }
14429
+ }
14430
+ /**
14431
+ * Who may see a presence state (F16). A state whose cursor names a record reaches
14432
+ * exactly the sessions whose download scope contains that record, the rule of the
14433
+ * Yjs doc channel, and only when the sender may read the record too: collaborators
14434
+ * with different grants see each other on the documents they share, and nobody
14435
+ * sees presence on a record outside their grant. A malformed cursor reaches
14436
+ * nobody. A state without a cursor names no record: it reaches only sessions of
14437
+ * the same presence partition (identical download scope; anonymous sessions are
14438
+ * each their own partition).
14439
+ */
14440
+ awarenessAudience(source, cursorTarget) {
14441
+ if (cursorTarget === void 0) {
14442
+ const partition = source.getPresencePartitionKey();
14443
+ return (targetSessionId) => {
14444
+ const target = this.sessions.get(targetSessionId);
14445
+ return target?.isStreaming() === true && target.getPresencePartitionKey() === partition;
14446
+ };
14447
+ }
14448
+ if (cursorTarget.invalid === true) return () => false;
14449
+ const { collection, recordId, stored } = cursorTarget;
14450
+ if (!source.canReceiveRecord(collection, recordId, stored)) return () => false;
14451
+ return (targetSessionId) => this.sessions.get(targetSessionId)?.canReceiveRecord(collection, recordId, stored) ?? false;
13661
14452
  }
13662
14453
  handleYjsDocRelay(sourceSessionId, message, storedRecord) {
13663
14454
  if (!this.sessions.has(sourceSessionId)) {
@@ -13733,7 +14524,7 @@ function normalizeHttpBody(body, contentType) {
13733
14524
 
13734
14525
  // src/auth/token-auth.ts
13735
14526
  init_cjs_shims();
13736
- var import_core35 = require("@korajs/core");
14527
+ var import_core36 = require("@korajs/core");
13737
14528
  var TokenAuthProvider = class {
13738
14529
  validate;
13739
14530
  constructor(options) {
@@ -13743,7 +14534,7 @@ var TokenAuthProvider = class {
13743
14534
  const context = await this.validate(token);
13744
14535
  if (!context) return null;
13745
14536
  if (context.scopes === void 0 && context.downlinkScopes === void 0 && context.uplinkScopes === void 0) {
13746
- return { ...context, scopes: (0, import_core35.claimScopes)({ userId: context.userId }) };
14537
+ return { ...context, scopes: (0, import_core36.claimScopes)({ userId: context.userId }) };
13747
14538
  }
13748
14539
  return context;
13749
14540
  }
@@ -13751,7 +14542,7 @@ var TokenAuthProvider = class {
13751
14542
 
13752
14543
  // src/auth/kora-auth-provider.ts
13753
14544
  init_cjs_shims();
13754
- var import_core36 = require("@korajs/core");
14545
+ var import_core37 = require("@korajs/core");
13755
14546
  var KoraAuthProvider = class {
13756
14547
  tokenValidator;
13757
14548
  userLookup;
@@ -13778,7 +14569,7 @@ var KoraAuthProvider = class {
13778
14569
  if (this.deviceTracker) {
13779
14570
  await this.deviceTracker.touchDevice(payload.dev);
13780
14571
  }
13781
- const scopes = this.resolveScopes ? await this.resolveScopes(payload.sub) : (0, import_core36.claimScopes)({ userId: payload.sub });
14572
+ const scopes = this.resolveScopes ? await this.resolveScopes(payload.sub) : (0, import_core37.claimScopes)({ userId: payload.sub });
13782
14573
  return {
13783
14574
  userId: payload.sub,
13784
14575
  scopes,
@@ -13833,7 +14624,7 @@ var MixedAuthProvider = class {
13833
14624
  };
13834
14625
 
13835
14626
  // src/index.ts
13836
- var import_core37 = require("@korajs/core");
14627
+ var import_core38 = require("@korajs/core");
13837
14628
 
13838
14629
  // src/server/create-server.ts
13839
14630
  init_cjs_shims();
@@ -13848,9 +14639,9 @@ init_ingest_validation();
13848
14639
  // src/server/static-files.ts
13849
14640
  init_cjs_shims();
13850
14641
  var import_node_crypto = require("crypto");
13851
- var import_node_fs = require("fs");
14642
+ var import_node_fs2 = require("fs");
13852
14643
  var import_promises = require("fs/promises");
13853
- var import_node_path = require("path");
14644
+ var import_node_path2 = require("path");
13854
14645
  var import_node_zlib = require("zlib");
13855
14646
  var STATIC_MIME_TYPES = {
13856
14647
  ".html": "text/html; charset=utf-8",
@@ -13906,7 +14697,7 @@ var ASSET_PREFIX = "/assets/";
13906
14697
  var IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
13907
14698
  var REVALIDATE_CACHE_CONTROL = "no-cache";
13908
14699
  function isContentHashedFileName(fileName) {
13909
- const ext = (0, import_node_path.extname)(fileName);
14700
+ const ext = (0, import_node_path2.extname)(fileName);
13910
14701
  const stem = ext ? fileName.slice(0, -ext.length) : fileName;
13911
14702
  const match = /[.-]([A-Za-z0-9_]{8,})$/.exec(stem);
13912
14703
  if (!match) return false;
@@ -13942,19 +14733,25 @@ function fileVersionKey(stats) {
13942
14733
  return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`;
13943
14734
  }
13944
14735
  var DIGEST_CACHE_MAX_ENTRIES = 4096;
13945
- function createStaticFileHandler(staticDir) {
13946
- const root = (0, import_node_path.resolve)(staticDir);
14736
+ function createStaticFileHandler(staticDir, options = {}) {
14737
+ const root = (0, import_node_path2.resolve)(staticDir);
14738
+ const spaFallback = options.spaFallback ?? "navigation";
14739
+ const servesShell = (req, pathname) => {
14740
+ if (pathname.startsWith(ASSET_PREFIX)) return false;
14741
+ if (isNavigation(req)) return true;
14742
+ return spaFallback === "extensionless" && (0, import_node_path2.extname)(pathname) === "";
14743
+ };
13947
14744
  const compressed = /* @__PURE__ */ new Map();
13948
14745
  let compressedBytes = 0;
13949
14746
  const digests = /* @__PURE__ */ new Map();
13950
14747
  const verifiedSiblings = /* @__PURE__ */ new Map();
13951
14748
  async function contentDigest(path, stats) {
13952
- const version = fileVersionKey(stats);
14749
+ const version2 = fileVersionKey(stats);
13953
14750
  const cached = digests.get(path);
13954
- if (cached && cached.version === version) return cached.digest;
14751
+ if (cached && cached.version === version2) return cached.digest;
13955
14752
  const hash = (0, import_node_crypto.createHash)("sha256");
13956
14753
  await new Promise((done, fail) => {
13957
- const stream = (0, import_node_fs.createReadStream)(path);
14754
+ const stream = (0, import_node_fs2.createReadStream)(path);
13958
14755
  stream.on("data", (chunk) => hash.update(chunk));
13959
14756
  stream.on("end", () => done());
13960
14757
  stream.on("error", fail);
@@ -13965,7 +14762,7 @@ function createStaticFileHandler(staticDir) {
13965
14762
  const oldest = digests.keys().next().value;
13966
14763
  if (oldest !== void 0) digests.delete(oldest);
13967
14764
  }
13968
- digests.set(path, { version, digest });
14765
+ digests.set(path, { version: version2, digest });
13969
14766
  return digest;
13970
14767
  }
13971
14768
  function remember(key, body) {
@@ -13993,14 +14790,14 @@ function createStaticFileHandler(staticDir) {
13993
14790
  return null;
13994
14791
  }
13995
14792
  if (decoded.includes("\0")) return null;
13996
- const candidate = (0, import_node_path.resolve)((0, import_node_path.join)(root, decoded));
13997
- if (candidate !== root && !candidate.startsWith(root + import_node_path.sep)) return null;
14793
+ const candidate = (0, import_node_path2.resolve)((0, import_node_path2.join)(root, decoded));
14794
+ if (candidate !== root && !candidate.startsWith(root + import_node_path2.sep)) return null;
13998
14795
  return candidate;
13999
14796
  }
14000
14797
  async function containedRealPath(path) {
14001
14798
  try {
14002
14799
  const [realRoot, real] = await Promise.all([(0, import_promises.realpath)(root), (0, import_promises.realpath)(path)]);
14003
- return real === realRoot || real.startsWith(realRoot + import_node_path.sep) ? real : null;
14800
+ return real === realRoot || real.startsWith(realRoot + import_node_path2.sep) ? real : null;
14004
14801
  } catch {
14005
14802
  return null;
14006
14803
  }
@@ -14013,12 +14810,12 @@ function createStaticFileHandler(staticDir) {
14013
14810
  const stats = await fileStat(real);
14014
14811
  if (stats?.isFile()) return { path: real, name: filePath, stats };
14015
14812
  if (stats?.isDirectory()) {
14016
- const index3 = (0, import_node_path.join)(real, "index.html");
14813
+ const index3 = (0, import_node_path2.join)(real, "index.html");
14017
14814
  const realIndex = await containedRealPath(index3);
14018
14815
  if (realIndex === null) return null;
14019
14816
  const indexStats = await fileStat(realIndex);
14020
14817
  if (indexStats?.isFile()) {
14021
- return { path: realIndex, name: (0, import_node_path.join)(filePath, "index.html"), stats: indexStats };
14818
+ return { path: realIndex, name: (0, import_node_path2.join)(filePath, "index.html"), stats: indexStats };
14022
14819
  }
14023
14820
  }
14024
14821
  return null;
@@ -14031,7 +14828,7 @@ function createStaticFileHandler(staticDir) {
14031
14828
  return;
14032
14829
  }
14033
14830
  let found = await locate(pathname);
14034
- if (!found && isNavigation(req) && !pathname.startsWith(ASSET_PREFIX)) {
14831
+ if (!found && servesShell(req, pathname)) {
14035
14832
  found = await locate("/index.html");
14036
14833
  }
14037
14834
  if (!found) {
@@ -14043,8 +14840,8 @@ function createStaticFileHandler(staticDir) {
14043
14840
  return;
14044
14841
  }
14045
14842
  const { path, name, stats } = found;
14046
- const ext = (0, import_node_path.extname)(name).toLowerCase();
14047
- const fileName = (0, import_node_path.basename)(name);
14843
+ const ext = (0, import_node_path2.extname)(name).toLowerCase();
14844
+ const fileName = (0, import_node_path2.basename)(name);
14048
14845
  const cacheControl = cacheControlFor(fileName);
14049
14846
  const revalidated = cacheControl === REVALIDATE_CACHE_CONTROL;
14050
14847
  const headers = {
@@ -14088,7 +14885,7 @@ function createStaticFileHandler(staticDir) {
14088
14885
  res.end();
14089
14886
  return;
14090
14887
  }
14091
- (0, import_node_fs.createReadStream)(path).pipe(res);
14888
+ (0, import_node_fs2.createReadStream)(path).pipe(res);
14092
14889
  };
14093
14890
  async function compressedBody(path, name, digest, encoding) {
14094
14891
  const sibling = await containedRealPath(`${name}${encoding === "br" ? ".br" : ".gz"}`);
@@ -14248,6 +15045,11 @@ function parseIpv6(address) {
14248
15045
 
14249
15046
  // src/server/production-server.ts
14250
15047
  var DEFAULT_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
15048
+ var OPERATIONAL_PATHS = {
15049
+ admin: "/__kora, /__kora/status, /__kora/events",
15050
+ metrics: "/__kora/metrics",
15051
+ backup: "/__kora/backup/*"
15052
+ };
14251
15053
  var DEFAULT_MAX_BACKUP_BYTES = 256 * 1024 * 1024;
14252
15054
  function createProductionServer(config) {
14253
15055
  const port = config.port ?? (Number(process.env.PORT) || 3001);
@@ -14283,10 +15085,27 @@ function createProductionServer(config) {
14283
15085
  }
14284
15086
  return null;
14285
15087
  }
14286
- function isOperationalRequestAllowed(req, kind) {
15088
+ const production = process.env.NODE_ENV === "production";
15089
+ const allowPublic = config.operationalAuth?.allowPublic === true;
15090
+ const OPERATIONAL_GROUPS = ["admin", "metrics", "backup"];
15091
+ const unprotectedGroups = OPERATIONAL_GROUPS.filter((kind) => !getOperationalToken(kind));
15092
+ function isOperationalRequestAllowed(req, res, kind) {
14287
15093
  const expected = getOperationalToken(kind);
14288
- if (!expected) return true;
14289
- return extractRequestToken(req) === expected;
15094
+ if (!expected) {
15095
+ if (!production || allowPublic) return true;
15096
+ res.writeHead(403, { "Content-Type": "application/json" });
15097
+ res.end(
15098
+ JSON.stringify({
15099
+ error: `This operational endpoint is disabled: no ${kind === "admin" ? "adminToken" : `${kind}Token or adminToken`} is configured and NODE_ENV is production.`,
15100
+ code: "OPERATIONAL_ENDPOINT_DISABLED",
15101
+ fix: "Set operationalAuth tokens in createProductionServer, or operationalAuth.allowPublic: true to serve it without one."
15102
+ })
15103
+ );
15104
+ return false;
15105
+ }
15106
+ if (extractRequestToken(req) === expected) return true;
15107
+ rejectUnauthorized(res);
15108
+ return false;
14290
15109
  }
14291
15110
  function rejectUnauthorized(res) {
14292
15111
  res.writeHead(401, {
@@ -14445,10 +15264,31 @@ function createProductionServer(config) {
14445
15264
  getLiveBlobRefs() {
14446
15265
  return syncServer.getLiveBlobRefs();
14447
15266
  },
15267
+ refreshScopes(userId) {
15268
+ return syncServer.refreshScopes(userId);
15269
+ },
15270
+ revalidateSessions() {
15271
+ return syncServer.revalidateSessions();
15272
+ },
14448
15273
  async start() {
15274
+ if (unprotectedGroups.length > 0) {
15275
+ const paths = unprotectedGroups.map((kind) => OPERATIONAL_PATHS[kind]).join(", ");
15276
+ syncServer.getLogger().log({
15277
+ timestamp: Date.now(),
15278
+ level: "warn",
15279
+ event: "server.operational_endpoints_unprotected",
15280
+ details: {
15281
+ groups: unprotectedGroups,
15282
+ disabled: production && !allowPublic,
15283
+ message: production && !allowPublic ? `Operational endpoints without a token are disabled (${paths}). Set operationalAuth.adminToken and backupToken to use them.` : `Operational endpoints without a token are PUBLIC (${paths}). Set operationalAuth.adminToken and backupToken before exposing this server.`
15284
+ }
15285
+ });
15286
+ }
14449
15287
  const { createServer } = await import("http");
14450
15288
  const { WebSocketServer } = await import("ws");
14451
- const serveStatic = createStaticFileHandler(staticDir);
15289
+ const serveStatic = createStaticFileHandler(staticDir, {
15290
+ ...config.spaFallback ? { spaFallback: config.spaFallback } : {}
15291
+ });
14452
15292
  httpServer = createServer(async (req, res) => {
14453
15293
  try {
14454
15294
  await handleRequest(req, res);
@@ -14482,29 +15322,20 @@ function createProductionServer(config) {
14482
15322
  return;
14483
15323
  }
14484
15324
  if (url.pathname === "/__kora/status") {
14485
- if (!isOperationalRequestAllowed(req, "admin")) {
14486
- rejectUnauthorized(res);
14487
- return;
14488
- }
15325
+ if (!isOperationalRequestAllowed(req, res, "admin")) return;
14489
15326
  const status = await syncServer.getStatus();
14490
15327
  res.writeHead(200, { "Content-Type": "application/json" });
14491
15328
  res.end(JSON.stringify(status, null, 2));
14492
15329
  return;
14493
15330
  }
14494
15331
  if (url.pathname === "/__kora/metrics") {
14495
- if (!isOperationalRequestAllowed(req, "metrics")) {
14496
- rejectUnauthorized(res);
14497
- return;
14498
- }
15332
+ if (!isOperationalRequestAllowed(req, res, "metrics")) return;
14499
15333
  res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
14500
15334
  res.end(formatPrometheusMetrics());
14501
15335
  return;
14502
15336
  }
14503
15337
  if (url.pathname === "/__kora/events") {
14504
- if (!isOperationalRequestAllowed(req, "admin")) {
14505
- rejectUnauthorized(res);
14506
- return;
14507
- }
15338
+ if (!isOperationalRequestAllowed(req, res, "admin")) return;
14508
15339
  res.writeHead(200, {
14509
15340
  "Content-Type": "text/event-stream",
14510
15341
  "Cache-Control": "no-cache",
@@ -14532,20 +15363,14 @@ data: ${JSON.stringify(s)}
14532
15363
  return;
14533
15364
  }
14534
15365
  if (url.pathname === "/__kora" || url.pathname === "/__kora/") {
14535
- if (!isOperationalRequestAllowed(req, "admin")) {
14536
- rejectUnauthorized(res);
14537
- return;
14538
- }
15366
+ if (!isOperationalRequestAllowed(req, res, "admin")) return;
14539
15367
  const status = await syncServer.getStatus();
14540
15368
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
14541
15369
  res.end(renderDashboardHtml(status));
14542
15370
  return;
14543
15371
  }
14544
15372
  if (url.pathname === "/__kora/backup/export" && req.method === "POST") {
14545
- if (!isOperationalRequestAllowed(req, "backup")) {
14546
- rejectUnauthorized(res);
14547
- return;
14548
- }
15373
+ if (!isOperationalRequestAllowed(req, res, "backup")) return;
14549
15374
  try {
14550
15375
  const backup = await config.store.exportBackup();
14551
15376
  res.writeHead(200, {
@@ -14561,10 +15386,7 @@ data: ${JSON.stringify(s)}
14561
15386
  return;
14562
15387
  }
14563
15388
  if (url.pathname === "/__kora/backup/import" && req.method === "POST") {
14564
- if (!isOperationalRequestAllowed(req, "backup")) {
14565
- rejectUnauthorized(res);
14566
- return;
14567
- }
15389
+ if (!isOperationalRequestAllowed(req, res, "backup")) return;
14568
15390
  try {
14569
15391
  const body = await readBodyBuffer(req, maxBackupBytes);
14570
15392
  if (body === null) {
@@ -14657,7 +15479,7 @@ data: ${JSON.stringify(s)}
14657
15479
  };
14658
15480
  }
14659
15481
  function renderDashboardHtml(status) {
14660
- const version = status.version;
15482
+ const version2 = status.version;
14661
15483
  const uptime = formatUptime(status.uptime);
14662
15484
  return `<!DOCTYPE html>
14663
15485
  <html lang="en">
@@ -14688,7 +15510,7 @@ function renderDashboardHtml(status) {
14688
15510
  </head>
14689
15511
  <body>
14690
15512
  <h1>Kora Sync Server</h1>
14691
- <p class="subtitle">v${version} &middot; <span class="status-dot running"></span>Running</p>
15513
+ <p class="subtitle">v${version2} &middot; <span class="status-dot running"></span>Running</p>
14692
15514
  <div class="grid">
14693
15515
  <div class="card"><div class="label">Uptime</div><div class="value">${uptime}</div></div>
14694
15516
  <div class="card"><div class="label">Connected Clients</div><div class="value" id="connectedClients">${status.connectedClients}</div></div>
@@ -14738,6 +15560,7 @@ function formatUptime(ms) {
14738
15560
  ClientSession,
14739
15561
  DEFAULT_MAX_SCOPE_PREDICATE_VALUES,
14740
15562
  HttpServerTransport,
15563
+ InvalidDiagnosticOptionsError,
14741
15564
  InvalidScopePredicateError,
14742
15565
  KoraAuthProvider,
14743
15566
  KoraSyncServer,
@@ -14766,6 +15589,7 @@ function formatUptime(ms) {
14766
15589
  createProductionServer,
14767
15590
  createSilentLogger,
14768
15591
  createSqliteServerStore,
15592
+ findJsonStringValues,
14769
15593
  isRetriableRejection,
14770
15594
  isServerNodeId,
14771
15595
  normalizeScopeMap,