@korajs/server 1.0.0-beta.1 → 1.0.0-beta.4

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,316 @@ var YjsDocRelay = class {
2992
3067
  }
2993
3068
  };
2994
3069
 
3070
+ // src/server/route-context.ts
3071
+ import {
3072
+ HybridLogicalClock as HybridLogicalClock2,
3073
+ createOperation as createOperation2,
3074
+ generateUUIDv7 as generateUUIDv76,
3075
+ isAtomicOp,
3076
+ resolveAtomicOp,
3077
+ toAtomicOp
3078
+ } from "@korajs/core";
3079
+
3080
+ // src/apply/route-predicate.ts
3081
+ function evaluateField(actual, operators) {
3082
+ if ("$eq" in operators && !Object.is(actual, operators.$eq)) {
3083
+ return false;
3084
+ }
3085
+ if ("$ne" in operators && Object.is(actual, operators.$ne)) {
3086
+ return false;
3087
+ }
3088
+ if (operators.$in !== void 0 && !operators.$in.some((candidate) => Object.is(actual, candidate))) {
3089
+ return false;
3090
+ }
3091
+ const numericChecks = [
3092
+ ["$lt", (a, b) => a < b],
3093
+ ["$lte", (a, b) => a <= b],
3094
+ ["$gt", (a, b) => a > b],
3095
+ ["$gte", (a, b) => a >= b]
3096
+ ];
3097
+ for (const [operator, compare] of numericChecks) {
3098
+ const bound = operators[operator];
3099
+ if (bound === void 0) {
3100
+ continue;
3101
+ }
3102
+ if (typeof actual !== "number" || typeof bound !== "number") {
3103
+ return false;
3104
+ }
3105
+ if (!compare(actual, bound)) {
3106
+ return false;
3107
+ }
3108
+ }
3109
+ return true;
3110
+ }
3111
+ function evaluateRoutePredicate(record, predicate) {
3112
+ const fields = Object.keys(predicate);
3113
+ if (fields.length === 0) {
3114
+ return true;
3115
+ }
3116
+ if (record === null) {
3117
+ return false;
3118
+ }
3119
+ for (const field of fields) {
3120
+ const operators = predicate[field];
3121
+ if (operators && !evaluateField(record[field], operators)) {
3122
+ return false;
3123
+ }
3124
+ }
3125
+ return true;
3126
+ }
3127
+
3128
+ // src/server/route-context.ts
3129
+ function recordMatchesScope(collection, record, scope) {
3130
+ const collectionScope = scope[collection];
3131
+ if (!collectionScope) {
3132
+ return false;
3133
+ }
3134
+ for (const [field, expected] of Object.entries(collectionScope)) {
3135
+ if (record[field] !== expected) {
3136
+ return false;
3137
+ }
3138
+ }
3139
+ return true;
3140
+ }
3141
+ function pickFields(record, keys) {
3142
+ const picked = {};
3143
+ for (const key of keys) {
3144
+ if (key in record) {
3145
+ picked[key] = record[key];
3146
+ }
3147
+ }
3148
+ return picked;
3149
+ }
3150
+ async function buildRouteOperation(store, mutation) {
3151
+ const nodeId = store.getNodeId();
3152
+ const clock = new HybridLogicalClock2(nodeId);
3153
+ const schemaVersion = store.getSchema()?.version ?? 1;
3154
+ const sequenceNumber = nextServerSequenceNumber(store);
3155
+ if (mutation.type === "insert") {
3156
+ return createOperation2(
3157
+ {
3158
+ nodeId,
3159
+ type: "insert",
3160
+ collection: mutation.collection,
3161
+ recordId: mutation.recordId ?? generateUUIDv76(),
3162
+ data: mutation.data ?? {},
3163
+ previousData: null,
3164
+ sequenceNumber,
3165
+ causalDeps: [],
3166
+ schemaVersion
3167
+ },
3168
+ clock
3169
+ );
3170
+ }
3171
+ if (!mutation.recordId) {
3172
+ throw new RouteMutationError(
3173
+ "MISSING_RECORD_ID",
3174
+ `A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
3175
+ );
3176
+ }
3177
+ const current = await store.findRecord(mutation.collection, mutation.recordId);
3178
+ if (mutation.type === "update") {
3179
+ const rawData = mutation.data ?? {};
3180
+ const data = {};
3181
+ const atomicOps = {};
3182
+ for (const key of Object.keys(rawData)) {
3183
+ const value = rawData[key];
3184
+ if (isAtomicOp(value)) {
3185
+ data[key] = resolveAtomicOp(current ? current[key] : void 0, value);
3186
+ atomicOps[key] = toAtomicOp(value);
3187
+ } else {
3188
+ data[key] = value;
3189
+ }
3190
+ }
3191
+ return createOperation2(
3192
+ {
3193
+ nodeId,
3194
+ type: "update",
3195
+ collection: mutation.collection,
3196
+ recordId: mutation.recordId,
3197
+ data,
3198
+ previousData: current ? pickFields(current, Object.keys(rawData)) : {},
3199
+ sequenceNumber,
3200
+ causalDeps: [],
3201
+ schemaVersion,
3202
+ ...Object.keys(atomicOps).length > 0 ? { atomicOps } : {}
3203
+ },
3204
+ clock
3205
+ );
3206
+ }
3207
+ return createOperation2(
3208
+ {
3209
+ nodeId,
3210
+ type: "delete",
3211
+ collection: mutation.collection,
3212
+ recordId: mutation.recordId,
3213
+ data: null,
3214
+ previousData: current ? { ...current } : null,
3215
+ sequenceNumber,
3216
+ causalDeps: [],
3217
+ schemaVersion
3218
+ },
3219
+ clock
3220
+ );
3221
+ }
3222
+ var RouteMutationError = class extends Error {
3223
+ constructor(code, message) {
3224
+ super(message);
3225
+ this.code = code;
3226
+ this.name = "RouteMutationError";
3227
+ }
3228
+ code;
3229
+ };
3230
+ function createRouteContext(server, store) {
3231
+ let mutationTail = Promise.resolve();
3232
+ function serialize(work) {
3233
+ const run = mutationTail.then(work, work);
3234
+ mutationTail = run.then(
3235
+ () => void 0,
3236
+ () => void 0
3237
+ );
3238
+ return run;
3239
+ }
3240
+ async function prepareMutation(mutation, scope) {
3241
+ let op;
3242
+ try {
3243
+ op = await buildRouteOperation(store, mutation);
3244
+ } catch (error) {
3245
+ if (error instanceof RouteMutationError) {
3246
+ return {
3247
+ ok: false,
3248
+ rejection: {
3249
+ ok: false,
3250
+ code: error.code,
3251
+ message: error.message,
3252
+ retriable: isRetriableRejection(error.code)
3253
+ }
3254
+ };
3255
+ }
3256
+ throw error;
3257
+ }
3258
+ if (scope && !operationMatchesScopes(op, scope)) {
3259
+ return {
3260
+ ok: false,
3261
+ rejection: {
3262
+ ok: false,
3263
+ code: "SCOPE_VIOLATION",
3264
+ message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
3265
+ retriable: isRetriableRejection("SCOPE_VIOLATION")
3266
+ }
3267
+ };
3268
+ }
3269
+ return { ok: true, op };
3270
+ }
3271
+ async function commitOperation(op, collection) {
3272
+ const result = await server.applyLocalOperation(op);
3273
+ if (result.result !== "applied") {
3274
+ const code = result.rejection?.code ?? "NOT_APPLIED";
3275
+ return {
3276
+ ok: false,
3277
+ code,
3278
+ message: result.rejection?.message ?? `Operation on "${collection}" was not applied (${result.result}).`,
3279
+ // Prefer the pipeline's own classification when present; fall back
3280
+ // to classifying the code so this stays correct if new codes appear.
3281
+ retriable: result.rejection?.retriable ?? isRetriableRejection(code)
3282
+ };
3283
+ }
3284
+ const record = await store.findRecord(collection, op.recordId);
3285
+ return { ok: true, operation: op, record };
3286
+ }
3287
+ async function rawApply(mutation, scope) {
3288
+ const prepared = await prepareMutation(mutation, scope);
3289
+ if (!prepared.ok) {
3290
+ return prepared.rejection;
3291
+ }
3292
+ return commitOperation(prepared.op, mutation.collection);
3293
+ }
3294
+ return {
3295
+ apply(mutation, options) {
3296
+ return serialize(() => rawApply(mutation, options?.scope));
3297
+ },
3298
+ applyConditional(spec, options) {
3299
+ return serialize(async () => {
3300
+ if (spec.idempotencyKey) {
3301
+ const existing = await store.findRecord(
3302
+ spec.idempotencyKey.collection,
3303
+ spec.idempotencyKey.id
3304
+ );
3305
+ if (existing) {
3306
+ return { ok: true, idempotent: true, operations: [], records: [existing] };
3307
+ }
3308
+ }
3309
+ if (spec.if && Object.keys(spec.if).length > 0) {
3310
+ const target = await store.findRecord(spec.collection, spec.id);
3311
+ if (!evaluateRoutePredicate(target, spec.if)) {
3312
+ const code = spec.reject?.code ?? "CONDITION_NOT_MET";
3313
+ return {
3314
+ ok: false,
3315
+ code,
3316
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
3317
+ retriable: false
3318
+ };
3319
+ }
3320
+ }
3321
+ const mutations = [];
3322
+ if (spec.update !== void 0 && spec.update !== null) {
3323
+ mutations.push({
3324
+ collection: spec.collection,
3325
+ type: "update",
3326
+ recordId: spec.id,
3327
+ data: spec.update
3328
+ });
3329
+ }
3330
+ if (spec.also) {
3331
+ mutations.push(...spec.also);
3332
+ }
3333
+ const prepared = [];
3334
+ for (const mutation of mutations) {
3335
+ const p = await prepareMutation(mutation, options?.scope);
3336
+ if (!p.ok) {
3337
+ return p.rejection;
3338
+ }
3339
+ prepared.push({ op: p.op, collection: mutation.collection });
3340
+ }
3341
+ const operations2 = [];
3342
+ const records = [];
3343
+ for (const { op, collection } of prepared) {
3344
+ const result = await commitOperation(op, collection);
3345
+ if (!result.ok) {
3346
+ return {
3347
+ ok: false,
3348
+ code: result.code,
3349
+ message: result.message,
3350
+ retriable: result.retriable
3351
+ };
3352
+ }
3353
+ operations2.push(result.operation);
3354
+ records.push(result.record);
3355
+ }
3356
+ return { ok: true, idempotent: false, operations: operations2, records };
3357
+ });
3358
+ },
3359
+ async query(collection, options) {
3360
+ const { scope, ...queryOptions } = options ?? {};
3361
+ const records = await store.queryCollection(collection, queryOptions);
3362
+ if (!scope) {
3363
+ return records;
3364
+ }
3365
+ return records.filter((record) => recordMatchesScope(collection, record, scope));
3366
+ },
3367
+ async findById(collection, id, options) {
3368
+ const record = await store.findRecord(collection, id);
3369
+ if (!record) {
3370
+ return null;
3371
+ }
3372
+ if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
3373
+ return null;
3374
+ }
3375
+ return record;
3376
+ }
3377
+ };
3378
+ }
3379
+
2995
3380
  // src/server/kora-sync-server.ts
2996
3381
  var DEFAULT_MAX_CONNECTIONS = 0;
2997
3382
  var DEFAULT_BATCH_SIZE2 = 100;
@@ -3016,10 +3401,16 @@ var KoraSyncServer = class {
3016
3401
  yjsDocRelay = new YjsDocRelay();
3017
3402
  blobChunkRelay;
3018
3403
  persistBlobChunk;
3404
+ maxOperationBytes;
3405
+ maxOpsPerMinute;
3406
+ validateOperation;
3407
+ koraContext;
3019
3408
  sessions = /* @__PURE__ */ new Map();
3020
3409
  httpClients = /* @__PURE__ */ new Map();
3021
3410
  httpSessionToClient = /* @__PURE__ */ new Map();
3022
- serverVersion = "0.4.0";
3411
+ // Informational value reported by getStatus(). Keep in sync with the
3412
+ // @korajs/server package version on release; it is not used for protocol negotiation.
3413
+ serverVersion = "1.0.0-beta.0";
3023
3414
  wsServer = null;
3024
3415
  running = false;
3025
3416
  constructor(config) {
@@ -3042,10 +3433,22 @@ var KoraSyncServer = class {
3042
3433
  this.metrics.setSchemaVersion(this.schemaVersion);
3043
3434
  this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
3044
3435
  this.persistBlobChunk = config.persistBlobChunk ?? null;
3436
+ this.maxOperationBytes = config.maxOperationBytes;
3437
+ this.maxOpsPerMinute = config.maxOpsPerMinute;
3438
+ this.validateOperation = config.validateOperation;
3439
+ this.koraContext = createRouteContext(this, this.store);
3045
3440
  if (!this.emitter) {
3046
3441
  this.emitter = new SimpleEventEmitter();
3047
3442
  }
3048
3443
  }
3444
+ /**
3445
+ * The trusted, scoped data-plane context (`apply` / `query` / `findById`).
3446
+ * Shared with the production server so HTTP routes, the operation validator,
3447
+ * and `server.kora` are all the same object over the same pipeline.
3448
+ */
3449
+ getKoraContext() {
3450
+ return this.koraContext;
3451
+ }
3049
3452
  /**
3050
3453
  * Subscribe to session-level events for metrics collection and logging.
3051
3454
  * Called when a new session is created.
@@ -3227,7 +3630,7 @@ var KoraSyncServer = class {
3227
3630
  if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
3228
3631
  transport.send({
3229
3632
  type: "error",
3230
- messageId: generateUUIDv76(),
3633
+ messageId: generateUUIDv77(),
3231
3634
  code: "MAX_CONNECTIONS",
3232
3635
  message: `Server has reached maximum connections (${this.maxConnections})`,
3233
3636
  retriable: true
@@ -3245,7 +3648,7 @@ var KoraSyncServer = class {
3245
3648
  max: this.maxConnections
3246
3649
  });
3247
3650
  }
3248
- const sessionId = generateUUIDv76();
3651
+ const sessionId = generateUUIDv77();
3249
3652
  this.metrics.recordConnection(sessionId);
3250
3653
  const sessionEmitter = new SimpleEventEmitter();
3251
3654
  sessionEmitter.on("sync:connected", (event) => {
@@ -3317,6 +3720,11 @@ var KoraSyncServer = class {
3317
3720
  this.blobChunkRelay.handleResponse(sourceSessionId, message);
3318
3721
  },
3319
3722
  ...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
3723
+ // Only forward when configured, so an unset server value leaves the
3724
+ // session on its own documented default rather than `undefined`.
3725
+ ...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
3726
+ ...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
3727
+ ...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
3320
3728
  onClose: (sid) => {
3321
3729
  this.handleSessionClose(sid);
3322
3730
  }
@@ -3577,159 +3985,6 @@ function createKoraServer(config) {
3577
3985
  return new KoraSyncServer(config);
3578
3986
  }
3579
3987
 
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
3988
  // src/server/production-server.ts
3734
3989
  var MIME_TYPES = {
3735
3990
  ".html": "text/html",
@@ -3754,7 +4009,7 @@ function createProductionServer(config) {
3754
4009
  enableDashboard: true,
3755
4010
  ...config.syncOptions
3756
4011
  });
3757
- const routeContext = createRouteContext(syncServer, config.store);
4012
+ const routeContext = syncServer.getKoraContext();
3758
4013
  let httpServer = null;
3759
4014
  function getOperationalToken(kind) {
3760
4015
  const auth = config.operationalAuth;
@@ -3894,6 +4149,10 @@ function createProductionServer(config) {
3894
4149
  res.end(JSON.stringify(result.body ?? null));
3895
4150
  }
3896
4151
  return {
4152
+ kora: routeContext,
4153
+ getLiveBlobRefs() {
4154
+ return syncServer.getLiveBlobRefs();
4155
+ },
3897
4156
  async start() {
3898
4157
  const { createServer } = await import("http");
3899
4158
  const { createReadStream, existsSync, statSync } = await import("fs");
@@ -4192,6 +4451,7 @@ export {
4192
4451
  MixedAuthProvider,
4193
4452
  NoAuthProvider,
4194
4453
  PostgresServerStore,
4454
+ RETRIABLE_REJECTION_CODES,
4195
4455
  SqliteServerStore,
4196
4456
  TokenAuthProvider,
4197
4457
  WsServerTransport,
@@ -4202,6 +4462,7 @@ export {
4202
4462
  createPrettyLogger,
4203
4463
  createProductionServer,
4204
4464
  createSilentLogger,
4205
- createSqliteServerStore
4465
+ createSqliteServerStore,
4466
+ isRetriableRejection
4206
4467
  };
4207
4468
  //# sourceMappingURL=index.js.map