@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.js CHANGED
@@ -54,11 +54,17 @@ function createDefaultLogger() {
54
54
  }
55
55
  }
56
56
 
57
+ // src/apply/rejection-taxonomy.ts
58
+ var RETRIABLE_REJECTION_CODES = /* @__PURE__ */ new Set(["RATE_LIMIT"]);
59
+ function isRetriableRejection(code) {
60
+ return RETRIABLE_REJECTION_CODES.has(code);
61
+ }
62
+
57
63
  // src/store/memory-server-store.ts
58
64
  import { generateUUIDv7 } from "@korajs/core";
59
65
 
60
66
  // src/store/materialization.ts
61
- import { StorageError } from "@korajs/core";
67
+ import { StorageError, quoteIdent } from "@korajs/core";
62
68
  function fieldTypeToSql(descriptor, dialect) {
63
69
  switch (descriptor.kind) {
64
70
  case "string":
@@ -73,6 +79,14 @@ function fieldTypeToSql(descriptor, dialect) {
73
79
  return dialect === "postgres" ? "BIGINT" : "INTEGER";
74
80
  case "array":
75
81
  return dialect === "postgres" ? "JSONB" : "TEXT";
82
+ case "object":
83
+ return dialect === "postgres" ? "JSONB" : "TEXT";
84
+ case "json":
85
+ return dialect === "postgres" ? "JSONB" : "TEXT";
86
+ case "blob":
87
+ return "TEXT";
88
+ case "secret":
89
+ return "TEXT";
76
90
  case "richtext":
77
91
  return dialect === "postgres" ? "BYTEA" : "BLOB";
78
92
  }
@@ -89,13 +103,13 @@ function generateCollectionDDL(name, collection, dialect) {
89
103
  const columns = ["id TEXT PRIMARY KEY NOT NULL"];
90
104
  for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
91
105
  const sqlType = fieldTypeToSql(descriptor, dialect);
92
- let colDef = `${fieldName} ${sqlType}`;
106
+ let colDef = `${quoteIdent(fieldName)} ${sqlType}`;
93
107
  if (descriptor.defaultValue !== void 0) {
94
108
  colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
95
109
  }
96
110
  if (descriptor.kind === "enum" && descriptor.enumValues) {
97
111
  const values = descriptor.enumValues.map((v) => `'${v}'`).join(", ");
98
- colDef += ` CHECK (${fieldName} IN (${values}))`;
112
+ colDef += ` CHECK (${quoteIdent(fieldName)} IN (${values}))`;
99
113
  }
100
114
  columns.push(colDef);
101
115
  }
@@ -103,24 +117,26 @@ function generateCollectionDDL(name, collection, dialect) {
103
117
  columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
104
118
  columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
105
119
  columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
106
- statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
120
+ statements.push(`CREATE TABLE IF NOT EXISTS ${quoteIdent(name)} (
107
121
  ${columns.join(",\n ")}
108
122
  )`);
109
123
  for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
110
124
  const sqlType = fieldTypeToSql(descriptor, dialect);
111
- let colDef = `${fieldName} ${sqlType}`;
125
+ let colDef = `${quoteIdent(fieldName)} ${sqlType}`;
112
126
  if (descriptor.defaultValue !== void 0) {
113
127
  colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
114
128
  }
115
129
  statements.push(`--kora:safe-alter
116
- ALTER TABLE ${name} ADD COLUMN ${colDef}`);
130
+ ALTER TABLE ${quoteIdent(name)} ADD COLUMN ${colDef}`);
117
131
  }
118
132
  for (const indexField of collection.indexes) {
119
133
  statements.push(
120
- `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
134
+ `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${quoteIdent(name)} (${quoteIdent(indexField)})`
121
135
  );
122
136
  }
123
- statements.push(`CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${name} (_deleted)`);
137
+ statements.push(
138
+ `CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${quoteIdent(name)} (_deleted)`
139
+ );
124
140
  return statements;
125
141
  }
126
142
  function generateAllCollectionDDL(schema, dialect) {
@@ -590,7 +606,7 @@ var MemoryServerStore = class {
590
606
  };
591
607
 
592
608
  // src/store/postgres-server-store.ts
593
- import { generateUUIDv7 as generateUUIDv72 } from "@korajs/core";
609
+ import { generateUUIDv7 as generateUUIDv72, quoteIdent as quoteIdent2 } from "@korajs/core";
594
610
  import { and, asc, between, count, eq, sql } from "drizzle-orm";
595
611
 
596
612
  // src/store/drizzle-pg-schema.ts
@@ -754,7 +770,7 @@ var PostgresServerStore = class {
754
770
  this.assertCollection(collection);
755
771
  const schema = this.schema;
756
772
  const collectionDef = schema.collections[collection];
757
- const query = sql`SELECT * FROM ${sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
773
+ const query = sql`SELECT * FROM ${sql.raw(quoteIdent2(collection))} WHERE id = ${id} AND _deleted = 0`;
758
774
  const rows = await this.db.execute(query);
759
775
  if (rows.length === 0) return null;
760
776
  return this.deserializeRow(rows[0], collectionDef);
@@ -771,7 +787,7 @@ var PostgresServerStore = class {
771
787
  }
772
788
  }
773
789
  const whereClause = this.buildWhereClause(where ?? {}, false);
774
- const query = sql`SELECT COUNT(*) as cnt FROM ${sql.raw(collection)} WHERE ${whereClause}`;
790
+ const query = sql`SELECT COUNT(*) as cnt FROM ${sql.raw(quoteIdent2(collection))} WHERE ${whereClause}`;
775
791
  const rows = await this.db.execute(query);
776
792
  const cnt = rows[0]?.cnt;
777
793
  return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
@@ -858,7 +874,7 @@ var PostgresServerStore = class {
858
874
  );
859
875
  } else {
860
876
  await txOrDb.execute(
861
- sql`UPDATE ${sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
877
+ sql`UPDATE ${sql.raw(quoteIdent2(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
862
878
  );
863
879
  }
864
880
  }
@@ -877,16 +893,16 @@ var PostgresServerStore = class {
877
893
  updatedAt,
878
894
  0
879
895
  ];
880
- const columnsSql = sql.raw(allColumns.join(", "));
896
+ const columnsSql = sql.raw(allColumns.map((c) => quoteIdent2(c)).join(", "));
881
897
  const valuesSql = sql.join(
882
898
  values.map((v) => sql`${v}`),
883
899
  sql.raw(", ")
884
900
  );
885
901
  const updateSet = sql.raw(
886
- allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
902
+ allColumns.slice(1).map((c) => `${quoteIdent2(c)} = excluded.${quoteIdent2(c)}`).join(", ")
887
903
  );
888
904
  await txOrDb.execute(
889
- sql`INSERT INTO ${sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
905
+ sql`INSERT INTO ${sql.raw(quoteIdent2(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
890
906
  );
891
907
  }
892
908
  /**
@@ -946,7 +962,7 @@ var PostgresServerStore = class {
946
962
  );
947
963
  } else {
948
964
  await this.db.execute(
949
- sql`INSERT INTO ${sql.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
965
+ sql`INSERT INTO ${sql.raw(quoteIdent2(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
950
966
  );
951
967
  }
952
968
  }
@@ -959,10 +975,12 @@ var PostgresServerStore = class {
959
975
  options?.where ?? {},
960
976
  options?.includeDeleted ?? false
961
977
  );
962
- const parts = [sql`SELECT * FROM ${sql.raw(collection)} WHERE ${whereClause}`];
978
+ const parts = [
979
+ sql`SELECT * FROM ${sql.raw(quoteIdent2(collection))} WHERE ${whereClause}`
980
+ ];
963
981
  if (options?.orderBy) {
964
982
  const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
965
- parts.push(sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
983
+ parts.push(sql.raw(` ORDER BY ${quoteIdent2(options.orderBy)} ${dir}`));
966
984
  }
967
985
  if (options?.limit !== void 0) {
968
986
  parts.push(sql` LIMIT ${options.limit}`);
@@ -978,7 +996,7 @@ var PostgresServerStore = class {
978
996
  conditions.push(sql.raw("_deleted = 0"));
979
997
  }
980
998
  for (const [key, value] of Object.entries(where)) {
981
- conditions.push(sql`${sql.raw(key)} = ${value}`);
999
+ conditions.push(sql`${sql.raw(quoteIdent2(key))} = ${value}`);
982
1000
  }
983
1001
  if (conditions.length === 0) {
984
1002
  return sql.raw("1 = 1");
@@ -1173,7 +1191,7 @@ async function loadPostgresDeps() {
1173
1191
 
1174
1192
  // src/store/sqlite-server-store.ts
1175
1193
  import { createRequire } from "module";
1176
- import { generateUUIDv7 as generateUUIDv73 } from "@korajs/core";
1194
+ import { generateUUIDv7 as generateUUIDv73, quoteIdent as quoteIdent3 } from "@korajs/core";
1177
1195
  import { and as and2, asc as asc2, between as between2, count as count2, eq as eq2, sql as sql2 } from "drizzle-orm";
1178
1196
 
1179
1197
  // src/store/drizzle-schema.ts
@@ -1328,7 +1346,7 @@ var SqliteServerStore = class {
1328
1346
  this.assertCollection(collection);
1329
1347
  const schema = this.schema;
1330
1348
  const collectionDef = schema.collections[collection];
1331
- const query = sql2`SELECT * FROM ${sql2.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
1349
+ const query = sql2`SELECT * FROM ${sql2.raw(quoteIdent3(collection))} WHERE id = ${id} AND _deleted = 0`;
1332
1350
  const rows = this.db.all(query);
1333
1351
  if (rows.length === 0) return null;
1334
1352
  return this.deserializeRow(rows[0], collectionDef);
@@ -1344,7 +1362,7 @@ var SqliteServerStore = class {
1344
1362
  }
1345
1363
  }
1346
1364
  const whereClause = this.buildWhereClause(where ?? {}, false);
1347
- const query = sql2`SELECT COUNT(*) as cnt FROM ${sql2.raw(collection)} WHERE ${whereClause}`;
1365
+ const query = sql2`SELECT COUNT(*) as cnt FROM ${sql2.raw(quoteIdent3(collection))} WHERE ${whereClause}`;
1348
1366
  const rows = this.db.all(query);
1349
1367
  return rows[0]?.cnt ?? 0;
1350
1368
  }
@@ -1421,7 +1439,7 @@ var SqliteServerStore = class {
1421
1439
  );
1422
1440
  } else {
1423
1441
  txOrDb.run(
1424
- sql2`UPDATE ${sql2.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1442
+ sql2`UPDATE ${sql2.raw(quoteIdent3(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1425
1443
  );
1426
1444
  }
1427
1445
  }
@@ -1442,16 +1460,16 @@ var SqliteServerStore = class {
1442
1460
  0
1443
1461
  // _deleted = false
1444
1462
  ];
1445
- const columnsSql = sql2.raw(allColumns.join(", "));
1463
+ const columnsSql = sql2.raw(allColumns.map((c) => quoteIdent3(c)).join(", "));
1446
1464
  const valuesSql = sql2.join(
1447
1465
  values.map((v) => sql2`${v}`),
1448
1466
  sql2.raw(", ")
1449
1467
  );
1450
1468
  const updateSet = sql2.raw(
1451
- allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
1469
+ allColumns.slice(1).map((c) => `${quoteIdent3(c)} = excluded.${quoteIdent3(c)}`).join(", ")
1452
1470
  );
1453
1471
  txOrDb.run(
1454
- sql2`INSERT INTO ${sql2.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1472
+ sql2`INSERT INTO ${sql2.raw(quoteIdent3(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1455
1473
  );
1456
1474
  }
1457
1475
  /**
@@ -1509,7 +1527,7 @@ var SqliteServerStore = class {
1509
1527
  );
1510
1528
  } else {
1511
1529
  tx.run(
1512
- sql2`INSERT INTO ${sql2.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1530
+ sql2`INSERT INTO ${sql2.raw(quoteIdent3(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1513
1531
  );
1514
1532
  }
1515
1533
  }
@@ -1523,10 +1541,12 @@ var SqliteServerStore = class {
1523
1541
  options?.where ?? {},
1524
1542
  options?.includeDeleted ?? false
1525
1543
  );
1526
- const parts = [sql2`SELECT * FROM ${sql2.raw(collection)} WHERE ${whereClause}`];
1544
+ const parts = [
1545
+ sql2`SELECT * FROM ${sql2.raw(quoteIdent3(collection))} WHERE ${whereClause}`
1546
+ ];
1527
1547
  if (options?.orderBy) {
1528
1548
  const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
1529
- parts.push(sql2.raw(` ORDER BY ${options.orderBy} ${dir}`));
1549
+ parts.push(sql2.raw(` ORDER BY ${quoteIdent3(options.orderBy)} ${dir}`));
1530
1550
  }
1531
1551
  if (options?.limit !== void 0) {
1532
1552
  parts.push(sql2` LIMIT ${options.limit}`);
@@ -1542,7 +1562,7 @@ var SqliteServerStore = class {
1542
1562
  conditions.push(sql2.raw("_deleted = 0"));
1543
1563
  }
1544
1564
  for (const [key, value] of Object.entries(where)) {
1545
- conditions.push(sql2`${sql2.raw(key)} = ${value}`);
1565
+ conditions.push(sql2`${sql2.raw(quoteIdent3(key))} = ${value}`);
1546
1566
  }
1547
1567
  if (conditions.length === 0) {
1548
1568
  return sql2.raw("1 = 1");
@@ -1861,8 +1881,9 @@ var WsServerTransport = class {
1861
1881
  };
1862
1882
 
1863
1883
  // src/session/client-session.ts
1864
- import { generateUUIDv7 as generateUUIDv74 } from "@korajs/core";
1884
+ import { generateUUIDv7 as generateUUIDv74, hashBlob } from "@korajs/core";
1865
1885
  import { topologicalSort } from "@korajs/core/internal";
1886
+ import { decodeBlobChunkBytes } from "@korajs/sync";
1866
1887
  import {
1867
1888
  NegotiatedMessageSerializer,
1868
1889
  SCHEMA_MISMATCH_PREFIX,
@@ -1992,12 +2013,14 @@ async function applyServerOperation(store, op, relationLookup) {
1992
2013
  const lookup = relationLookup ?? (schema ? buildMergeRelationLookup(schema) : /* @__PURE__ */ new Map());
1993
2014
  const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
1994
2015
  if (!constraintCheck.valid) {
2016
+ const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
1995
2017
  return {
1996
2018
  result: "skipped",
1997
2019
  appliedOperations: [],
1998
2020
  rejection: {
1999
- code: constraintCheck.code ?? "CONSTRAINT_VIOLATION",
2000
- message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`
2021
+ code,
2022
+ message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`,
2023
+ retriable: isRetriableRejection(code)
2001
2024
  }
2002
2025
  };
2003
2026
  }
@@ -2010,7 +2033,8 @@ async function applyServerOperation(store, op, relationLookup) {
2010
2033
  appliedOperations: [],
2011
2034
  rejection: {
2012
2035
  code: "REFERENTIAL_INTEGRITY",
2013
- message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`
2036
+ message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`,
2037
+ retriable: isRetriableRejection("REFERENTIAL_INTEGRITY")
2014
2038
  }
2015
2039
  };
2016
2040
  }
@@ -2043,6 +2067,13 @@ async function applyServerOperation(store, op, relationLookup) {
2043
2067
  };
2044
2068
  }
2045
2069
 
2070
+ // src/auth/no-auth.ts
2071
+ var NoAuthProvider = class {
2072
+ async authenticate(_token) {
2073
+ return { userId: "anonymous" };
2074
+ }
2075
+ };
2076
+
2046
2077
  // src/scopes/resolve-session-scopes.ts
2047
2078
  import { buildScopeMap } from "@korajs/core";
2048
2079
  function resolveSessionScopes(schema, options) {
@@ -2168,6 +2199,25 @@ var SessionRateLimiter = class {
2168
2199
  // src/session/client-session.ts
2169
2200
  var DEFAULT_BATCH_SIZE = 100;
2170
2201
  var DEFAULT_SCHEMA_VERSION = 1;
2202
+ var warnedUnscopedProviders = /* @__PURE__ */ new WeakSet();
2203
+ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
2204
+ if (!auth || auth instanceof NoAuthProvider) {
2205
+ return;
2206
+ }
2207
+ if (resolvedScopes) {
2208
+ return;
2209
+ }
2210
+ if (!schema || Object.keys(schema.collections).length === 0) {
2211
+ return;
2212
+ }
2213
+ if (warnedUnscopedProviders.has(auth)) {
2214
+ return;
2215
+ }
2216
+ warnedUnscopedProviders.add(auth);
2217
+ console.warn(
2218
+ "[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.)"
2219
+ );
2220
+ }
2171
2221
  var ClientSession = class {
2172
2222
  state = "connected";
2173
2223
  clientNodeId = null;
@@ -2186,10 +2236,15 @@ var ClientSession = class {
2186
2236
  onRelay;
2187
2237
  onAwarenessUpdate;
2188
2238
  onYjsDocUpdate;
2239
+ onBlobChunkRequest;
2240
+ onBlobChunkResponse;
2241
+ persistBlobChunk;
2189
2242
  onClose;
2190
2243
  maxOperationBytes;
2191
2244
  maxOpsPerMinute;
2192
2245
  rateLimiter;
2246
+ validateOperation;
2247
+ koraContext;
2193
2248
  constructor(options) {
2194
2249
  this.sessionId = options.sessionId;
2195
2250
  this.transport = options.transport;
@@ -2207,10 +2262,15 @@ var ClientSession = class {
2207
2262
  this.onRelay = options.onRelay ?? null;
2208
2263
  this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
2209
2264
  this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
2265
+ this.onBlobChunkRequest = options.onBlobChunkRequest ?? null;
2266
+ this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
2267
+ this.persistBlobChunk = options.persistBlobChunk ?? null;
2210
2268
  this.onClose = options.onClose ?? null;
2211
2269
  this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
2212
2270
  this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
2213
2271
  this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
2272
+ this.validateOperation = options.validateOperation ?? null;
2273
+ this.koraContext = options.koraContext ?? null;
2214
2274
  }
2215
2275
  /**
2216
2276
  * Start handling messages from the client transport.
@@ -2312,8 +2372,33 @@ var ClientSession = class {
2312
2372
  case "yjs-doc-update":
2313
2373
  this.handleYjsDocUpdate(message);
2314
2374
  break;
2375
+ case "blob-chunk-request":
2376
+ this.onBlobChunkRequest?.(this.sessionId, message);
2377
+ break;
2378
+ case "blob-chunk-response":
2379
+ this.onBlobChunkResponse?.(this.sessionId, message);
2380
+ break;
2381
+ case "blob-chunk-push":
2382
+ await this.handleBlobChunkPush(message);
2383
+ break;
2315
2384
  }
2316
2385
  }
2386
+ /**
2387
+ * Persist a client-uploaded blob chunk (or manifest) centrally. The bytes are
2388
+ * verified to hash to the declared hash before storing, so a corrupt or
2389
+ * mislabeled upload is rejected rather than served later as trusted content.
2390
+ */
2391
+ async handleBlobChunkPush(message) {
2392
+ if (!this.persistBlobChunk) {
2393
+ return;
2394
+ }
2395
+ const bytes = decodeBlobChunkBytes(message.bytes);
2396
+ const actual = await hashBlob(bytes);
2397
+ if (actual !== message.hash) {
2398
+ return;
2399
+ }
2400
+ await this.persistBlobChunk(message.hash, bytes);
2401
+ }
2317
2402
  handleMessageFailure(error) {
2318
2403
  const reason = error instanceof Error ? error.message : "Message handling failed";
2319
2404
  this.sendError("SYNC_ERROR", reason, true);
@@ -2347,6 +2432,7 @@ var ClientSession = class {
2347
2432
  this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
2348
2433
  }
2349
2434
  }
2435
+ warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
2350
2436
  if (msg.syncQueries && msg.syncQueries.length > 0) {
2351
2437
  this.syncQuerySubsets = dedupeQuerySubsets(msg.syncQueries);
2352
2438
  } else {
@@ -2383,6 +2469,9 @@ var ClientSession = class {
2383
2469
  accepted: true,
2384
2470
  selectedWireFormat,
2385
2471
  serverTime: Date.now(),
2472
+ // Advertise central blob storage so the client uploads the bytes behind
2473
+ // its blob fields, keeping them available after the author goes offline.
2474
+ ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
2386
2475
  // Confirm the accepted scope so the client knows what data will be synced.
2387
2476
  // This may differ from what the client requested if auth scopes are narrower.
2388
2477
  ...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
@@ -2428,10 +2517,39 @@ var ClientSession = class {
2428
2517
  );
2429
2518
  continue;
2430
2519
  }
2520
+ if (this.validateOperation && this.koraContext) {
2521
+ let decision;
2522
+ try {
2523
+ decision = await this.validateOperation(op, {
2524
+ auth: this.authContext,
2525
+ kora: this.koraContext
2526
+ });
2527
+ } catch (error) {
2528
+ const message = error instanceof Error ? error.message : "validator error";
2529
+ this.sendOperationRejected(op, "VALIDATION_ERROR", `Validator threw: ${message}`, true);
2530
+ continue;
2531
+ }
2532
+ if (decision.action === "reject") {
2533
+ this.sendOperationRejected(
2534
+ op,
2535
+ decision.code,
2536
+ decision.message,
2537
+ decision.retriable ?? isRetriableRejection(decision.code)
2538
+ );
2539
+ continue;
2540
+ }
2541
+ if (decision.action === "ignore") {
2542
+ continue;
2543
+ }
2544
+ }
2431
2545
  try {
2432
2546
  const applyResult = await applyServerOperation(this.store, op);
2433
2547
  if (applyResult.rejection) {
2434
- this.sendError(applyResult.rejection.code, applyResult.rejection.message, false);
2548
+ this.sendError(
2549
+ applyResult.rejection.code,
2550
+ applyResult.rejection.message,
2551
+ applyResult.rejection.retriable
2552
+ );
2435
2553
  continue;
2436
2554
  }
2437
2555
  if (applyResult.result === "applied") {
@@ -2550,6 +2668,25 @@ var ClientSession = class {
2550
2668
  };
2551
2669
  this.sendToClient(errorMsg);
2552
2670
  }
2671
+ /**
2672
+ * Reject one specific client operation, tied to its id, so the submitter can
2673
+ * divert it out of its pending queue into a durable rejected store rather than
2674
+ * losing it or retrying forever. The op is NOT materialized, so no other
2675
+ * replica ever sees it.
2676
+ */
2677
+ sendOperationRejected(op, code, message, retriable) {
2678
+ const rejectedMsg = {
2679
+ type: "operation-rejected",
2680
+ messageId: generateUUIDv74(),
2681
+ operationId: op.id,
2682
+ collection: op.collection,
2683
+ recordId: op.recordId,
2684
+ code,
2685
+ message,
2686
+ retriable
2687
+ };
2688
+ this.sendToClient(rejectedMsg);
2689
+ }
2553
2690
  setSerializerWireFormat(format) {
2554
2691
  if (typeof this.serializer.setWireFormat === "function") {
2555
2692
  this.serializer.setWireFormat(format);
@@ -2570,7 +2707,7 @@ function selectWireFormat(supportedWireFormats) {
2570
2707
  }
2571
2708
 
2572
2709
  // src/server/kora-sync-server.ts
2573
- import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv76 } from "@korajs/core";
2710
+ import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv77, isBlobRef } from "@korajs/core";
2574
2711
  import { SimpleEventEmitter } from "@korajs/core/internal";
2575
2712
  import { JsonMessageSerializer as JsonMessageSerializer2 } from "@korajs/sync";
2576
2713
 
@@ -2787,6 +2924,115 @@ var ServerMetricsCollector = class {
2787
2924
  }
2788
2925
  };
2789
2926
 
2927
+ // src/richtext/blob-chunk-relay.ts
2928
+ import {
2929
+ encodeBlobChunkBytes
2930
+ } from "@korajs/sync";
2931
+ var BlobChunkRelay = class {
2932
+ clients = /* @__PURE__ */ new Map();
2933
+ /** requestId -> the session that originated the request (to route the answer back). */
2934
+ pending = /* @__PURE__ */ new Map();
2935
+ resolveBlobChunk;
2936
+ constructor(resolveBlobChunk) {
2937
+ this.resolveBlobChunk = resolveBlobChunk ?? null;
2938
+ }
2939
+ addClient(sessionId, transport) {
2940
+ this.clients.set(sessionId, { sessionId, transport });
2941
+ }
2942
+ removeClient(sessionId) {
2943
+ this.clients.delete(sessionId);
2944
+ for (const [requestId, originSessionId] of this.pending) {
2945
+ if (originSessionId === sessionId) {
2946
+ this.pending.delete(requestId);
2947
+ }
2948
+ }
2949
+ }
2950
+ getClientCount() {
2951
+ return this.clients.size;
2952
+ }
2953
+ getPendingCount() {
2954
+ return this.pending.size;
2955
+ }
2956
+ clear() {
2957
+ this.clients.clear();
2958
+ this.pending.clear();
2959
+ }
2960
+ /**
2961
+ * Handle an inbound chunk request from a session. Tries the server's own
2962
+ * store first (if configured), otherwise forwards the request to peer
2963
+ * sessions and remembers who to route the answer back to.
2964
+ */
2965
+ handleRequest(sourceSessionId, message) {
2966
+ if (!this.clients.has(sourceSessionId)) {
2967
+ return;
2968
+ }
2969
+ if (this.resolveBlobChunk) {
2970
+ void this.resolveBlobChunk(message.hash).then(
2971
+ (bytes) => {
2972
+ if (bytes !== null) {
2973
+ this.sendResponseTo(sourceSessionId, message.requestId, encodeBlobChunkBytes(bytes));
2974
+ return;
2975
+ }
2976
+ this.forwardRequestToPeers(sourceSessionId, message);
2977
+ },
2978
+ () => {
2979
+ this.forwardRequestToPeers(sourceSessionId, message);
2980
+ }
2981
+ );
2982
+ return;
2983
+ }
2984
+ this.forwardRequestToPeers(sourceSessionId, message);
2985
+ }
2986
+ /**
2987
+ * Handle an inbound chunk response from a peer. Routes it back to the session
2988
+ * that originally requested it. A "not held" answer (bytes === null) is
2989
+ * ignored so a peer without the chunk does not preempt one that has it; the
2990
+ * requester's own per-request timeout bounds the wait.
2991
+ */
2992
+ handleResponse(sourceSessionId, message) {
2993
+ if (!this.clients.has(sourceSessionId)) {
2994
+ return;
2995
+ }
2996
+ if (message.bytes === null) {
2997
+ return;
2998
+ }
2999
+ const originSessionId = this.pending.get(message.requestId);
3000
+ if (!originSessionId) {
3001
+ return;
3002
+ }
3003
+ this.pending.delete(message.requestId);
3004
+ this.sendResponseTo(originSessionId, message.requestId, message.bytes);
3005
+ }
3006
+ forwardRequestToPeers(sourceSessionId, message) {
3007
+ this.pending.set(message.requestId, sourceSessionId);
3008
+ for (const [, client] of this.clients) {
3009
+ if (client.sessionId === sourceSessionId) {
3010
+ continue;
3011
+ }
3012
+ if (!client.transport.isConnected()) {
3013
+ continue;
3014
+ }
3015
+ client.transport.send(message);
3016
+ }
3017
+ }
3018
+ sendResponseTo(sessionId, requestId, bytes) {
3019
+ const client = this.clients.get(sessionId);
3020
+ if (!client || !client.transport.isConnected()) {
3021
+ return;
3022
+ }
3023
+ const response = {
3024
+ type: "blob-chunk-response",
3025
+ messageId: generateResponseId(requestId),
3026
+ requestId,
3027
+ bytes
3028
+ };
3029
+ client.transport.send(response);
3030
+ }
3031
+ };
3032
+ function generateResponseId(requestId) {
3033
+ return `blob-resp-${requestId}`;
3034
+ }
3035
+
2790
3036
  // src/richtext/yjs-doc-relay.ts
2791
3037
  var YjsDocRelay = class {
2792
3038
  clients = /* @__PURE__ */ new Map();
@@ -2821,6 +3067,169 @@ var YjsDocRelay = class {
2821
3067
  }
2822
3068
  };
2823
3069
 
3070
+ // src/server/route-context.ts
3071
+ import { HybridLogicalClock as HybridLogicalClock2, createOperation as createOperation2, generateUUIDv7 as generateUUIDv76 } from "@korajs/core";
3072
+ function recordMatchesScope(collection, record, scope) {
3073
+ const collectionScope = scope[collection];
3074
+ if (!collectionScope) {
3075
+ return false;
3076
+ }
3077
+ for (const [field, expected] of Object.entries(collectionScope)) {
3078
+ if (record[field] !== expected) {
3079
+ return false;
3080
+ }
3081
+ }
3082
+ return true;
3083
+ }
3084
+ function pickFields(record, keys) {
3085
+ const picked = {};
3086
+ for (const key of keys) {
3087
+ if (key in record) {
3088
+ picked[key] = record[key];
3089
+ }
3090
+ }
3091
+ return picked;
3092
+ }
3093
+ async function buildRouteOperation(store, mutation) {
3094
+ const nodeId = store.getNodeId();
3095
+ const clock = new HybridLogicalClock2(nodeId);
3096
+ const schemaVersion = store.getSchema()?.version ?? 1;
3097
+ const sequenceNumber = nextServerSequenceNumber(store);
3098
+ if (mutation.type === "insert") {
3099
+ return createOperation2(
3100
+ {
3101
+ nodeId,
3102
+ type: "insert",
3103
+ collection: mutation.collection,
3104
+ recordId: mutation.recordId ?? generateUUIDv76(),
3105
+ data: mutation.data ?? {},
3106
+ previousData: null,
3107
+ sequenceNumber,
3108
+ causalDeps: [],
3109
+ schemaVersion
3110
+ },
3111
+ clock
3112
+ );
3113
+ }
3114
+ if (!mutation.recordId) {
3115
+ throw new RouteMutationError(
3116
+ "MISSING_RECORD_ID",
3117
+ `A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
3118
+ );
3119
+ }
3120
+ const current = await store.findRecord(mutation.collection, mutation.recordId);
3121
+ if (mutation.type === "update") {
3122
+ const data = mutation.data ?? {};
3123
+ return createOperation2(
3124
+ {
3125
+ nodeId,
3126
+ type: "update",
3127
+ collection: mutation.collection,
3128
+ recordId: mutation.recordId,
3129
+ data,
3130
+ previousData: current ? pickFields(current, Object.keys(data)) : {},
3131
+ sequenceNumber,
3132
+ causalDeps: [],
3133
+ schemaVersion
3134
+ },
3135
+ clock
3136
+ );
3137
+ }
3138
+ return createOperation2(
3139
+ {
3140
+ nodeId,
3141
+ type: "delete",
3142
+ collection: mutation.collection,
3143
+ recordId: mutation.recordId,
3144
+ data: null,
3145
+ previousData: current ? { ...current } : null,
3146
+ sequenceNumber,
3147
+ causalDeps: [],
3148
+ schemaVersion
3149
+ },
3150
+ clock
3151
+ );
3152
+ }
3153
+ var RouteMutationError = class extends Error {
3154
+ constructor(code, message) {
3155
+ super(message);
3156
+ this.code = code;
3157
+ this.name = "RouteMutationError";
3158
+ }
3159
+ code;
3160
+ };
3161
+ function createRouteContext(server, store) {
3162
+ let mutationTail = Promise.resolve();
3163
+ function serialize(work) {
3164
+ const run = mutationTail.then(work, work);
3165
+ mutationTail = run.then(
3166
+ () => void 0,
3167
+ () => void 0
3168
+ );
3169
+ return run;
3170
+ }
3171
+ return {
3172
+ apply(mutation, options) {
3173
+ return serialize(async () => {
3174
+ let op;
3175
+ try {
3176
+ op = await buildRouteOperation(store, mutation);
3177
+ } catch (error) {
3178
+ if (error instanceof RouteMutationError) {
3179
+ return {
3180
+ ok: false,
3181
+ code: error.code,
3182
+ message: error.message,
3183
+ retriable: isRetriableRejection(error.code)
3184
+ };
3185
+ }
3186
+ throw error;
3187
+ }
3188
+ if (options?.scope && !operationMatchesScopes(op, options.scope)) {
3189
+ return {
3190
+ ok: false,
3191
+ code: "SCOPE_VIOLATION",
3192
+ message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
3193
+ retriable: isRetriableRejection("SCOPE_VIOLATION")
3194
+ };
3195
+ }
3196
+ const result = await server.applyLocalOperation(op);
3197
+ if (result.result !== "applied") {
3198
+ const code = result.rejection?.code ?? "NOT_APPLIED";
3199
+ return {
3200
+ ok: false,
3201
+ code,
3202
+ message: result.rejection?.message ?? `Operation on "${mutation.collection}" was not applied (${result.result}).`,
3203
+ // Prefer the pipeline's own classification when present; fall back
3204
+ // to classifying the code so this stays correct if new codes appear.
3205
+ retriable: result.rejection?.retriable ?? isRetriableRejection(code)
3206
+ };
3207
+ }
3208
+ const record = await store.findRecord(mutation.collection, op.recordId);
3209
+ return { ok: true, operation: op, record };
3210
+ });
3211
+ },
3212
+ async query(collection, options) {
3213
+ const { scope, ...queryOptions } = options ?? {};
3214
+ const records = await store.queryCollection(collection, queryOptions);
3215
+ if (!scope) {
3216
+ return records;
3217
+ }
3218
+ return records.filter((record) => recordMatchesScope(collection, record, scope));
3219
+ },
3220
+ async findById(collection, id, options) {
3221
+ const record = await store.findRecord(collection, id);
3222
+ if (!record) {
3223
+ return null;
3224
+ }
3225
+ if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
3226
+ return null;
3227
+ }
3228
+ return record;
3229
+ }
3230
+ };
3231
+ }
3232
+
2824
3233
  // src/server/kora-sync-server.ts
2825
3234
  var DEFAULT_MAX_CONNECTIONS = 0;
2826
3235
  var DEFAULT_BATCH_SIZE2 = 100;
@@ -2843,10 +3252,18 @@ var KoraSyncServer = class {
2843
3252
  metrics;
2844
3253
  awarenessRelay = new AwarenessRelay();
2845
3254
  yjsDocRelay = new YjsDocRelay();
3255
+ blobChunkRelay;
3256
+ persistBlobChunk;
3257
+ maxOperationBytes;
3258
+ maxOpsPerMinute;
3259
+ validateOperation;
3260
+ koraContext;
2846
3261
  sessions = /* @__PURE__ */ new Map();
2847
3262
  httpClients = /* @__PURE__ */ new Map();
2848
3263
  httpSessionToClient = /* @__PURE__ */ new Map();
2849
- serverVersion = "0.4.0";
3264
+ // Informational value reported by getStatus(). Keep in sync with the
3265
+ // @korajs/server package version on release; it is not used for protocol negotiation.
3266
+ serverVersion = "1.0.0-beta.0";
2850
3267
  wsServer = null;
2851
3268
  running = false;
2852
3269
  constructor(config) {
@@ -2867,10 +3284,24 @@ var KoraSyncServer = class {
2867
3284
  this.logger = config.logger ?? createDefaultLogger();
2868
3285
  this.metrics = config.metricsCollector ?? new ServerMetricsCollector();
2869
3286
  this.metrics.setSchemaVersion(this.schemaVersion);
3287
+ this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
3288
+ this.persistBlobChunk = config.persistBlobChunk ?? null;
3289
+ this.maxOperationBytes = config.maxOperationBytes;
3290
+ this.maxOpsPerMinute = config.maxOpsPerMinute;
3291
+ this.validateOperation = config.validateOperation;
3292
+ this.koraContext = createRouteContext(this, this.store);
2870
3293
  if (!this.emitter) {
2871
3294
  this.emitter = new SimpleEventEmitter();
2872
3295
  }
2873
3296
  }
3297
+ /**
3298
+ * The trusted, scoped data-plane context (`apply` / `query` / `findById`).
3299
+ * Shared with the production server so HTTP routes, the operation validator,
3300
+ * and `server.kora` are all the same object over the same pipeline.
3301
+ */
3302
+ getKoraContext() {
3303
+ return this.koraContext;
3304
+ }
2874
3305
  /**
2875
3306
  * Subscribe to session-level events for metrics collection and logging.
2876
3307
  * Called when a new session is created.
@@ -2990,6 +3421,7 @@ var KoraSyncServer = class {
2990
3421
  });
2991
3422
  this.awarenessRelay.clear();
2992
3423
  this.yjsDocRelay.clear();
3424
+ this.blobChunkRelay.clear();
2993
3425
  for (const session of this.sessions.values()) {
2994
3426
  session.close("server shutting down");
2995
3427
  }
@@ -3051,7 +3483,7 @@ var KoraSyncServer = class {
3051
3483
  if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
3052
3484
  transport.send({
3053
3485
  type: "error",
3054
- messageId: generateUUIDv76(),
3486
+ messageId: generateUUIDv77(),
3055
3487
  code: "MAX_CONNECTIONS",
3056
3488
  message: `Server has reached maximum connections (${this.maxConnections})`,
3057
3489
  retriable: true
@@ -3069,7 +3501,7 @@ var KoraSyncServer = class {
3069
3501
  max: this.maxConnections
3070
3502
  });
3071
3503
  }
3072
- const sessionId = generateUUIDv76();
3504
+ const sessionId = generateUUIDv77();
3073
3505
  this.metrics.recordConnection(sessionId);
3074
3506
  const sessionEmitter = new SimpleEventEmitter();
3075
3507
  sessionEmitter.on("sync:connected", (event) => {
@@ -3134,12 +3566,25 @@ var KoraSyncServer = class {
3134
3566
  onYjsDocUpdate: (sourceSessionId, message) => {
3135
3567
  this.handleYjsDocRelay(sourceSessionId, message);
3136
3568
  },
3569
+ onBlobChunkRequest: (sourceSessionId, message) => {
3570
+ this.blobChunkRelay.handleRequest(sourceSessionId, message);
3571
+ },
3572
+ onBlobChunkResponse: (sourceSessionId, message) => {
3573
+ this.blobChunkRelay.handleResponse(sourceSessionId, message);
3574
+ },
3575
+ ...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
3576
+ // Only forward when configured, so an unset server value leaves the
3577
+ // session on its own documented default rather than `undefined`.
3578
+ ...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
3579
+ ...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
3580
+ ...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
3137
3581
  onClose: (sid) => {
3138
3582
  this.handleSessionClose(sid);
3139
3583
  }
3140
3584
  });
3141
3585
  this.sessions.set(sessionId, session);
3142
3586
  this.yjsDocRelay.addClient(sessionId, transport);
3587
+ this.blobChunkRelay.addClient(sessionId, transport);
3143
3588
  session.start();
3144
3589
  this.logger.log({
3145
3590
  timestamp: Date.now(),
@@ -3172,6 +3617,61 @@ var KoraSyncServer = class {
3172
3617
  errorCount: snapshot.errorCount
3173
3618
  };
3174
3619
  }
3620
+ /**
3621
+ * Apply a server-originated operation (for example one created by a custom
3622
+ * HTTP route) through the same validated pipeline that incoming client
3623
+ * operations use — Tier 2 constraints, referential integrity, and cascade
3624
+ * side effects — then relay every applied operation to connected clients.
3625
+ *
3626
+ * Because the operation did not come from a client session, it is relayed to
3627
+ * ALL sessions (there is no source session to exclude). Each session still
3628
+ * applies its own per-scope visibility filter in `relayOperations`, so a
3629
+ * client only receives the operation if it falls within that client's scope.
3630
+ *
3631
+ * @param op - A fully-formed, server-originated operation to apply
3632
+ * @returns The apply result, including any server-generated side-effect ops
3633
+ */
3634
+ async applyLocalOperation(op) {
3635
+ const result = await applyServerOperation(this.store, op);
3636
+ if (result.result === "applied" && result.appliedOperations.length > 0) {
3637
+ for (const session of this.sessions.values()) {
3638
+ session.relayOperations(result.appliedOperations);
3639
+ }
3640
+ }
3641
+ return result;
3642
+ }
3643
+ /**
3644
+ * Collect every blob reference still reachable from live records on the server.
3645
+ *
3646
+ * This is the live set for garbage-collecting the server's central blob store:
3647
+ * pass the result to `collectBlobGarbage(blobStore, liveRefs)` from
3648
+ * `@korajs/store` to reclaim bytes no record references any more. Only
3649
+ * collections that declare a `blob` field are scanned.
3650
+ *
3651
+ * @returns Every live blob reference across all collections
3652
+ */
3653
+ async getLiveBlobRefs() {
3654
+ const schema = this.store.getSchema();
3655
+ if (!schema) {
3656
+ return [];
3657
+ }
3658
+ const refs = [];
3659
+ for (const [name, collection] of Object.entries(schema.collections)) {
3660
+ const hasBlobField = Object.values(collection.fields).some((field) => field.kind === "blob");
3661
+ if (!hasBlobField) {
3662
+ continue;
3663
+ }
3664
+ const records = await this.store.materializeCollection(name);
3665
+ for (const record of records) {
3666
+ for (const value of Object.values(record)) {
3667
+ if (isBlobRef(value)) {
3668
+ refs.push(value);
3669
+ }
3670
+ }
3671
+ }
3672
+ }
3673
+ return refs;
3674
+ }
3175
3675
  /**
3176
3676
  * Get the number of currently connected clients.
3177
3677
  */
@@ -3205,6 +3705,7 @@ var KoraSyncServer = class {
3205
3705
  this.metrics.recordDisconnection(sessionId);
3206
3706
  this.awarenessRelay.removeClient(sessionId);
3207
3707
  this.yjsDocRelay.removeClient(sessionId);
3708
+ this.blobChunkRelay.removeClient(sessionId);
3208
3709
  this.sessions.delete(sessionId);
3209
3710
  const clientId = this.httpSessionToClient.get(sessionId);
3210
3711
  if (clientId) {
@@ -3257,13 +3758,6 @@ function normalizeHttpBody(body, contentType) {
3257
3758
  return body;
3258
3759
  }
3259
3760
 
3260
- // src/auth/no-auth.ts
3261
- var NoAuthProvider = class {
3262
- async authenticate(_token) {
3263
- return { userId: "anonymous" };
3264
- }
3265
- };
3266
-
3267
3761
  // src/auth/token-auth.ts
3268
3762
  var TokenAuthProvider = class {
3269
3763
  validate;
@@ -3368,6 +3862,7 @@ function createProductionServer(config) {
3368
3862
  enableDashboard: true,
3369
3863
  ...config.syncOptions
3370
3864
  });
3865
+ const routeContext = syncServer.getKoraContext();
3371
3866
  let httpServer = null;
3372
3867
  function getOperationalToken(kind) {
3373
3868
  const auth = config.operationalAuth;
@@ -3454,6 +3949,10 @@ function createProductionServer(config) {
3454
3949
  const chunks = [];
3455
3950
  req.on("data", (chunk) => chunks.push(chunk));
3456
3951
  req.on("end", () => resolve(Buffer.concat(chunks)));
3952
+ if (!req.readableFlowing) {
3953
+ req.resume();
3954
+ }
3955
+ req.on("error", () => resolve(Buffer.concat(chunks)));
3457
3956
  });
3458
3957
  }
3459
3958
  async function readJsonBody(req) {
@@ -3503,6 +4002,10 @@ function createProductionServer(config) {
3503
4002
  res.end(JSON.stringify(result.body ?? null));
3504
4003
  }
3505
4004
  return {
4005
+ kora: routeContext,
4006
+ getLiveBlobRefs() {
4007
+ return syncServer.getLiveBlobRefs();
4008
+ },
3506
4009
  async start() {
3507
4010
  const { createServer } = await import("http");
3508
4011
  const { createReadStream, existsSync, statSync } = await import("fs");
@@ -3510,6 +4013,19 @@ function createProductionServer(config) {
3510
4013
  const { WebSocketServer } = await import("ws");
3511
4014
  const distDir = resolve(staticDir);
3512
4015
  httpServer = createServer(async (req, res) => {
4016
+ try {
4017
+ await handleRequest(req, res);
4018
+ } catch (error) {
4019
+ console.error("[kora] Unhandled error in HTTP request handler:", error);
4020
+ if (!res.headersSent) {
4021
+ res.writeHead(500, { "Content-Type": "application/json" });
4022
+ res.end(JSON.stringify({ error: "Internal server error" }));
4023
+ } else if (!res.writableEnded) {
4024
+ res.end();
4025
+ }
4026
+ }
4027
+ });
4028
+ async function handleRequest(req, res) {
3513
4029
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
3514
4030
  res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
3515
4031
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
@@ -3637,7 +4153,8 @@ data: ${JSON.stringify(s)}
3637
4153
  body: await readJsonBody(req),
3638
4154
  headers: req.headers,
3639
4155
  query: getQuery(url),
3640
- ip: getClientIp(req)
4156
+ ip: getClientIp(req),
4157
+ kora: routeContext
3641
4158
  });
3642
4159
  writeJsonResponse(res, result);
3643
4160
  return;
@@ -3671,7 +4188,7 @@ data: ${JSON.stringify(s)}
3671
4188
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
3672
4189
  res.writeHead(200, { "Content-Type": contentType });
3673
4190
  createReadStream(filePath).pipe(res);
3674
- });
4191
+ }
3675
4192
  const wss = new WebSocketServer({ noServer: true });
3676
4193
  httpServer.on("upgrade", (req, socket, head) => {
3677
4194
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
@@ -3787,6 +4304,7 @@ export {
3787
4304
  MixedAuthProvider,
3788
4305
  NoAuthProvider,
3789
4306
  PostgresServerStore,
4307
+ RETRIABLE_REJECTION_CODES,
3790
4308
  SqliteServerStore,
3791
4309
  TokenAuthProvider,
3792
4310
  WsServerTransport,
@@ -3797,6 +4315,7 @@ export {
3797
4315
  createPrettyLogger,
3798
4316
  createProductionServer,
3799
4317
  createSilentLogger,
3800
- createSqliteServerStore
4318
+ createSqliteServerStore,
4319
+ isRetriableRejection
3801
4320
  };
3802
4321
  //# sourceMappingURL=index.js.map