@korajs/server 1.0.0-beta.0 → 1.0.0-beta.2

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
@@ -176,6 +176,7 @@ __export(index_exports, {
176
176
  MixedAuthProvider: () => MixedAuthProvider,
177
177
  NoAuthProvider: () => NoAuthProvider,
178
178
  PostgresServerStore: () => PostgresServerStore,
179
+ RETRIABLE_REJECTION_CODES: () => RETRIABLE_REJECTION_CODES,
179
180
  SqliteServerStore: () => SqliteServerStore,
180
181
  TokenAuthProvider: () => TokenAuthProvider,
181
182
  WsServerTransport: () => WsServerTransport,
@@ -186,7 +187,8 @@ __export(index_exports, {
186
187
  createPrettyLogger: () => createPrettyLogger,
187
188
  createProductionServer: () => createProductionServer,
188
189
  createSilentLogger: () => createSilentLogger,
189
- createSqliteServerStore: () => createSqliteServerStore
190
+ createSqliteServerStore: () => createSqliteServerStore,
191
+ isRetriableRejection: () => isRetriableRejection
190
192
  });
191
193
  module.exports = __toCommonJS(index_exports);
192
194
  init_cjs_shims();
@@ -248,6 +250,13 @@ function createDefaultLogger() {
248
250
  }
249
251
  }
250
252
 
253
+ // src/apply/rejection-taxonomy.ts
254
+ init_cjs_shims();
255
+ var RETRIABLE_REJECTION_CODES = /* @__PURE__ */ new Set(["RATE_LIMIT"]);
256
+ function isRetriableRejection(code) {
257
+ return RETRIABLE_REJECTION_CODES.has(code);
258
+ }
259
+
251
260
  // src/store/memory-server-store.ts
252
261
  init_cjs_shims();
253
262
  var import_core2 = require("@korajs/core");
@@ -269,6 +278,14 @@ function fieldTypeToSql(descriptor, dialect) {
269
278
  return dialect === "postgres" ? "BIGINT" : "INTEGER";
270
279
  case "array":
271
280
  return dialect === "postgres" ? "JSONB" : "TEXT";
281
+ case "object":
282
+ return dialect === "postgres" ? "JSONB" : "TEXT";
283
+ case "json":
284
+ return dialect === "postgres" ? "JSONB" : "TEXT";
285
+ case "blob":
286
+ return "TEXT";
287
+ case "secret":
288
+ return "TEXT";
272
289
  case "richtext":
273
290
  return dialect === "postgres" ? "BYTEA" : "BLOB";
274
291
  }
@@ -285,13 +302,13 @@ function generateCollectionDDL(name, collection, dialect) {
285
302
  const columns = ["id TEXT PRIMARY KEY NOT NULL"];
286
303
  for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
287
304
  const sqlType = fieldTypeToSql(descriptor, dialect);
288
- let colDef = `${fieldName} ${sqlType}`;
305
+ let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
289
306
  if (descriptor.defaultValue !== void 0) {
290
307
  colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
291
308
  }
292
309
  if (descriptor.kind === "enum" && descriptor.enumValues) {
293
310
  const values = descriptor.enumValues.map((v) => `'${v}'`).join(", ");
294
- colDef += ` CHECK (${fieldName} IN (${values}))`;
311
+ colDef += ` CHECK (${(0, import_core.quoteIdent)(fieldName)} IN (${values}))`;
295
312
  }
296
313
  columns.push(colDef);
297
314
  }
@@ -299,24 +316,26 @@ function generateCollectionDDL(name, collection, dialect) {
299
316
  columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
300
317
  columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
301
318
  columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
302
- statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
319
+ statements.push(`CREATE TABLE IF NOT EXISTS ${(0, import_core.quoteIdent)(name)} (
303
320
  ${columns.join(",\n ")}
304
321
  )`);
305
322
  for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
306
323
  const sqlType = fieldTypeToSql(descriptor, dialect);
307
- let colDef = `${fieldName} ${sqlType}`;
324
+ let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
308
325
  if (descriptor.defaultValue !== void 0) {
309
326
  colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
310
327
  }
311
328
  statements.push(`--kora:safe-alter
312
- ALTER TABLE ${name} ADD COLUMN ${colDef}`);
329
+ ALTER TABLE ${(0, import_core.quoteIdent)(name)} ADD COLUMN ${colDef}`);
313
330
  }
314
331
  for (const indexField of collection.indexes) {
315
332
  statements.push(
316
- `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
333
+ `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${(0, import_core.quoteIdent)(name)} (${(0, import_core.quoteIdent)(indexField)})`
317
334
  );
318
335
  }
319
- statements.push(`CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${name} (_deleted)`);
336
+ statements.push(
337
+ `CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${(0, import_core.quoteIdent)(name)} (_deleted)`
338
+ );
320
339
  return statements;
321
340
  }
322
341
  function generateAllCollectionDDL(schema, dialect) {
@@ -952,7 +971,7 @@ var PostgresServerStore = class {
952
971
  this.assertCollection(collection);
953
972
  const schema = this.schema;
954
973
  const collectionDef = schema.collections[collection];
955
- const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
974
+ const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
956
975
  const rows = await this.db.execute(query);
957
976
  if (rows.length === 0) return null;
958
977
  return this.deserializeRow(rows[0], collectionDef);
@@ -969,7 +988,7 @@ var PostgresServerStore = class {
969
988
  }
970
989
  }
971
990
  const whereClause = this.buildWhereClause(where ?? {}, false);
972
- const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`;
991
+ const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`;
973
992
  const rows = await this.db.execute(query);
974
993
  const cnt = rows[0]?.cnt;
975
994
  return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
@@ -1056,7 +1075,7 @@ var PostgresServerStore = class {
1056
1075
  );
1057
1076
  } else {
1058
1077
  await txOrDb.execute(
1059
- import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1078
+ import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1060
1079
  );
1061
1080
  }
1062
1081
  }
@@ -1075,16 +1094,16 @@ var PostgresServerStore = class {
1075
1094
  updatedAt,
1076
1095
  0
1077
1096
  ];
1078
- const columnsSql = import_drizzle_orm.sql.raw(allColumns.join(", "));
1097
+ const columnsSql = import_drizzle_orm.sql.raw(allColumns.map((c) => (0, import_core3.quoteIdent)(c)).join(", "));
1079
1098
  const valuesSql = import_drizzle_orm.sql.join(
1080
1099
  values.map((v) => import_drizzle_orm.sql`${v}`),
1081
1100
  import_drizzle_orm.sql.raw(", ")
1082
1101
  );
1083
1102
  const updateSet = import_drizzle_orm.sql.raw(
1084
- allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
1103
+ allColumns.slice(1).map((c) => `${(0, import_core3.quoteIdent)(c)} = excluded.${(0, import_core3.quoteIdent)(c)}`).join(", ")
1085
1104
  );
1086
1105
  await txOrDb.execute(
1087
- import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1106
+ import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1088
1107
  );
1089
1108
  }
1090
1109
  /**
@@ -1144,7 +1163,7 @@ var PostgresServerStore = class {
1144
1163
  );
1145
1164
  } else {
1146
1165
  await this.db.execute(
1147
- import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1166
+ import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1148
1167
  );
1149
1168
  }
1150
1169
  }
@@ -1157,10 +1176,12 @@ var PostgresServerStore = class {
1157
1176
  options?.where ?? {},
1158
1177
  options?.includeDeleted ?? false
1159
1178
  );
1160
- const parts = [import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`];
1179
+ const parts = [
1180
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`
1181
+ ];
1161
1182
  if (options?.orderBy) {
1162
1183
  const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
1163
- parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
1184
+ parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${(0, import_core3.quoteIdent)(options.orderBy)} ${dir}`));
1164
1185
  }
1165
1186
  if (options?.limit !== void 0) {
1166
1187
  parts.push(import_drizzle_orm.sql` LIMIT ${options.limit}`);
@@ -1176,7 +1197,7 @@ var PostgresServerStore = class {
1176
1197
  conditions.push(import_drizzle_orm.sql.raw("_deleted = 0"));
1177
1198
  }
1178
1199
  for (const [key, value] of Object.entries(where)) {
1179
- conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw(key)} = ${value}`);
1200
+ conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(key))} = ${value}`);
1180
1201
  }
1181
1202
  if (conditions.length === 0) {
1182
1203
  return import_drizzle_orm.sql.raw("1 = 1");
@@ -1528,7 +1549,7 @@ var SqliteServerStore = class {
1528
1549
  this.assertCollection(collection);
1529
1550
  const schema = this.schema;
1530
1551
  const collectionDef = schema.collections[collection];
1531
- const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
1552
+ const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
1532
1553
  const rows = this.db.all(query);
1533
1554
  if (rows.length === 0) return null;
1534
1555
  return this.deserializeRow(rows[0], collectionDef);
@@ -1544,7 +1565,7 @@ var SqliteServerStore = class {
1544
1565
  }
1545
1566
  }
1546
1567
  const whereClause = this.buildWhereClause(where ?? {}, false);
1547
- const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`;
1568
+ const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`;
1548
1569
  const rows = this.db.all(query);
1549
1570
  return rows[0]?.cnt ?? 0;
1550
1571
  }
@@ -1621,7 +1642,7 @@ var SqliteServerStore = class {
1621
1642
  );
1622
1643
  } else {
1623
1644
  txOrDb.run(
1624
- import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1645
+ import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1625
1646
  );
1626
1647
  }
1627
1648
  }
@@ -1642,16 +1663,16 @@ var SqliteServerStore = class {
1642
1663
  0
1643
1664
  // _deleted = false
1644
1665
  ];
1645
- const columnsSql = import_drizzle_orm2.sql.raw(allColumns.join(", "));
1666
+ const columnsSql = import_drizzle_orm2.sql.raw(allColumns.map((c) => (0, import_core4.quoteIdent)(c)).join(", "));
1646
1667
  const valuesSql = import_drizzle_orm2.sql.join(
1647
1668
  values.map((v) => import_drizzle_orm2.sql`${v}`),
1648
1669
  import_drizzle_orm2.sql.raw(", ")
1649
1670
  );
1650
1671
  const updateSet = import_drizzle_orm2.sql.raw(
1651
- allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
1672
+ allColumns.slice(1).map((c) => `${(0, import_core4.quoteIdent)(c)} = excluded.${(0, import_core4.quoteIdent)(c)}`).join(", ")
1652
1673
  );
1653
1674
  txOrDb.run(
1654
- import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1675
+ import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1655
1676
  );
1656
1677
  }
1657
1678
  /**
@@ -1709,7 +1730,7 @@ var SqliteServerStore = class {
1709
1730
  );
1710
1731
  } else {
1711
1732
  tx.run(
1712
- import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1733
+ import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1713
1734
  );
1714
1735
  }
1715
1736
  }
@@ -1723,10 +1744,12 @@ var SqliteServerStore = class {
1723
1744
  options?.where ?? {},
1724
1745
  options?.includeDeleted ?? false
1725
1746
  );
1726
- const parts = [import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`];
1747
+ const parts = [
1748
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`
1749
+ ];
1727
1750
  if (options?.orderBy) {
1728
1751
  const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
1729
- parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
1752
+ parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${(0, import_core4.quoteIdent)(options.orderBy)} ${dir}`));
1730
1753
  }
1731
1754
  if (options?.limit !== void 0) {
1732
1755
  parts.push(import_drizzle_orm2.sql` LIMIT ${options.limit}`);
@@ -1742,7 +1765,7 @@ var SqliteServerStore = class {
1742
1765
  conditions.push(import_drizzle_orm2.sql.raw("_deleted = 0"));
1743
1766
  }
1744
1767
  for (const [key, value] of Object.entries(where)) {
1745
- conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw(key)} = ${value}`);
1768
+ conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(key))} = ${value}`);
1746
1769
  }
1747
1770
  if (conditions.length === 0) {
1748
1771
  return import_drizzle_orm2.sql.raw("1 = 1");
@@ -2067,6 +2090,7 @@ init_cjs_shims();
2067
2090
  var import_core10 = require("@korajs/core");
2068
2091
  var import_internal = require("@korajs/core/internal");
2069
2092
  var import_sync2 = require("@korajs/sync");
2093
+ var import_sync3 = require("@korajs/sync");
2070
2094
 
2071
2095
  // src/apply/apply-server-operation.ts
2072
2096
  init_cjs_shims();
@@ -2188,12 +2212,14 @@ async function applyServerOperation(store, op, relationLookup) {
2188
2212
  const lookup = relationLookup ?? (schema ? (0, import_merge2.buildMergeRelationLookup)(schema) : /* @__PURE__ */ new Map());
2189
2213
  const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
2190
2214
  if (!constraintCheck.valid) {
2215
+ const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
2191
2216
  return {
2192
2217
  result: "skipped",
2193
2218
  appliedOperations: [],
2194
2219
  rejection: {
2195
- code: constraintCheck.code ?? "CONSTRAINT_VIOLATION",
2196
- message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`
2220
+ code,
2221
+ message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`,
2222
+ retriable: isRetriableRejection(code)
2197
2223
  }
2198
2224
  };
2199
2225
  }
@@ -2206,7 +2232,8 @@ async function applyServerOperation(store, op, relationLookup) {
2206
2232
  appliedOperations: [],
2207
2233
  rejection: {
2208
2234
  code: "REFERENTIAL_INTEGRITY",
2209
- message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`
2235
+ message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`,
2236
+ retriable: isRetriableRejection("REFERENTIAL_INTEGRITY")
2210
2237
  }
2211
2238
  };
2212
2239
  }
@@ -2239,6 +2266,14 @@ async function applyServerOperation(store, op, relationLookup) {
2239
2266
  };
2240
2267
  }
2241
2268
 
2269
+ // src/auth/no-auth.ts
2270
+ init_cjs_shims();
2271
+ var NoAuthProvider = class {
2272
+ async authenticate(_token) {
2273
+ return { userId: "anonymous" };
2274
+ }
2275
+ };
2276
+
2242
2277
  // src/scopes/resolve-session-scopes.ts
2243
2278
  init_cjs_shims();
2244
2279
  var import_core8 = require("@korajs/core");
@@ -2368,6 +2403,25 @@ var SessionRateLimiter = class {
2368
2403
  // src/session/client-session.ts
2369
2404
  var DEFAULT_BATCH_SIZE = 100;
2370
2405
  var DEFAULT_SCHEMA_VERSION = 1;
2406
+ var warnedUnscopedProviders = /* @__PURE__ */ new WeakSet();
2407
+ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
2408
+ if (!auth || auth instanceof NoAuthProvider) {
2409
+ return;
2410
+ }
2411
+ if (resolvedScopes) {
2412
+ return;
2413
+ }
2414
+ if (!schema || Object.keys(schema.collections).length === 0) {
2415
+ return;
2416
+ }
2417
+ if (warnedUnscopedProviders.has(auth)) {
2418
+ return;
2419
+ }
2420
+ warnedUnscopedProviders.add(auth);
2421
+ console.warn(
2422
+ "[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.)"
2423
+ );
2424
+ }
2371
2425
  var ClientSession = class {
2372
2426
  state = "connected";
2373
2427
  clientNodeId = null;
@@ -2386,16 +2440,21 @@ var ClientSession = class {
2386
2440
  onRelay;
2387
2441
  onAwarenessUpdate;
2388
2442
  onYjsDocUpdate;
2443
+ onBlobChunkRequest;
2444
+ onBlobChunkResponse;
2445
+ persistBlobChunk;
2389
2446
  onClose;
2390
2447
  maxOperationBytes;
2391
2448
  maxOpsPerMinute;
2392
2449
  rateLimiter;
2450
+ validateOperation;
2451
+ koraContext;
2393
2452
  constructor(options) {
2394
2453
  this.sessionId = options.sessionId;
2395
2454
  this.transport = options.transport;
2396
2455
  this.store = options.store;
2397
2456
  this.auth = options.auth ?? null;
2398
- this.serializer = options.serializer ?? new import_sync2.NegotiatedMessageSerializer("json");
2457
+ this.serializer = options.serializer ?? new import_sync3.NegotiatedMessageSerializer("json");
2399
2458
  this.emitter = options.emitter ?? null;
2400
2459
  this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
2401
2460
  this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
@@ -2407,10 +2466,15 @@ var ClientSession = class {
2407
2466
  this.onRelay = options.onRelay ?? null;
2408
2467
  this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
2409
2468
  this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
2469
+ this.onBlobChunkRequest = options.onBlobChunkRequest ?? null;
2470
+ this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
2471
+ this.persistBlobChunk = options.persistBlobChunk ?? null;
2410
2472
  this.onClose = options.onClose ?? null;
2411
2473
  this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
2412
2474
  this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
2413
2475
  this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
2476
+ this.validateOperation = options.validateOperation ?? null;
2477
+ this.koraContext = options.koraContext ?? null;
2414
2478
  }
2415
2479
  /**
2416
2480
  * Start handling messages from the client transport.
@@ -2512,8 +2576,33 @@ var ClientSession = class {
2512
2576
  case "yjs-doc-update":
2513
2577
  this.handleYjsDocUpdate(message);
2514
2578
  break;
2579
+ case "blob-chunk-request":
2580
+ this.onBlobChunkRequest?.(this.sessionId, message);
2581
+ break;
2582
+ case "blob-chunk-response":
2583
+ this.onBlobChunkResponse?.(this.sessionId, message);
2584
+ break;
2585
+ case "blob-chunk-push":
2586
+ await this.handleBlobChunkPush(message);
2587
+ break;
2515
2588
  }
2516
2589
  }
2590
+ /**
2591
+ * Persist a client-uploaded blob chunk (or manifest) centrally. The bytes are
2592
+ * verified to hash to the declared hash before storing, so a corrupt or
2593
+ * mislabeled upload is rejected rather than served later as trusted content.
2594
+ */
2595
+ async handleBlobChunkPush(message) {
2596
+ if (!this.persistBlobChunk) {
2597
+ return;
2598
+ }
2599
+ const bytes = (0, import_sync2.decodeBlobChunkBytes)(message.bytes);
2600
+ const actual = await (0, import_core10.hashBlob)(bytes);
2601
+ if (actual !== message.hash) {
2602
+ return;
2603
+ }
2604
+ await this.persistBlobChunk(message.hash, bytes);
2605
+ }
2517
2606
  handleMessageFailure(error) {
2518
2607
  const reason = error instanceof Error ? error.message : "Message handling failed";
2519
2608
  this.sendError("SYNC_ERROR", reason, true);
@@ -2547,25 +2636,26 @@ var ClientSession = class {
2547
2636
  this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
2548
2637
  }
2549
2638
  }
2639
+ warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
2550
2640
  if (msg.syncQueries && msg.syncQueries.length > 0) {
2551
- this.syncQuerySubsets = (0, import_sync2.dedupeQuerySubsets)(msg.syncQueries);
2641
+ this.syncQuerySubsets = (0, import_sync3.dedupeQuerySubsets)(msg.syncQueries);
2552
2642
  } else {
2553
2643
  this.syncQuerySubsets = [];
2554
2644
  }
2555
- this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync2.decodeDeltaCursor)(msg.deltaCursor) : null;
2645
+ this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
2556
2646
  const serverVector = this.store.getVersionVector();
2557
2647
  const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
2558
2648
  this.setSerializerWireFormat(selectedWireFormat);
2559
- if (!(0, import_sync2.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
2649
+ if (!(0, import_sync3.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
2560
2650
  const { min, max } = this.supportedSchemaVersions;
2561
2651
  const response2 = {
2562
2652
  type: "handshake-response",
2563
2653
  messageId: (0, import_core10.generateUUIDv7)(),
2564
2654
  nodeId: this.store.getNodeId(),
2565
- versionVector: (0, import_sync2.versionVectorToWire)(serverVector),
2655
+ versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
2566
2656
  schemaVersion: this.schemaVersion,
2567
2657
  accepted: false,
2568
- rejectReason: `${import_sync2.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`,
2658
+ rejectReason: `${import_sync3.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`,
2569
2659
  supportedSchemaMin: min,
2570
2660
  supportedSchemaMax: max,
2571
2661
  serverTime: Date.now()
@@ -2578,11 +2668,14 @@ var ClientSession = class {
2578
2668
  type: "handshake-response",
2579
2669
  messageId: (0, import_core10.generateUUIDv7)(),
2580
2670
  nodeId: this.store.getNodeId(),
2581
- versionVector: (0, import_sync2.versionVectorToWire)(serverVector),
2671
+ versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
2582
2672
  schemaVersion: this.schemaVersion,
2583
2673
  accepted: true,
2584
2674
  selectedWireFormat,
2585
2675
  serverTime: Date.now(),
2676
+ // Advertise central blob storage so the client uploads the bytes behind
2677
+ // its blob fields, keeping them available after the author goes offline.
2678
+ ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
2586
2679
  // Confirm the accepted scope so the client knows what data will be synced.
2587
2680
  // This may differ from what the client requested if auth scopes are narrower.
2588
2681
  ...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
@@ -2590,7 +2683,7 @@ var ClientSession = class {
2590
2683
  this.sendToClient(response);
2591
2684
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
2592
2685
  this.state = "syncing";
2593
- const clientVector = (0, import_sync2.wireToVersionVector)(msg.versionVector);
2686
+ const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
2594
2687
  await this.sendDelta(clientVector);
2595
2688
  this.state = "streaming";
2596
2689
  }
@@ -2628,10 +2721,39 @@ var ClientSession = class {
2628
2721
  );
2629
2722
  continue;
2630
2723
  }
2724
+ if (this.validateOperation && this.koraContext) {
2725
+ let decision;
2726
+ try {
2727
+ decision = await this.validateOperation(op, {
2728
+ auth: this.authContext,
2729
+ kora: this.koraContext
2730
+ });
2731
+ } catch (error) {
2732
+ const message = error instanceof Error ? error.message : "validator error";
2733
+ this.sendOperationRejected(op, "VALIDATION_ERROR", `Validator threw: ${message}`, true);
2734
+ continue;
2735
+ }
2736
+ if (decision.action === "reject") {
2737
+ this.sendOperationRejected(
2738
+ op,
2739
+ decision.code,
2740
+ decision.message,
2741
+ decision.retriable ?? isRetriableRejection(decision.code)
2742
+ );
2743
+ continue;
2744
+ }
2745
+ if (decision.action === "ignore") {
2746
+ continue;
2747
+ }
2748
+ }
2631
2749
  try {
2632
2750
  const applyResult = await applyServerOperation(this.store, op);
2633
2751
  if (applyResult.rejection) {
2634
- this.sendError(applyResult.rejection.code, applyResult.rejection.message, false);
2752
+ this.sendError(
2753
+ applyResult.rejection.code,
2754
+ applyResult.rejection.message,
2755
+ applyResult.rejection.retriable
2756
+ );
2635
2757
  continue;
2636
2758
  }
2637
2759
  if (applyResult.result === "applied") {
@@ -2692,7 +2814,7 @@ var ClientSession = class {
2692
2814
  return;
2693
2815
  }
2694
2816
  const sorted = (0, import_internal.topologicalSort)(missing);
2695
- const afterCursor = (0, import_sync2.sliceOperationsAfterCursor)(sorted, this.resumeDeltaCursor);
2817
+ const afterCursor = (0, import_sync3.sliceOperationsAfterCursor)(sorted, this.resumeDeltaCursor);
2696
2818
  const totalBatches = Math.ceil(afterCursor.length / this.batchSize);
2697
2819
  if (afterCursor.length === 0) {
2698
2820
  const emptyBatch = {
@@ -2710,7 +2832,7 @@ var ClientSession = class {
2710
2832
  const start = i * this.batchSize;
2711
2833
  const batchOps = afterCursor.slice(start, start + this.batchSize);
2712
2834
  const serializedOps = batchOps.map((op) => this.serializer.encodeOperation(op));
2713
- const batchCursor = (0, import_sync2.createDeltaCursorFromBatch)(batchOps, i);
2835
+ const batchCursor = (0, import_sync3.createDeltaCursorFromBatch)(batchOps, i);
2714
2836
  const batchMsg = {
2715
2837
  type: "operation-batch",
2716
2838
  messageId: (0, import_core10.generateUUIDv7)(),
@@ -2718,7 +2840,7 @@ var ClientSession = class {
2718
2840
  isFinal: i === totalBatches - 1,
2719
2841
  batchIndex: i,
2720
2842
  totalBatches,
2721
- ...batchCursor ? { cursor: (0, import_sync2.encodeDeltaCursor)(batchCursor) } : {}
2843
+ ...batchCursor ? { cursor: (0, import_sync3.encodeDeltaCursor)(batchCursor) } : {}
2722
2844
  };
2723
2845
  this.sendToClient(batchMsg);
2724
2846
  this.emitter?.emit({
@@ -2732,7 +2854,7 @@ var ClientSession = class {
2732
2854
  if (!operationMatchesScopes(op, this.authContext?.scopes)) {
2733
2855
  return false;
2734
2856
  }
2735
- return (0, import_sync2.operationMatchesQuerySubsets)(op, this.syncQuerySubsets);
2857
+ return (0, import_sync3.operationMatchesQuerySubsets)(op, this.syncQuerySubsets);
2736
2858
  }
2737
2859
  handleAwarenessUpdate(msg) {
2738
2860
  this.onAwarenessUpdate?.(this.sessionId, msg);
@@ -2750,6 +2872,25 @@ var ClientSession = class {
2750
2872
  };
2751
2873
  this.sendToClient(errorMsg);
2752
2874
  }
2875
+ /**
2876
+ * Reject one specific client operation, tied to its id, so the submitter can
2877
+ * divert it out of its pending queue into a durable rejected store rather than
2878
+ * losing it or retrying forever. The op is NOT materialized, so no other
2879
+ * replica ever sees it.
2880
+ */
2881
+ sendOperationRejected(op, code, message, retriable) {
2882
+ const rejectedMsg = {
2883
+ type: "operation-rejected",
2884
+ messageId: (0, import_core10.generateUUIDv7)(),
2885
+ operationId: op.id,
2886
+ collection: op.collection,
2887
+ recordId: op.recordId,
2888
+ code,
2889
+ message,
2890
+ retriable
2891
+ };
2892
+ this.sendToClient(rejectedMsg);
2893
+ }
2753
2894
  setSerializerWireFormat(format) {
2754
2895
  if (typeof this.serializer.setWireFormat === "function") {
2755
2896
  this.serializer.setWireFormat(format);
@@ -2771,9 +2912,9 @@ function selectWireFormat(supportedWireFormats) {
2771
2912
 
2772
2913
  // src/server/kora-sync-server.ts
2773
2914
  init_cjs_shims();
2774
- var import_core12 = require("@korajs/core");
2915
+ var import_core13 = require("@korajs/core");
2775
2916
  var import_internal2 = require("@korajs/core/internal");
2776
- var import_sync3 = require("@korajs/sync");
2917
+ var import_sync5 = require("@korajs/sync");
2777
2918
 
2778
2919
  // src/awareness/awareness-relay.ts
2779
2920
  init_cjs_shims();
@@ -2990,6 +3131,114 @@ var ServerMetricsCollector = class {
2990
3131
  }
2991
3132
  };
2992
3133
 
3134
+ // src/richtext/blob-chunk-relay.ts
3135
+ init_cjs_shims();
3136
+ var import_sync4 = require("@korajs/sync");
3137
+ var BlobChunkRelay = class {
3138
+ clients = /* @__PURE__ */ new Map();
3139
+ /** requestId -> the session that originated the request (to route the answer back). */
3140
+ pending = /* @__PURE__ */ new Map();
3141
+ resolveBlobChunk;
3142
+ constructor(resolveBlobChunk) {
3143
+ this.resolveBlobChunk = resolveBlobChunk ?? null;
3144
+ }
3145
+ addClient(sessionId, transport) {
3146
+ this.clients.set(sessionId, { sessionId, transport });
3147
+ }
3148
+ removeClient(sessionId) {
3149
+ this.clients.delete(sessionId);
3150
+ for (const [requestId, originSessionId] of this.pending) {
3151
+ if (originSessionId === sessionId) {
3152
+ this.pending.delete(requestId);
3153
+ }
3154
+ }
3155
+ }
3156
+ getClientCount() {
3157
+ return this.clients.size;
3158
+ }
3159
+ getPendingCount() {
3160
+ return this.pending.size;
3161
+ }
3162
+ clear() {
3163
+ this.clients.clear();
3164
+ this.pending.clear();
3165
+ }
3166
+ /**
3167
+ * Handle an inbound chunk request from a session. Tries the server's own
3168
+ * store first (if configured), otherwise forwards the request to peer
3169
+ * sessions and remembers who to route the answer back to.
3170
+ */
3171
+ handleRequest(sourceSessionId, message) {
3172
+ if (!this.clients.has(sourceSessionId)) {
3173
+ return;
3174
+ }
3175
+ if (this.resolveBlobChunk) {
3176
+ void this.resolveBlobChunk(message.hash).then(
3177
+ (bytes) => {
3178
+ if (bytes !== null) {
3179
+ this.sendResponseTo(sourceSessionId, message.requestId, (0, import_sync4.encodeBlobChunkBytes)(bytes));
3180
+ return;
3181
+ }
3182
+ this.forwardRequestToPeers(sourceSessionId, message);
3183
+ },
3184
+ () => {
3185
+ this.forwardRequestToPeers(sourceSessionId, message);
3186
+ }
3187
+ );
3188
+ return;
3189
+ }
3190
+ this.forwardRequestToPeers(sourceSessionId, message);
3191
+ }
3192
+ /**
3193
+ * Handle an inbound chunk response from a peer. Routes it back to the session
3194
+ * that originally requested it. A "not held" answer (bytes === null) is
3195
+ * ignored so a peer without the chunk does not preempt one that has it; the
3196
+ * requester's own per-request timeout bounds the wait.
3197
+ */
3198
+ handleResponse(sourceSessionId, message) {
3199
+ if (!this.clients.has(sourceSessionId)) {
3200
+ return;
3201
+ }
3202
+ if (message.bytes === null) {
3203
+ return;
3204
+ }
3205
+ const originSessionId = this.pending.get(message.requestId);
3206
+ if (!originSessionId) {
3207
+ return;
3208
+ }
3209
+ this.pending.delete(message.requestId);
3210
+ this.sendResponseTo(originSessionId, message.requestId, message.bytes);
3211
+ }
3212
+ forwardRequestToPeers(sourceSessionId, message) {
3213
+ this.pending.set(message.requestId, sourceSessionId);
3214
+ for (const [, client] of this.clients) {
3215
+ if (client.sessionId === sourceSessionId) {
3216
+ continue;
3217
+ }
3218
+ if (!client.transport.isConnected()) {
3219
+ continue;
3220
+ }
3221
+ client.transport.send(message);
3222
+ }
3223
+ }
3224
+ sendResponseTo(sessionId, requestId, bytes) {
3225
+ const client = this.clients.get(sessionId);
3226
+ if (!client || !client.transport.isConnected()) {
3227
+ return;
3228
+ }
3229
+ const response = {
3230
+ type: "blob-chunk-response",
3231
+ messageId: generateResponseId(requestId),
3232
+ requestId,
3233
+ bytes
3234
+ };
3235
+ client.transport.send(response);
3236
+ }
3237
+ };
3238
+ function generateResponseId(requestId) {
3239
+ return `blob-resp-${requestId}`;
3240
+ }
3241
+
2993
3242
  // src/richtext/yjs-doc-relay.ts
2994
3243
  init_cjs_shims();
2995
3244
  var YjsDocRelay = class {
@@ -3025,6 +3274,170 @@ var YjsDocRelay = class {
3025
3274
  }
3026
3275
  };
3027
3276
 
3277
+ // src/server/route-context.ts
3278
+ init_cjs_shims();
3279
+ var import_core12 = require("@korajs/core");
3280
+ function recordMatchesScope(collection, record, scope) {
3281
+ const collectionScope = scope[collection];
3282
+ if (!collectionScope) {
3283
+ return false;
3284
+ }
3285
+ for (const [field, expected] of Object.entries(collectionScope)) {
3286
+ if (record[field] !== expected) {
3287
+ return false;
3288
+ }
3289
+ }
3290
+ return true;
3291
+ }
3292
+ function pickFields(record, keys) {
3293
+ const picked = {};
3294
+ for (const key of keys) {
3295
+ if (key in record) {
3296
+ picked[key] = record[key];
3297
+ }
3298
+ }
3299
+ return picked;
3300
+ }
3301
+ async function buildRouteOperation(store, mutation) {
3302
+ const nodeId = store.getNodeId();
3303
+ const clock = new import_core12.HybridLogicalClock(nodeId);
3304
+ const schemaVersion = store.getSchema()?.version ?? 1;
3305
+ const sequenceNumber = nextServerSequenceNumber(store);
3306
+ if (mutation.type === "insert") {
3307
+ return (0, import_core12.createOperation)(
3308
+ {
3309
+ nodeId,
3310
+ type: "insert",
3311
+ collection: mutation.collection,
3312
+ recordId: mutation.recordId ?? (0, import_core12.generateUUIDv7)(),
3313
+ data: mutation.data ?? {},
3314
+ previousData: null,
3315
+ sequenceNumber,
3316
+ causalDeps: [],
3317
+ schemaVersion
3318
+ },
3319
+ clock
3320
+ );
3321
+ }
3322
+ if (!mutation.recordId) {
3323
+ throw new RouteMutationError(
3324
+ "MISSING_RECORD_ID",
3325
+ `A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
3326
+ );
3327
+ }
3328
+ const current = await store.findRecord(mutation.collection, mutation.recordId);
3329
+ if (mutation.type === "update") {
3330
+ const data = mutation.data ?? {};
3331
+ return (0, import_core12.createOperation)(
3332
+ {
3333
+ nodeId,
3334
+ type: "update",
3335
+ collection: mutation.collection,
3336
+ recordId: mutation.recordId,
3337
+ data,
3338
+ previousData: current ? pickFields(current, Object.keys(data)) : {},
3339
+ sequenceNumber,
3340
+ causalDeps: [],
3341
+ schemaVersion
3342
+ },
3343
+ clock
3344
+ );
3345
+ }
3346
+ return (0, import_core12.createOperation)(
3347
+ {
3348
+ nodeId,
3349
+ type: "delete",
3350
+ collection: mutation.collection,
3351
+ recordId: mutation.recordId,
3352
+ data: null,
3353
+ previousData: current ? { ...current } : null,
3354
+ sequenceNumber,
3355
+ causalDeps: [],
3356
+ schemaVersion
3357
+ },
3358
+ clock
3359
+ );
3360
+ }
3361
+ var RouteMutationError = class extends Error {
3362
+ constructor(code, message) {
3363
+ super(message);
3364
+ this.code = code;
3365
+ this.name = "RouteMutationError";
3366
+ }
3367
+ code;
3368
+ };
3369
+ function createRouteContext(server, store) {
3370
+ let mutationTail = Promise.resolve();
3371
+ function serialize(work) {
3372
+ const run = mutationTail.then(work, work);
3373
+ mutationTail = run.then(
3374
+ () => void 0,
3375
+ () => void 0
3376
+ );
3377
+ return run;
3378
+ }
3379
+ return {
3380
+ apply(mutation, options) {
3381
+ return serialize(async () => {
3382
+ let op;
3383
+ try {
3384
+ op = await buildRouteOperation(store, mutation);
3385
+ } catch (error) {
3386
+ if (error instanceof RouteMutationError) {
3387
+ return {
3388
+ ok: false,
3389
+ code: error.code,
3390
+ message: error.message,
3391
+ retriable: isRetriableRejection(error.code)
3392
+ };
3393
+ }
3394
+ throw error;
3395
+ }
3396
+ if (options?.scope && !operationMatchesScopes(op, options.scope)) {
3397
+ return {
3398
+ ok: false,
3399
+ code: "SCOPE_VIOLATION",
3400
+ message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
3401
+ retriable: isRetriableRejection("SCOPE_VIOLATION")
3402
+ };
3403
+ }
3404
+ const result = await server.applyLocalOperation(op);
3405
+ if (result.result !== "applied") {
3406
+ const code = result.rejection?.code ?? "NOT_APPLIED";
3407
+ return {
3408
+ ok: false,
3409
+ code,
3410
+ message: result.rejection?.message ?? `Operation on "${mutation.collection}" was not applied (${result.result}).`,
3411
+ // Prefer the pipeline's own classification when present; fall back
3412
+ // to classifying the code so this stays correct if new codes appear.
3413
+ retriable: result.rejection?.retriable ?? isRetriableRejection(code)
3414
+ };
3415
+ }
3416
+ const record = await store.findRecord(mutation.collection, op.recordId);
3417
+ return { ok: true, operation: op, record };
3418
+ });
3419
+ },
3420
+ async query(collection, options) {
3421
+ const { scope, ...queryOptions } = options ?? {};
3422
+ const records = await store.queryCollection(collection, queryOptions);
3423
+ if (!scope) {
3424
+ return records;
3425
+ }
3426
+ return records.filter((record) => recordMatchesScope(collection, record, scope));
3427
+ },
3428
+ async findById(collection, id, options) {
3429
+ const record = await store.findRecord(collection, id);
3430
+ if (!record) {
3431
+ return null;
3432
+ }
3433
+ if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
3434
+ return null;
3435
+ }
3436
+ return record;
3437
+ }
3438
+ };
3439
+ }
3440
+
3028
3441
  // src/server/kora-sync-server.ts
3029
3442
  var DEFAULT_MAX_CONNECTIONS = 0;
3030
3443
  var DEFAULT_BATCH_SIZE2 = 100;
@@ -3047,16 +3460,24 @@ var KoraSyncServer = class {
3047
3460
  metrics;
3048
3461
  awarenessRelay = new AwarenessRelay();
3049
3462
  yjsDocRelay = new YjsDocRelay();
3463
+ blobChunkRelay;
3464
+ persistBlobChunk;
3465
+ maxOperationBytes;
3466
+ maxOpsPerMinute;
3467
+ validateOperation;
3468
+ koraContext;
3050
3469
  sessions = /* @__PURE__ */ new Map();
3051
3470
  httpClients = /* @__PURE__ */ new Map();
3052
3471
  httpSessionToClient = /* @__PURE__ */ new Map();
3053
- serverVersion = "0.4.0";
3472
+ // Informational value reported by getStatus(). Keep in sync with the
3473
+ // @korajs/server package version on release; it is not used for protocol negotiation.
3474
+ serverVersion = "1.0.0-beta.0";
3054
3475
  wsServer = null;
3055
3476
  running = false;
3056
3477
  constructor(config) {
3057
3478
  this.store = config.store;
3058
3479
  this.auth = config.auth ?? null;
3059
- this.serializer = config.serializer ?? new import_sync3.JsonMessageSerializer();
3480
+ this.serializer = config.serializer ?? new import_sync5.JsonMessageSerializer();
3060
3481
  this.emitter = config.emitter ?? null;
3061
3482
  this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
3062
3483
  this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
@@ -3071,10 +3492,24 @@ var KoraSyncServer = class {
3071
3492
  this.logger = config.logger ?? createDefaultLogger();
3072
3493
  this.metrics = config.metricsCollector ?? new ServerMetricsCollector();
3073
3494
  this.metrics.setSchemaVersion(this.schemaVersion);
3495
+ this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
3496
+ this.persistBlobChunk = config.persistBlobChunk ?? null;
3497
+ this.maxOperationBytes = config.maxOperationBytes;
3498
+ this.maxOpsPerMinute = config.maxOpsPerMinute;
3499
+ this.validateOperation = config.validateOperation;
3500
+ this.koraContext = createRouteContext(this, this.store);
3074
3501
  if (!this.emitter) {
3075
3502
  this.emitter = new import_internal2.SimpleEventEmitter();
3076
3503
  }
3077
3504
  }
3505
+ /**
3506
+ * The trusted, scoped data-plane context (`apply` / `query` / `findById`).
3507
+ * Shared with the production server so HTTP routes, the operation validator,
3508
+ * and `server.kora` are all the same object over the same pipeline.
3509
+ */
3510
+ getKoraContext() {
3511
+ return this.koraContext;
3512
+ }
3078
3513
  /**
3079
3514
  * Subscribe to session-level events for metrics collection and logging.
3080
3515
  * Called when a new session is created.
@@ -3143,10 +3578,10 @@ var KoraSyncServer = class {
3143
3578
  */
3144
3579
  async start(wsServerImpl) {
3145
3580
  if (this.running) {
3146
- throw new import_core12.SyncError("Server is already running", { port: this.port });
3581
+ throw new import_core13.SyncError("Server is already running", { port: this.port });
3147
3582
  }
3148
3583
  if (!wsServerImpl && this.port === void 0) {
3149
- throw new import_core12.SyncError(
3584
+ throw new import_core13.SyncError(
3150
3585
  "Port is required for standalone mode. Provide port in config or use handleConnection() for attach mode.",
3151
3586
  {}
3152
3587
  );
@@ -3194,6 +3629,7 @@ var KoraSyncServer = class {
3194
3629
  });
3195
3630
  this.awarenessRelay.clear();
3196
3631
  this.yjsDocRelay.clear();
3632
+ this.blobChunkRelay.clear();
3197
3633
  for (const session of this.sessions.values()) {
3198
3634
  session.close("server shutting down");
3199
3635
  }
@@ -3255,7 +3691,7 @@ var KoraSyncServer = class {
3255
3691
  if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
3256
3692
  transport.send({
3257
3693
  type: "error",
3258
- messageId: (0, import_core12.generateUUIDv7)(),
3694
+ messageId: (0, import_core13.generateUUIDv7)(),
3259
3695
  code: "MAX_CONNECTIONS",
3260
3696
  message: `Server has reached maximum connections (${this.maxConnections})`,
3261
3697
  retriable: true
@@ -3268,12 +3704,12 @@ var KoraSyncServer = class {
3268
3704
  event: "connection.rejected",
3269
3705
  details: { reason: "max_connections", max: this.maxConnections }
3270
3706
  });
3271
- throw new import_core12.SyncError("Maximum connections reached", {
3707
+ throw new import_core13.SyncError("Maximum connections reached", {
3272
3708
  current: this.sessions.size,
3273
3709
  max: this.maxConnections
3274
3710
  });
3275
3711
  }
3276
- const sessionId = (0, import_core12.generateUUIDv7)();
3712
+ const sessionId = (0, import_core13.generateUUIDv7)();
3277
3713
  this.metrics.recordConnection(sessionId);
3278
3714
  const sessionEmitter = new import_internal2.SimpleEventEmitter();
3279
3715
  sessionEmitter.on("sync:connected", (event) => {
@@ -3338,12 +3774,25 @@ var KoraSyncServer = class {
3338
3774
  onYjsDocUpdate: (sourceSessionId, message) => {
3339
3775
  this.handleYjsDocRelay(sourceSessionId, message);
3340
3776
  },
3777
+ onBlobChunkRequest: (sourceSessionId, message) => {
3778
+ this.blobChunkRelay.handleRequest(sourceSessionId, message);
3779
+ },
3780
+ onBlobChunkResponse: (sourceSessionId, message) => {
3781
+ this.blobChunkRelay.handleResponse(sourceSessionId, message);
3782
+ },
3783
+ ...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
3784
+ // Only forward when configured, so an unset server value leaves the
3785
+ // session on its own documented default rather than `undefined`.
3786
+ ...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
3787
+ ...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
3788
+ ...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
3341
3789
  onClose: (sid) => {
3342
3790
  this.handleSessionClose(sid);
3343
3791
  }
3344
3792
  });
3345
3793
  this.sessions.set(sessionId, session);
3346
3794
  this.yjsDocRelay.addClient(sessionId, transport);
3795
+ this.blobChunkRelay.addClient(sessionId, transport);
3347
3796
  session.start();
3348
3797
  this.logger.log({
3349
3798
  timestamp: Date.now(),
@@ -3376,6 +3825,61 @@ var KoraSyncServer = class {
3376
3825
  errorCount: snapshot.errorCount
3377
3826
  };
3378
3827
  }
3828
+ /**
3829
+ * Apply a server-originated operation (for example one created by a custom
3830
+ * HTTP route) through the same validated pipeline that incoming client
3831
+ * operations use — Tier 2 constraints, referential integrity, and cascade
3832
+ * side effects — then relay every applied operation to connected clients.
3833
+ *
3834
+ * Because the operation did not come from a client session, it is relayed to
3835
+ * ALL sessions (there is no source session to exclude). Each session still
3836
+ * applies its own per-scope visibility filter in `relayOperations`, so a
3837
+ * client only receives the operation if it falls within that client's scope.
3838
+ *
3839
+ * @param op - A fully-formed, server-originated operation to apply
3840
+ * @returns The apply result, including any server-generated side-effect ops
3841
+ */
3842
+ async applyLocalOperation(op) {
3843
+ const result = await applyServerOperation(this.store, op);
3844
+ if (result.result === "applied" && result.appliedOperations.length > 0) {
3845
+ for (const session of this.sessions.values()) {
3846
+ session.relayOperations(result.appliedOperations);
3847
+ }
3848
+ }
3849
+ return result;
3850
+ }
3851
+ /**
3852
+ * Collect every blob reference still reachable from live records on the server.
3853
+ *
3854
+ * This is the live set for garbage-collecting the server's central blob store:
3855
+ * pass the result to `collectBlobGarbage(blobStore, liveRefs)` from
3856
+ * `@korajs/store` to reclaim bytes no record references any more. Only
3857
+ * collections that declare a `blob` field are scanned.
3858
+ *
3859
+ * @returns Every live blob reference across all collections
3860
+ */
3861
+ async getLiveBlobRefs() {
3862
+ const schema = this.store.getSchema();
3863
+ if (!schema) {
3864
+ return [];
3865
+ }
3866
+ const refs = [];
3867
+ for (const [name, collection] of Object.entries(schema.collections)) {
3868
+ const hasBlobField = Object.values(collection.fields).some((field) => field.kind === "blob");
3869
+ if (!hasBlobField) {
3870
+ continue;
3871
+ }
3872
+ const records = await this.store.materializeCollection(name);
3873
+ for (const record of records) {
3874
+ for (const value of Object.values(record)) {
3875
+ if ((0, import_core13.isBlobRef)(value)) {
3876
+ refs.push(value);
3877
+ }
3878
+ }
3879
+ }
3880
+ }
3881
+ return refs;
3882
+ }
3379
3883
  /**
3380
3884
  * Get the number of currently connected clients.
3381
3885
  */
@@ -3409,6 +3913,7 @@ var KoraSyncServer = class {
3409
3913
  this.metrics.recordDisconnection(sessionId);
3410
3914
  this.awarenessRelay.removeClient(sessionId);
3411
3915
  this.yjsDocRelay.removeClient(sessionId);
3916
+ this.blobChunkRelay.removeClient(sessionId);
3412
3917
  this.sessions.delete(sessionId);
3413
3918
  const clientId = this.httpSessionToClient.get(sessionId);
3414
3919
  if (clientId) {
@@ -3461,14 +3966,6 @@ function normalizeHttpBody(body, contentType) {
3461
3966
  return body;
3462
3967
  }
3463
3968
 
3464
- // src/auth/no-auth.ts
3465
- init_cjs_shims();
3466
- var NoAuthProvider = class {
3467
- async authenticate(_token) {
3468
- return { userId: "anonymous" };
3469
- }
3470
- };
3471
-
3472
3969
  // src/auth/token-auth.ts
3473
3970
  init_cjs_shims();
3474
3971
  var TokenAuthProvider = class {
@@ -3578,6 +4075,7 @@ function createProductionServer(config) {
3578
4075
  enableDashboard: true,
3579
4076
  ...config.syncOptions
3580
4077
  });
4078
+ const routeContext = syncServer.getKoraContext();
3581
4079
  let httpServer = null;
3582
4080
  function getOperationalToken(kind) {
3583
4081
  const auth = config.operationalAuth;
@@ -3664,6 +4162,10 @@ function createProductionServer(config) {
3664
4162
  const chunks = [];
3665
4163
  req.on("data", (chunk) => chunks.push(chunk));
3666
4164
  req.on("end", () => resolve(Buffer.concat(chunks)));
4165
+ if (!req.readableFlowing) {
4166
+ req.resume();
4167
+ }
4168
+ req.on("error", () => resolve(Buffer.concat(chunks)));
3667
4169
  });
3668
4170
  }
3669
4171
  async function readJsonBody(req) {
@@ -3713,6 +4215,10 @@ function createProductionServer(config) {
3713
4215
  res.end(JSON.stringify(result.body ?? null));
3714
4216
  }
3715
4217
  return {
4218
+ kora: routeContext,
4219
+ getLiveBlobRefs() {
4220
+ return syncServer.getLiveBlobRefs();
4221
+ },
3716
4222
  async start() {
3717
4223
  const { createServer } = await import("http");
3718
4224
  const { createReadStream, existsSync, statSync } = await import("fs");
@@ -3720,6 +4226,19 @@ function createProductionServer(config) {
3720
4226
  const { WebSocketServer } = await import("ws");
3721
4227
  const distDir = resolve(staticDir);
3722
4228
  httpServer = createServer(async (req, res) => {
4229
+ try {
4230
+ await handleRequest(req, res);
4231
+ } catch (error) {
4232
+ console.error("[kora] Unhandled error in HTTP request handler:", error);
4233
+ if (!res.headersSent) {
4234
+ res.writeHead(500, { "Content-Type": "application/json" });
4235
+ res.end(JSON.stringify({ error: "Internal server error" }));
4236
+ } else if (!res.writableEnded) {
4237
+ res.end();
4238
+ }
4239
+ }
4240
+ });
4241
+ async function handleRequest(req, res) {
3723
4242
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
3724
4243
  res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
3725
4244
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
@@ -3847,7 +4366,8 @@ data: ${JSON.stringify(s)}
3847
4366
  body: await readJsonBody(req),
3848
4367
  headers: req.headers,
3849
4368
  query: getQuery(url),
3850
- ip: getClientIp(req)
4369
+ ip: getClientIp(req),
4370
+ kora: routeContext
3851
4371
  });
3852
4372
  writeJsonResponse(res, result);
3853
4373
  return;
@@ -3881,7 +4401,7 @@ data: ${JSON.stringify(s)}
3881
4401
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
3882
4402
  res.writeHead(200, { "Content-Type": contentType });
3883
4403
  createReadStream(filePath).pipe(res);
3884
- });
4404
+ }
3885
4405
  const wss = new WebSocketServer({ noServer: true });
3886
4406
  httpServer.on("upgrade", (req, socket, head) => {
3887
4407
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
@@ -3998,6 +4518,7 @@ function formatUptime(ms) {
3998
4518
  MixedAuthProvider,
3999
4519
  NoAuthProvider,
4000
4520
  PostgresServerStore,
4521
+ RETRIABLE_REJECTION_CODES,
4001
4522
  SqliteServerStore,
4002
4523
  TokenAuthProvider,
4003
4524
  WsServerTransport,
@@ -4008,6 +4529,7 @@ function formatUptime(ms) {
4008
4529
  createPrettyLogger,
4009
4530
  createProductionServer,
4010
4531
  createSilentLogger,
4011
- createSqliteServerStore
4532
+ createSqliteServerStore,
4533
+ isRetriableRejection
4012
4534
  });
4013
4535
  //# sourceMappingURL=index.cjs.map