@korajs/server 0.6.1 → 1.0.0-beta.1

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
@@ -58,6 +58,7 @@ function createDefaultLogger() {
58
58
  import { generateUUIDv7 } from "@korajs/core";
59
59
 
60
60
  // src/store/materialization.ts
61
+ import { StorageError } from "@korajs/core";
61
62
  function fieldTypeToSql(descriptor, dialect) {
62
63
  switch (descriptor.kind) {
63
64
  case "string":
@@ -153,6 +154,52 @@ function replayOperationsForRecord(ops) {
153
154
  }
154
155
  return deleted ? null : record;
155
156
  }
157
+ var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
158
+ var BASE64_REVERSE = new Map(
159
+ Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
160
+ );
161
+ function base64ToBytes(base64) {
162
+ const cleaned = base64.replace(/=+$/, "");
163
+ const out = new Uint8Array(Math.floor(cleaned.length * 6 / 8));
164
+ let buffer = 0;
165
+ let bits = 0;
166
+ let index3 = 0;
167
+ for (const char of cleaned) {
168
+ const value = BASE64_REVERSE.get(char);
169
+ if (value === void 0) {
170
+ throw new StorageError(`Invalid base64 character "${char}" in tagged richtext value`, {
171
+ char
172
+ });
173
+ }
174
+ buffer = buffer << 6 | value;
175
+ bits += 6;
176
+ if (bits >= 8) {
177
+ bits -= 8;
178
+ out[index3] = buffer >> bits & 255;
179
+ index3 += 1;
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+ function decodeRichtextColumnValue(value) {
185
+ if (typeof value !== "object" || value === null || ArrayBuffer.isView(value)) {
186
+ return value;
187
+ }
188
+ const record = value;
189
+ if (Object.keys(record).length === 1 && typeof record.$koraBytes === "string") {
190
+ return base64ToBytes(record.$koraBytes);
191
+ }
192
+ const keys = Object.keys(record);
193
+ if (keys.length > 0 && keys.every((k, i) => k === String(i) && typeof record[k] === "number")) {
194
+ const bytes = new Uint8Array(keys.length);
195
+ for (let i = 0; i < keys.length; i++) {
196
+ const byte = record[String(i)];
197
+ bytes[i] = typeof byte === "number" ? byte & 255 : 0;
198
+ }
199
+ return bytes;
200
+ }
201
+ return value;
202
+ }
156
203
  function serializeFieldValue(value, descriptor) {
157
204
  if (value === null || value === void 0) return null;
158
205
  switch (descriptor.kind) {
@@ -160,6 +207,8 @@ function serializeFieldValue(value, descriptor) {
160
207
  return typeof value === "string" ? value : JSON.stringify(value);
161
208
  case "boolean":
162
209
  return value ? 1 : 0;
210
+ case "richtext":
211
+ return decodeRichtextColumnValue(value);
163
212
  default:
164
213
  return value;
165
214
  }
@@ -1812,8 +1861,9 @@ var WsServerTransport = class {
1812
1861
  };
1813
1862
 
1814
1863
  // src/session/client-session.ts
1815
- import { generateUUIDv7 as generateUUIDv74 } from "@korajs/core";
1864
+ import { generateUUIDv7 as generateUUIDv74, hashBlob } from "@korajs/core";
1816
1865
  import { topologicalSort } from "@korajs/core/internal";
1866
+ import { decodeBlobChunkBytes } from "@korajs/sync";
1817
1867
  import {
1818
1868
  NegotiatedMessageSerializer,
1819
1869
  SCHEMA_MISMATCH_PREFIX,
@@ -1994,6 +2044,13 @@ async function applyServerOperation(store, op, relationLookup) {
1994
2044
  };
1995
2045
  }
1996
2046
 
2047
+ // src/auth/no-auth.ts
2048
+ var NoAuthProvider = class {
2049
+ async authenticate(_token) {
2050
+ return { userId: "anonymous" };
2051
+ }
2052
+ };
2053
+
1997
2054
  // src/scopes/resolve-session-scopes.ts
1998
2055
  import { buildScopeMap } from "@korajs/core";
1999
2056
  function resolveSessionScopes(schema, options) {
@@ -2060,9 +2117,17 @@ function asRecord(value) {
2060
2117
  }
2061
2118
 
2062
2119
  // src/session/operation-validation.ts
2120
+ import { MAX_LOGICAL } from "@korajs/core";
2063
2121
  var SERVER_MAX_TIMESTAMP_FUTURE_MS = 6e4;
2064
2122
  function isOperationTimestampValid(op, now = Date.now()) {
2065
- return op.timestamp.wallTime <= now + SERVER_MAX_TIMESTAMP_FUTURE_MS;
2123
+ const { wallTime, logical } = op.timestamp;
2124
+ if (!Number.isInteger(wallTime) || wallTime < 0) {
2125
+ return false;
2126
+ }
2127
+ if (!Number.isInteger(logical) || logical < 0 || logical > MAX_LOGICAL) {
2128
+ return false;
2129
+ }
2130
+ return wallTime <= now + SERVER_MAX_TIMESTAMP_FUTURE_MS;
2066
2131
  }
2067
2132
 
2068
2133
  // src/session/session-operation-limits.ts
@@ -2111,6 +2176,25 @@ var SessionRateLimiter = class {
2111
2176
  // src/session/client-session.ts
2112
2177
  var DEFAULT_BATCH_SIZE = 100;
2113
2178
  var DEFAULT_SCHEMA_VERSION = 1;
2179
+ var warnedUnscopedProviders = /* @__PURE__ */ new WeakSet();
2180
+ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
2181
+ if (!auth || auth instanceof NoAuthProvider) {
2182
+ return;
2183
+ }
2184
+ if (resolvedScopes) {
2185
+ return;
2186
+ }
2187
+ if (!schema || Object.keys(schema.collections).length === 0) {
2188
+ return;
2189
+ }
2190
+ if (warnedUnscopedProviders.has(auth)) {
2191
+ return;
2192
+ }
2193
+ warnedUnscopedProviders.add(auth);
2194
+ console.warn(
2195
+ "[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.)"
2196
+ );
2197
+ }
2114
2198
  var ClientSession = class {
2115
2199
  state = "connected";
2116
2200
  clientNodeId = null;
@@ -2129,6 +2213,9 @@ var ClientSession = class {
2129
2213
  onRelay;
2130
2214
  onAwarenessUpdate;
2131
2215
  onYjsDocUpdate;
2216
+ onBlobChunkRequest;
2217
+ onBlobChunkResponse;
2218
+ persistBlobChunk;
2132
2219
  onClose;
2133
2220
  maxOperationBytes;
2134
2221
  maxOpsPerMinute;
@@ -2150,6 +2237,9 @@ var ClientSession = class {
2150
2237
  this.onRelay = options.onRelay ?? null;
2151
2238
  this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
2152
2239
  this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
2240
+ this.onBlobChunkRequest = options.onBlobChunkRequest ?? null;
2241
+ this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
2242
+ this.persistBlobChunk = options.persistBlobChunk ?? null;
2153
2243
  this.onClose = options.onClose ?? null;
2154
2244
  this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
2155
2245
  this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
@@ -2255,7 +2345,32 @@ var ClientSession = class {
2255
2345
  case "yjs-doc-update":
2256
2346
  this.handleYjsDocUpdate(message);
2257
2347
  break;
2348
+ case "blob-chunk-request":
2349
+ this.onBlobChunkRequest?.(this.sessionId, message);
2350
+ break;
2351
+ case "blob-chunk-response":
2352
+ this.onBlobChunkResponse?.(this.sessionId, message);
2353
+ break;
2354
+ case "blob-chunk-push":
2355
+ await this.handleBlobChunkPush(message);
2356
+ break;
2357
+ }
2358
+ }
2359
+ /**
2360
+ * Persist a client-uploaded blob chunk (or manifest) centrally. The bytes are
2361
+ * verified to hash to the declared hash before storing, so a corrupt or
2362
+ * mislabeled upload is rejected rather than served later as trusted content.
2363
+ */
2364
+ async handleBlobChunkPush(message) {
2365
+ if (!this.persistBlobChunk) {
2366
+ return;
2367
+ }
2368
+ const bytes = decodeBlobChunkBytes(message.bytes);
2369
+ const actual = await hashBlob(bytes);
2370
+ if (actual !== message.hash) {
2371
+ return;
2258
2372
  }
2373
+ await this.persistBlobChunk(message.hash, bytes);
2259
2374
  }
2260
2375
  handleMessageFailure(error) {
2261
2376
  const reason = error instanceof Error ? error.message : "Message handling failed";
@@ -2290,6 +2405,7 @@ var ClientSession = class {
2290
2405
  this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
2291
2406
  }
2292
2407
  }
2408
+ warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
2293
2409
  if (msg.syncQueries && msg.syncQueries.length > 0) {
2294
2410
  this.syncQuerySubsets = dedupeQuerySubsets(msg.syncQueries);
2295
2411
  } else {
@@ -2310,7 +2426,8 @@ var ClientSession = class {
2310
2426
  accepted: false,
2311
2427
  rejectReason: `${SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`,
2312
2428
  supportedSchemaMin: min,
2313
- supportedSchemaMax: max
2429
+ supportedSchemaMax: max,
2430
+ serverTime: Date.now()
2314
2431
  };
2315
2432
  this.sendToClient(response2);
2316
2433
  this.close("schema version mismatch");
@@ -2324,6 +2441,10 @@ var ClientSession = class {
2324
2441
  schemaVersion: this.schemaVersion,
2325
2442
  accepted: true,
2326
2443
  selectedWireFormat,
2444
+ serverTime: Date.now(),
2445
+ // Advertise central blob storage so the client uploads the bytes behind
2446
+ // its blob fields, keeping them available after the author goes offline.
2447
+ ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
2327
2448
  // Confirm the accepted scope so the client knows what data will be synced.
2328
2449
  // This may differ from what the client requested if auth scopes are narrower.
2329
2450
  ...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
@@ -2511,7 +2632,7 @@ function selectWireFormat(supportedWireFormats) {
2511
2632
  }
2512
2633
 
2513
2634
  // src/server/kora-sync-server.ts
2514
- import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv76 } from "@korajs/core";
2635
+ import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv76, isBlobRef } from "@korajs/core";
2515
2636
  import { SimpleEventEmitter } from "@korajs/core/internal";
2516
2637
  import { JsonMessageSerializer as JsonMessageSerializer2 } from "@korajs/sync";
2517
2638
 
@@ -2728,6 +2849,115 @@ var ServerMetricsCollector = class {
2728
2849
  }
2729
2850
  };
2730
2851
 
2852
+ // src/richtext/blob-chunk-relay.ts
2853
+ import {
2854
+ encodeBlobChunkBytes
2855
+ } from "@korajs/sync";
2856
+ var BlobChunkRelay = class {
2857
+ clients = /* @__PURE__ */ new Map();
2858
+ /** requestId -> the session that originated the request (to route the answer back). */
2859
+ pending = /* @__PURE__ */ new Map();
2860
+ resolveBlobChunk;
2861
+ constructor(resolveBlobChunk) {
2862
+ this.resolveBlobChunk = resolveBlobChunk ?? null;
2863
+ }
2864
+ addClient(sessionId, transport) {
2865
+ this.clients.set(sessionId, { sessionId, transport });
2866
+ }
2867
+ removeClient(sessionId) {
2868
+ this.clients.delete(sessionId);
2869
+ for (const [requestId, originSessionId] of this.pending) {
2870
+ if (originSessionId === sessionId) {
2871
+ this.pending.delete(requestId);
2872
+ }
2873
+ }
2874
+ }
2875
+ getClientCount() {
2876
+ return this.clients.size;
2877
+ }
2878
+ getPendingCount() {
2879
+ return this.pending.size;
2880
+ }
2881
+ clear() {
2882
+ this.clients.clear();
2883
+ this.pending.clear();
2884
+ }
2885
+ /**
2886
+ * Handle an inbound chunk request from a session. Tries the server's own
2887
+ * store first (if configured), otherwise forwards the request to peer
2888
+ * sessions and remembers who to route the answer back to.
2889
+ */
2890
+ handleRequest(sourceSessionId, message) {
2891
+ if (!this.clients.has(sourceSessionId)) {
2892
+ return;
2893
+ }
2894
+ if (this.resolveBlobChunk) {
2895
+ void this.resolveBlobChunk(message.hash).then(
2896
+ (bytes) => {
2897
+ if (bytes !== null) {
2898
+ this.sendResponseTo(sourceSessionId, message.requestId, encodeBlobChunkBytes(bytes));
2899
+ return;
2900
+ }
2901
+ this.forwardRequestToPeers(sourceSessionId, message);
2902
+ },
2903
+ () => {
2904
+ this.forwardRequestToPeers(sourceSessionId, message);
2905
+ }
2906
+ );
2907
+ return;
2908
+ }
2909
+ this.forwardRequestToPeers(sourceSessionId, message);
2910
+ }
2911
+ /**
2912
+ * Handle an inbound chunk response from a peer. Routes it back to the session
2913
+ * that originally requested it. A "not held" answer (bytes === null) is
2914
+ * ignored so a peer without the chunk does not preempt one that has it; the
2915
+ * requester's own per-request timeout bounds the wait.
2916
+ */
2917
+ handleResponse(sourceSessionId, message) {
2918
+ if (!this.clients.has(sourceSessionId)) {
2919
+ return;
2920
+ }
2921
+ if (message.bytes === null) {
2922
+ return;
2923
+ }
2924
+ const originSessionId = this.pending.get(message.requestId);
2925
+ if (!originSessionId) {
2926
+ return;
2927
+ }
2928
+ this.pending.delete(message.requestId);
2929
+ this.sendResponseTo(originSessionId, message.requestId, message.bytes);
2930
+ }
2931
+ forwardRequestToPeers(sourceSessionId, message) {
2932
+ this.pending.set(message.requestId, sourceSessionId);
2933
+ for (const [, client] of this.clients) {
2934
+ if (client.sessionId === sourceSessionId) {
2935
+ continue;
2936
+ }
2937
+ if (!client.transport.isConnected()) {
2938
+ continue;
2939
+ }
2940
+ client.transport.send(message);
2941
+ }
2942
+ }
2943
+ sendResponseTo(sessionId, requestId, bytes) {
2944
+ const client = this.clients.get(sessionId);
2945
+ if (!client || !client.transport.isConnected()) {
2946
+ return;
2947
+ }
2948
+ const response = {
2949
+ type: "blob-chunk-response",
2950
+ messageId: generateResponseId(requestId),
2951
+ requestId,
2952
+ bytes
2953
+ };
2954
+ client.transport.send(response);
2955
+ }
2956
+ };
2957
+ function generateResponseId(requestId) {
2958
+ return `blob-resp-${requestId}`;
2959
+ }
2960
+
2731
2961
  // src/richtext/yjs-doc-relay.ts
2732
2962
  var YjsDocRelay = class {
2733
2963
  clients = /* @__PURE__ */ new Map();
@@ -2784,6 +3014,8 @@ var KoraSyncServer = class {
2784
3014
  metrics;
2785
3015
  awarenessRelay = new AwarenessRelay();
2786
3016
  yjsDocRelay = new YjsDocRelay();
3017
+ blobChunkRelay;
3018
+ persistBlobChunk;
2787
3019
  sessions = /* @__PURE__ */ new Map();
2788
3020
  httpClients = /* @__PURE__ */ new Map();
2789
3021
  httpSessionToClient = /* @__PURE__ */ new Map();
@@ -2808,6 +3040,8 @@ var KoraSyncServer = class {
2808
3040
  this.logger = config.logger ?? createDefaultLogger();
2809
3041
  this.metrics = config.metricsCollector ?? new ServerMetricsCollector();
2810
3042
  this.metrics.setSchemaVersion(this.schemaVersion);
3043
+ this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
3044
+ this.persistBlobChunk = config.persistBlobChunk ?? null;
2811
3045
  if (!this.emitter) {
2812
3046
  this.emitter = new SimpleEventEmitter();
2813
3047
  }
@@ -2931,6 +3165,7 @@ var KoraSyncServer = class {
2931
3165
  });
2932
3166
  this.awarenessRelay.clear();
2933
3167
  this.yjsDocRelay.clear();
3168
+ this.blobChunkRelay.clear();
2934
3169
  for (const session of this.sessions.values()) {
2935
3170
  session.close("server shutting down");
2936
3171
  }
@@ -3075,12 +3310,20 @@ var KoraSyncServer = class {
3075
3310
  onYjsDocUpdate: (sourceSessionId, message) => {
3076
3311
  this.handleYjsDocRelay(sourceSessionId, message);
3077
3312
  },
3313
+ onBlobChunkRequest: (sourceSessionId, message) => {
3314
+ this.blobChunkRelay.handleRequest(sourceSessionId, message);
3315
+ },
3316
+ onBlobChunkResponse: (sourceSessionId, message) => {
3317
+ this.blobChunkRelay.handleResponse(sourceSessionId, message);
3318
+ },
3319
+ ...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
3078
3320
  onClose: (sid) => {
3079
3321
  this.handleSessionClose(sid);
3080
3322
  }
3081
3323
  });
3082
3324
  this.sessions.set(sessionId, session);
3083
3325
  this.yjsDocRelay.addClient(sessionId, transport);
3326
+ this.blobChunkRelay.addClient(sessionId, transport);
3084
3327
  session.start();
3085
3328
  this.logger.log({
3086
3329
  timestamp: Date.now(),
@@ -3113,6 +3356,61 @@ var KoraSyncServer = class {
3113
3356
  errorCount: snapshot.errorCount
3114
3357
  };
3115
3358
  }
3359
+ /**
3360
+ * Apply a server-originated operation (for example one created by a custom
3361
+ * HTTP route) through the same validated pipeline that incoming client
3362
+ * operations use — Tier 2 constraints, referential integrity, and cascade
3363
+ * side effects — then relay every applied operation to connected clients.
3364
+ *
3365
+ * Because the operation did not come from a client session, it is relayed to
3366
+ * ALL sessions (there is no source session to exclude). Each session still
3367
+ * applies its own per-scope visibility filter in `relayOperations`, so a
3368
+ * client only receives the operation if it falls within that client's scope.
3369
+ *
3370
+ * @param op - A fully-formed, server-originated operation to apply
3371
+ * @returns The apply result, including any server-generated side-effect ops
3372
+ */
3373
+ async applyLocalOperation(op) {
3374
+ const result = await applyServerOperation(this.store, op);
3375
+ if (result.result === "applied" && result.appliedOperations.length > 0) {
3376
+ for (const session of this.sessions.values()) {
3377
+ session.relayOperations(result.appliedOperations);
3378
+ }
3379
+ }
3380
+ return result;
3381
+ }
3382
+ /**
3383
+ * Collect every blob reference still reachable from live records on the server.
3384
+ *
3385
+ * This is the live set for garbage-collecting the server's central blob store:
3386
+ * pass the result to `collectBlobGarbage(blobStore, liveRefs)` from
3387
+ * `@korajs/store` to reclaim bytes no record references any more. Only
3388
+ * collections that declare a `blob` field are scanned.
3389
+ *
3390
+ * @returns Every live blob reference across all collections
3391
+ */
3392
+ async getLiveBlobRefs() {
3393
+ const schema = this.store.getSchema();
3394
+ if (!schema) {
3395
+ return [];
3396
+ }
3397
+ const refs = [];
3398
+ for (const [name, collection] of Object.entries(schema.collections)) {
3399
+ const hasBlobField = Object.values(collection.fields).some((field) => field.kind === "blob");
3400
+ if (!hasBlobField) {
3401
+ continue;
3402
+ }
3403
+ const records = await this.store.materializeCollection(name);
3404
+ for (const record of records) {
3405
+ for (const value of Object.values(record)) {
3406
+ if (isBlobRef(value)) {
3407
+ refs.push(value);
3408
+ }
3409
+ }
3410
+ }
3411
+ }
3412
+ return refs;
3413
+ }
3116
3414
  /**
3117
3415
  * Get the number of currently connected clients.
3118
3416
  */
@@ -3146,6 +3444,7 @@ var KoraSyncServer = class {
3146
3444
  this.metrics.recordDisconnection(sessionId);
3147
3445
  this.awarenessRelay.removeClient(sessionId);
3148
3446
  this.yjsDocRelay.removeClient(sessionId);
3447
+ this.blobChunkRelay.removeClient(sessionId);
3149
3448
  this.sessions.delete(sessionId);
3150
3449
  const clientId = this.httpSessionToClient.get(sessionId);
3151
3450
  if (clientId) {
@@ -3198,13 +3497,6 @@ function normalizeHttpBody(body, contentType) {
3198
3497
  return body;
3199
3498
  }
3200
3499
 
3201
- // src/auth/no-auth.ts
3202
- var NoAuthProvider = class {
3203
- async authenticate(_token) {
3204
- return { userId: "anonymous" };
3205
- }
3206
- };
3207
-
3208
3500
  // src/auth/token-auth.ts
3209
3501
  var TokenAuthProvider = class {
3210
3502
  validate;
@@ -3285,6 +3577,159 @@ function createKoraServer(config) {
3285
3577
  return new KoraSyncServer(config);
3286
3578
  }
3287
3579
 
3580
+ // src/server/route-context.ts
3581
+ import { HybridLogicalClock as HybridLogicalClock2, createOperation as createOperation2, generateUUIDv7 as generateUUIDv77 } from "@korajs/core";
3582
+ function recordMatchesScope(collection, record, scope) {
3583
+ const collectionScope = scope[collection];
3584
+ if (!collectionScope) {
3585
+ return false;
3586
+ }
3587
+ for (const [field, expected] of Object.entries(collectionScope)) {
3588
+ if (record[field] !== expected) {
3589
+ return false;
3590
+ }
3591
+ }
3592
+ return true;
3593
+ }
3594
+ function pickFields(record, keys) {
3595
+ const picked = {};
3596
+ for (const key of keys) {
3597
+ if (key in record) {
3598
+ picked[key] = record[key];
3599
+ }
3600
+ }
3601
+ return picked;
3602
+ }
3603
+ async function buildRouteOperation(store, mutation) {
3604
+ const nodeId = store.getNodeId();
3605
+ const clock = new HybridLogicalClock2(nodeId);
3606
+ const schemaVersion = store.getSchema()?.version ?? 1;
3607
+ const sequenceNumber = nextServerSequenceNumber(store);
3608
+ if (mutation.type === "insert") {
3609
+ return createOperation2(
3610
+ {
3611
+ nodeId,
3612
+ type: "insert",
3613
+ collection: mutation.collection,
3614
+ recordId: mutation.recordId ?? generateUUIDv77(),
3615
+ data: mutation.data ?? {},
3616
+ previousData: null,
3617
+ sequenceNumber,
3618
+ causalDeps: [],
3619
+ schemaVersion
3620
+ },
3621
+ clock
3622
+ );
3623
+ }
3624
+ if (!mutation.recordId) {
3625
+ throw new RouteMutationError(
3626
+ "MISSING_RECORD_ID",
3627
+ `A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
3628
+ );
3629
+ }
3630
+ const current = await store.findRecord(mutation.collection, mutation.recordId);
3631
+ if (mutation.type === "update") {
3632
+ const data = mutation.data ?? {};
3633
+ return createOperation2(
3634
+ {
3635
+ nodeId,
3636
+ type: "update",
3637
+ collection: mutation.collection,
3638
+ recordId: mutation.recordId,
3639
+ data,
3640
+ previousData: current ? pickFields(current, Object.keys(data)) : {},
3641
+ sequenceNumber,
3642
+ causalDeps: [],
3643
+ schemaVersion
3644
+ },
3645
+ clock
3646
+ );
3647
+ }
3648
+ return createOperation2(
3649
+ {
3650
+ nodeId,
3651
+ type: "delete",
3652
+ collection: mutation.collection,
3653
+ recordId: mutation.recordId,
3654
+ data: null,
3655
+ previousData: current ? { ...current } : null,
3656
+ sequenceNumber,
3657
+ causalDeps: [],
3658
+ schemaVersion
3659
+ },
3660
+ clock
3661
+ );
3662
+ }
3663
+ var RouteMutationError = class extends Error {
3664
+ constructor(code, message) {
3665
+ super(message);
3666
+ this.code = code;
3667
+ this.name = "RouteMutationError";
3668
+ }
3669
+ code;
3670
+ };
3671
+ function createRouteContext(server, store) {
3672
+ let mutationTail = Promise.resolve();
3673
+ function serialize(work) {
3674
+ const run = mutationTail.then(work, work);
3675
+ mutationTail = run.then(
3676
+ () => void 0,
3677
+ () => void 0
3678
+ );
3679
+ return run;
3680
+ }
3681
+ return {
3682
+ apply(mutation, options) {
3683
+ return serialize(async () => {
3684
+ let op;
3685
+ try {
3686
+ op = await buildRouteOperation(store, mutation);
3687
+ } catch (error) {
3688
+ if (error instanceof RouteMutationError) {
3689
+ return { ok: false, code: error.code, message: error.message };
3690
+ }
3691
+ throw error;
3692
+ }
3693
+ if (options?.scope && !operationMatchesScopes(op, options.scope)) {
3694
+ return {
3695
+ ok: false,
3696
+ code: "SCOPE_VIOLATION",
3697
+ message: `Mutation on "${mutation.collection}" is outside the provided scope.`
3698
+ };
3699
+ }
3700
+ const result = await server.applyLocalOperation(op);
3701
+ if (result.result !== "applied") {
3702
+ return {
3703
+ ok: false,
3704
+ code: result.rejection?.code ?? "NOT_APPLIED",
3705
+ message: result.rejection?.message ?? `Operation on "${mutation.collection}" was not applied (${result.result}).`
3706
+ };
3707
+ }
3708
+ const record = await store.findRecord(mutation.collection, op.recordId);
3709
+ return { ok: true, operation: op, record };
3710
+ });
3711
+ },
3712
+ async query(collection, options) {
3713
+ const { scope, ...queryOptions } = options ?? {};
3714
+ const records = await store.queryCollection(collection, queryOptions);
3715
+ if (!scope) {
3716
+ return records;
3717
+ }
3718
+ return records.filter((record) => recordMatchesScope(collection, record, scope));
3719
+ },
3720
+ async findById(collection, id, options) {
3721
+ const record = await store.findRecord(collection, id);
3722
+ if (!record) {
3723
+ return null;
3724
+ }
3725
+ if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
3726
+ return null;
3727
+ }
3728
+ return record;
3729
+ }
3730
+ };
3731
+ }
3732
+
3288
3733
  // src/server/production-server.ts
3289
3734
  var MIME_TYPES = {
3290
3735
  ".html": "text/html",
@@ -3309,6 +3754,7 @@ function createProductionServer(config) {
3309
3754
  enableDashboard: true,
3310
3755
  ...config.syncOptions
3311
3756
  });
3757
+ const routeContext = createRouteContext(syncServer, config.store);
3312
3758
  let httpServer = null;
3313
3759
  function getOperationalToken(kind) {
3314
3760
  const auth = config.operationalAuth;
@@ -3395,6 +3841,10 @@ function createProductionServer(config) {
3395
3841
  const chunks = [];
3396
3842
  req.on("data", (chunk) => chunks.push(chunk));
3397
3843
  req.on("end", () => resolve(Buffer.concat(chunks)));
3844
+ if (!req.readableFlowing) {
3845
+ req.resume();
3846
+ }
3847
+ req.on("error", () => resolve(Buffer.concat(chunks)));
3398
3848
  });
3399
3849
  }
3400
3850
  async function readJsonBody(req) {
@@ -3451,6 +3901,19 @@ function createProductionServer(config) {
3451
3901
  const { WebSocketServer } = await import("ws");
3452
3902
  const distDir = resolve(staticDir);
3453
3903
  httpServer = createServer(async (req, res) => {
3904
+ try {
3905
+ await handleRequest(req, res);
3906
+ } catch (error) {
3907
+ console.error("[kora] Unhandled error in HTTP request handler:", error);
3908
+ if (!res.headersSent) {
3909
+ res.writeHead(500, { "Content-Type": "application/json" });
3910
+ res.end(JSON.stringify({ error: "Internal server error" }));
3911
+ } else if (!res.writableEnded) {
3912
+ res.end();
3913
+ }
3914
+ }
3915
+ });
3916
+ async function handleRequest(req, res) {
3454
3917
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
3455
3918
  res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
3456
3919
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
@@ -3578,7 +4041,8 @@ data: ${JSON.stringify(s)}
3578
4041
  body: await readJsonBody(req),
3579
4042
  headers: req.headers,
3580
4043
  query: getQuery(url),
3581
- ip: getClientIp(req)
4044
+ ip: getClientIp(req),
4045
+ kora: routeContext
3582
4046
  });
3583
4047
  writeJsonResponse(res, result);
3584
4048
  return;
@@ -3612,7 +4076,7 @@ data: ${JSON.stringify(s)}
3612
4076
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
3613
4077
  res.writeHead(200, { "Content-Type": contentType });
3614
4078
  createReadStream(filePath).pipe(res);
3615
- });
4079
+ }
3616
4080
  const wss = new WebSocketServer({ noServer: true });
3617
4081
  httpServer.on("upgrade", (req, socket, head) => {
3618
4082
  const url = new URL(req.url || "/", `http://${req.headers.host}`);