@korajs/server 1.0.0-beta.1 → 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");
@@ -1993,12 +2013,14 @@ async function applyServerOperation(store, op, relationLookup) {
1993
2013
  const lookup = relationLookup ?? (schema ? buildMergeRelationLookup(schema) : /* @__PURE__ */ new Map());
1994
2014
  const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
1995
2015
  if (!constraintCheck.valid) {
2016
+ const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
1996
2017
  return {
1997
2018
  result: "skipped",
1998
2019
  appliedOperations: [],
1999
2020
  rejection: {
2000
- code: constraintCheck.code ?? "CONSTRAINT_VIOLATION",
2001
- 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)
2002
2024
  }
2003
2025
  };
2004
2026
  }
@@ -2011,7 +2033,8 @@ async function applyServerOperation(store, op, relationLookup) {
2011
2033
  appliedOperations: [],
2012
2034
  rejection: {
2013
2035
  code: "REFERENTIAL_INTEGRITY",
2014
- 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")
2015
2038
  }
2016
2039
  };
2017
2040
  }
@@ -2220,6 +2243,8 @@ var ClientSession = class {
2220
2243
  maxOperationBytes;
2221
2244
  maxOpsPerMinute;
2222
2245
  rateLimiter;
2246
+ validateOperation;
2247
+ koraContext;
2223
2248
  constructor(options) {
2224
2249
  this.sessionId = options.sessionId;
2225
2250
  this.transport = options.transport;
@@ -2244,6 +2269,8 @@ var ClientSession = class {
2244
2269
  this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
2245
2270
  this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
2246
2271
  this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
2272
+ this.validateOperation = options.validateOperation ?? null;
2273
+ this.koraContext = options.koraContext ?? null;
2247
2274
  }
2248
2275
  /**
2249
2276
  * Start handling messages from the client transport.
@@ -2490,10 +2517,39 @@ var ClientSession = class {
2490
2517
  );
2491
2518
  continue;
2492
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
+ }
2493
2545
  try {
2494
2546
  const applyResult = await applyServerOperation(this.store, op);
2495
2547
  if (applyResult.rejection) {
2496
- 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
+ );
2497
2553
  continue;
2498
2554
  }
2499
2555
  if (applyResult.result === "applied") {
@@ -2612,6 +2668,25 @@ var ClientSession = class {
2612
2668
  };
2613
2669
  this.sendToClient(errorMsg);
2614
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
+ }
2615
2690
  setSerializerWireFormat(format) {
2616
2691
  if (typeof this.serializer.setWireFormat === "function") {
2617
2692
  this.serializer.setWireFormat(format);
@@ -2632,7 +2707,7 @@ function selectWireFormat(supportedWireFormats) {
2632
2707
  }
2633
2708
 
2634
2709
  // src/server/kora-sync-server.ts
2635
- import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv76, isBlobRef } from "@korajs/core";
2710
+ import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv77, isBlobRef } from "@korajs/core";
2636
2711
  import { SimpleEventEmitter } from "@korajs/core/internal";
2637
2712
  import { JsonMessageSerializer as JsonMessageSerializer2 } from "@korajs/sync";
2638
2713
 
@@ -2992,6 +3067,169 @@ var YjsDocRelay = class {
2992
3067
  }
2993
3068
  };
2994
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
+
2995
3233
  // src/server/kora-sync-server.ts
2996
3234
  var DEFAULT_MAX_CONNECTIONS = 0;
2997
3235
  var DEFAULT_BATCH_SIZE2 = 100;
@@ -3016,10 +3254,16 @@ var KoraSyncServer = class {
3016
3254
  yjsDocRelay = new YjsDocRelay();
3017
3255
  blobChunkRelay;
3018
3256
  persistBlobChunk;
3257
+ maxOperationBytes;
3258
+ maxOpsPerMinute;
3259
+ validateOperation;
3260
+ koraContext;
3019
3261
  sessions = /* @__PURE__ */ new Map();
3020
3262
  httpClients = /* @__PURE__ */ new Map();
3021
3263
  httpSessionToClient = /* @__PURE__ */ new Map();
3022
- 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";
3023
3267
  wsServer = null;
3024
3268
  running = false;
3025
3269
  constructor(config) {
@@ -3042,10 +3286,22 @@ var KoraSyncServer = class {
3042
3286
  this.metrics.setSchemaVersion(this.schemaVersion);
3043
3287
  this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
3044
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);
3045
3293
  if (!this.emitter) {
3046
3294
  this.emitter = new SimpleEventEmitter();
3047
3295
  }
3048
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
+ }
3049
3305
  /**
3050
3306
  * Subscribe to session-level events for metrics collection and logging.
3051
3307
  * Called when a new session is created.
@@ -3227,7 +3483,7 @@ var KoraSyncServer = class {
3227
3483
  if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
3228
3484
  transport.send({
3229
3485
  type: "error",
3230
- messageId: generateUUIDv76(),
3486
+ messageId: generateUUIDv77(),
3231
3487
  code: "MAX_CONNECTIONS",
3232
3488
  message: `Server has reached maximum connections (${this.maxConnections})`,
3233
3489
  retriable: true
@@ -3245,7 +3501,7 @@ var KoraSyncServer = class {
3245
3501
  max: this.maxConnections
3246
3502
  });
3247
3503
  }
3248
- const sessionId = generateUUIDv76();
3504
+ const sessionId = generateUUIDv77();
3249
3505
  this.metrics.recordConnection(sessionId);
3250
3506
  const sessionEmitter = new SimpleEventEmitter();
3251
3507
  sessionEmitter.on("sync:connected", (event) => {
@@ -3317,6 +3573,11 @@ var KoraSyncServer = class {
3317
3573
  this.blobChunkRelay.handleResponse(sourceSessionId, message);
3318
3574
  },
3319
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 } : {},
3320
3581
  onClose: (sid) => {
3321
3582
  this.handleSessionClose(sid);
3322
3583
  }
@@ -3577,159 +3838,6 @@ function createKoraServer(config) {
3577
3838
  return new KoraSyncServer(config);
3578
3839
  }
3579
3840
 
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
-
3733
3841
  // src/server/production-server.ts
3734
3842
  var MIME_TYPES = {
3735
3843
  ".html": "text/html",
@@ -3754,7 +3862,7 @@ function createProductionServer(config) {
3754
3862
  enableDashboard: true,
3755
3863
  ...config.syncOptions
3756
3864
  });
3757
- const routeContext = createRouteContext(syncServer, config.store);
3865
+ const routeContext = syncServer.getKoraContext();
3758
3866
  let httpServer = null;
3759
3867
  function getOperationalToken(kind) {
3760
3868
  const auth = config.operationalAuth;
@@ -3894,6 +4002,10 @@ function createProductionServer(config) {
3894
4002
  res.end(JSON.stringify(result.body ?? null));
3895
4003
  }
3896
4004
  return {
4005
+ kora: routeContext,
4006
+ getLiveBlobRefs() {
4007
+ return syncServer.getLiveBlobRefs();
4008
+ },
3897
4009
  async start() {
3898
4010
  const { createServer } = await import("http");
3899
4011
  const { createReadStream, existsSync, statSync } = await import("fs");
@@ -4192,6 +4304,7 @@ export {
4192
4304
  MixedAuthProvider,
4193
4305
  NoAuthProvider,
4194
4306
  PostgresServerStore,
4307
+ RETRIABLE_REJECTION_CODES,
4195
4308
  SqliteServerStore,
4196
4309
  TokenAuthProvider,
4197
4310
  WsServerTransport,
@@ -4202,6 +4315,7 @@ export {
4202
4315
  createPrettyLogger,
4203
4316
  createProductionServer,
4204
4317
  createSilentLogger,
4205
- createSqliteServerStore
4318
+ createSqliteServerStore,
4319
+ isRetriableRejection
4206
4320
  };
4207
4321
  //# sourceMappingURL=index.js.map