@concavejs/core 0.0.1-alpha.6 → 0.0.1-alpha.8

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.
Files changed (69) hide show
  1. package/dist/auth/auth-context.d.ts +4 -15
  2. package/dist/auth/auth-context.js +6 -15
  3. package/dist/auth/jwt.d.ts +5 -0
  4. package/dist/auth/jwt.js +37 -5
  5. package/dist/docstore/index.d.ts +1 -0
  6. package/dist/docstore/index.js +1 -0
  7. package/dist/docstore/interface.d.ts +8 -0
  8. package/dist/docstore/search-interfaces.d.ts +29 -0
  9. package/dist/docstore/search-interfaces.js +8 -0
  10. package/dist/http/api-router.d.ts +2 -0
  11. package/dist/http/api-router.js +60 -4
  12. package/dist/http/http-handler.js +19 -4
  13. package/dist/id-codec/document-id.js +4 -3
  14. package/dist/index.d.ts +3 -2
  15. package/dist/index.js +2 -1
  16. package/dist/kernel/context-storage.d.ts +1 -0
  17. package/dist/kernel/context-storage.js +29 -11
  18. package/dist/kernel/docstore-gateway.d.ts +31 -6
  19. package/dist/kernel/docstore-gateway.js +105 -31
  20. package/dist/kernel/index.d.ts +4 -3
  21. package/dist/kernel/index.js +4 -3
  22. package/dist/kernel/missing-schema-error.d.ts +5 -0
  23. package/dist/kernel/missing-schema-error.js +25 -0
  24. package/dist/kernel/native-timers.d.ts +7 -0
  25. package/dist/kernel/native-timers.js +14 -0
  26. package/dist/kernel/schema-service.d.ts +0 -5
  27. package/dist/kernel/schema-service.js +1 -25
  28. package/dist/kernel/udf-kernel.d.ts +11 -1
  29. package/dist/kernel/udf-kernel.js +10 -10
  30. package/dist/query/query-runtime.js +6 -3
  31. package/dist/queryengine/cursor.js +3 -2
  32. package/dist/queryengine/index.d.ts +2 -2
  33. package/dist/queryengine/index.js +1 -1
  34. package/dist/ryow/uncommitted-writes.js +4 -5
  35. package/dist/scheduler/cron-executor.d.ts +6 -2
  36. package/dist/scheduler/cron-executor.js +56 -27
  37. package/dist/scheduler/scheduled-function-executor.d.ts +6 -2
  38. package/dist/scheduler/scheduled-function-executor.js +61 -42
  39. package/dist/subscriptions/subscription-manager.d.ts +22 -0
  40. package/dist/subscriptions/subscription-manager.js +131 -44
  41. package/dist/sync/protocol-handler.d.ts +30 -0
  42. package/dist/sync/protocol-handler.js +140 -1
  43. package/dist/sync/session-backpressure.js +4 -3
  44. package/dist/sync/session-heartbeat.js +3 -2
  45. package/dist/system/internal.js +6 -3
  46. package/dist/system/system-functions.js +1 -1
  47. package/dist/transactor/occ-transaction.js +9 -8
  48. package/dist/transactor/occ-validation.js +3 -3
  49. package/dist/udf/analysis/validator.js +1 -1
  50. package/dist/udf/execution-adapter.d.ts +1 -1
  51. package/dist/udf/execution-adapter.js +2 -2
  52. package/dist/udf/executor/inline.d.ts +2 -2
  53. package/dist/udf/executor/inline.js +9 -5
  54. package/dist/udf/executor/interface.d.ts +1 -1
  55. package/dist/udf/module-loader/call-context.d.ts +5 -6
  56. package/dist/udf/module-loader/call-context.js +5 -9
  57. package/dist/udf/module-loader/module-loader.js +26 -4
  58. package/dist/udf/runtime/udf-rand.js +1 -1
  59. package/dist/udf/runtime/udf-setup.d.ts +22 -1
  60. package/dist/udf/runtime/udf-setup.js +151 -55
  61. package/dist/utils/base64.d.ts +4 -0
  62. package/dist/utils/base64.js +58 -0
  63. package/dist/utils/crypto.d.ts +2 -0
  64. package/dist/utils/crypto.js +40 -0
  65. package/dist/utils/index.d.ts +1 -0
  66. package/dist/utils/index.js +1 -0
  67. package/dist/utils/utils.d.ts +6 -1
  68. package/dist/utils/utils.js +6 -1
  69. package/package.json +5 -1
@@ -7,6 +7,7 @@
7
7
  import { encodeServerMessage } from "../types";
8
8
  import { assertAdminToken, assertSystemToken, verifyJwtAndGetIdentity, JWTValidationError } from "../auth";
9
9
  import { SubscriptionManager } from "../subscriptions";
10
+ import { nativeTimers } from "../kernel/native-timers";
10
11
  import { deserializeKeyRange } from "../queryengine";
11
12
  import { advanceSessionTimestamp, convertTablesToRanges, formatRangesForLog, longToBigIntTimestamp, makeStateVersion, makeTransitionMessage, toLongTimestamp, } from "./protocol-utils";
12
13
  import { executeReactiveQuery } from "./query-execution";
@@ -60,6 +61,11 @@ export const WEBSOCKET_READY_STATE_OPEN = 1;
60
61
  const BACKPRESSURE_HIGH_WATER_MARK = 100; // Max messages in queue before dropping
61
62
  const BACKPRESSURE_BUFFER_LIMIT = 1024 * 1024; // 1MB buffer limit
62
63
  const SLOW_CLIENT_TIMEOUT_MS = 30000; // 30 seconds to drain queue before disconnect
64
+ const DEFAULT_RATE_LIMIT_WINDOW_MS = 5000;
65
+ const DEFAULT_MAX_MESSAGES_PER_WINDOW = 1000;
66
+ const DEFAULT_OPERATION_TIMEOUT_MS = 15000;
67
+ const DEFAULT_MAX_ACTIVE_QUERIES_PER_SESSION = 1_000;
68
+ const RATE_LIMIT_HARD_MULTIPLIER = 5;
63
69
  /**
64
70
  * Session state for a connected client
65
71
  */
@@ -96,15 +102,27 @@ export class SyncSession {
96
102
  export class SyncProtocolHandler {
97
103
  udfExecutor;
98
104
  sessions = new Map();
105
+ rateLimitStates = new Map();
99
106
  subscriptionManager;
100
107
  instanceName;
101
108
  backpressureController;
102
109
  heartbeatController;
103
110
  isDev;
111
+ maxMessagesPerWindow;
112
+ rateLimitWindowMs;
113
+ operationTimeoutMs;
114
+ maxActiveQueriesPerSession;
104
115
  constructor(instanceName, udfExecutor, options) {
105
116
  this.udfExecutor = udfExecutor;
106
117
  this.instanceName = instanceName;
107
118
  this.isDev = options?.isDev ?? true;
119
+ this.maxMessagesPerWindow = Math.max(1, options?.maxMessagesPerWindow ?? DEFAULT_MAX_MESSAGES_PER_WINDOW);
120
+ this.rateLimitWindowMs = Math.max(1, options?.rateLimitWindowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS);
121
+ const configuredOperationTimeout = options?.operationTimeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS;
122
+ this.operationTimeoutMs = Number.isFinite(configuredOperationTimeout)
123
+ ? Math.max(0, Math.floor(configuredOperationTimeout))
124
+ : DEFAULT_OPERATION_TIMEOUT_MS;
125
+ this.maxActiveQueriesPerSession = Math.max(1, options?.maxActiveQueriesPerSession ?? DEFAULT_MAX_ACTIVE_QUERIES_PER_SESSION);
108
126
  this.subscriptionManager = new SubscriptionManager();
109
127
  this.backpressureController = new SessionBackpressureController({
110
128
  websocketReadyStateOpen: WEBSOCKET_READY_STATE_OPEN,
@@ -125,6 +143,10 @@ export class SyncProtocolHandler {
125
143
  createSession(sessionId, websocket) {
126
144
  const session = new SyncSession(websocket);
127
145
  this.sessions.set(sessionId, session);
146
+ this.rateLimitStates.set(sessionId, {
147
+ windowStartedAt: Date.now(),
148
+ messagesInWindow: 0,
149
+ });
128
150
  return session;
129
151
  }
130
152
  /**
@@ -146,6 +168,7 @@ export class SyncProtocolHandler {
146
168
  session.isDraining = false;
147
169
  this.subscriptionManager.unsubscribeAll(sessionId);
148
170
  this.sessions.delete(sessionId);
171
+ this.rateLimitStates.delete(sessionId);
149
172
  }
150
173
  }
151
174
  /**
@@ -156,6 +179,11 @@ export class SyncProtocolHandler {
156
179
  if (session) {
157
180
  this.sessions.delete(oldSessionId);
158
181
  this.sessions.set(newSessionId, session);
182
+ const rateLimitState = this.rateLimitStates.get(oldSessionId);
183
+ if (rateLimitState) {
184
+ this.rateLimitStates.delete(oldSessionId);
185
+ this.rateLimitStates.set(newSessionId, rateLimitState);
186
+ }
159
187
  // Update subscription manager mappings
160
188
  this.subscriptionManager.updateSessionId(oldSessionId, newSessionId);
161
189
  }
@@ -169,6 +197,32 @@ export class SyncProtocolHandler {
169
197
  if (!session && message.type !== "Connect") {
170
198
  throw new Error("Session not found");
171
199
  }
200
+ if (session) {
201
+ const rateLimitDecision = this.consumeRateLimit(sessionId);
202
+ if (rateLimitDecision === "reject") {
203
+ return [
204
+ {
205
+ type: "FatalError",
206
+ error: "Rate limit exceeded, retry shortly",
207
+ },
208
+ ];
209
+ }
210
+ if (rateLimitDecision === "close") {
211
+ try {
212
+ session.websocket.close(1013, "Rate limit exceeded");
213
+ }
214
+ catch {
215
+ // Ignore close errors and still destroy local session state.
216
+ }
217
+ this.destroySession(sessionId);
218
+ return [
219
+ {
220
+ type: "FatalError",
221
+ error: "Rate limit exceeded",
222
+ },
223
+ ];
224
+ }
225
+ }
172
226
  switch (message.type) {
173
227
  case "Connect":
174
228
  return this.handleConnect(sessionId, message);
@@ -229,6 +283,13 @@ export class SyncProtocolHandler {
229
283
  async handleModifyQuerySet(sessionId, message) {
230
284
  const session = this.sessions.get(sessionId);
231
285
  return this.withSessionLock(session, async () => {
286
+ // If the client restarts its local query set (baseVersion=0), align the
287
+ // server's initial transition timestamp with the zeroed client state.
288
+ // Without this, a carried maxObservedTimestamp can produce startVersion
289
+ // mismatches like "X:0:0 transitioning from 0:0:0" after reconnect.
290
+ if (session.querySetVersion === 0 && session.identityVersion === 0 && message.baseVersion === 0) {
291
+ session.timestamp = 0n;
292
+ }
232
293
  // For reconnections, accept the client's base version as the starting point
233
294
  if (session.querySetVersion === 0 && message.baseVersion > 0) {
234
295
  session.querySetVersion = message.baseVersion;
@@ -241,6 +302,15 @@ export class SyncProtocolHandler {
241
302
  return [fatalError];
242
303
  }
243
304
  const startVersion = makeStateVersion(session.querySetVersion, session.identityVersion, session.timestamp);
305
+ const projectedActiveQueryCount = this.computeProjectedActiveQueryCount(session, message);
306
+ if (projectedActiveQueryCount > this.maxActiveQueriesPerSession) {
307
+ return [
308
+ {
309
+ type: "FatalError",
310
+ error: `Too many active queries: ${projectedActiveQueryCount} exceeds limit ${this.maxActiveQueriesPerSession}`,
311
+ },
312
+ ];
313
+ }
244
314
  // Update version before async work
245
315
  session.querySetVersion = message.newVersion;
246
316
  const modifications = [];
@@ -514,6 +584,27 @@ export class SyncProtocolHandler {
514
584
  const endVersion = makeStateVersion(session.querySetVersion, session.identityVersion, endTimestamp);
515
585
  return makeTransitionMessage(startVersion, endVersion, modifications);
516
586
  }
587
+ /**
588
+ * Re-execute all active subscription queries across all sessions.
589
+ * Used during hot-reload to push fresh results after module code changes.
590
+ */
591
+ async rerunAllSubscriptions() {
592
+ for (const [sessionId, session] of this.sessions) {
593
+ if (session.activeQueries.size === 0)
594
+ continue;
595
+ await this.withSessionLock(session, async () => {
596
+ const allQueryIds = new Set(session.activeQueries.keys());
597
+ const modifications = await this.rerunSpecificQueries(session, sessionId, allQueryIds);
598
+ if (modifications.length > 0) {
599
+ const startTimestamp = session.timestamp;
600
+ const endTimestamp = advanceSessionTimestamp(session);
601
+ const startVersion = makeStateVersion(session.querySetVersion, session.identityVersion, startTimestamp);
602
+ const endVersion = makeStateVersion(session.querySetVersion, session.identityVersion, endTimestamp);
603
+ this.send(session, makeTransitionMessage(startVersion, endVersion, modifications));
604
+ }
605
+ });
606
+ }
607
+ }
517
608
  /**
518
609
  * Broadcast updates using fine-grained range-based tracking
519
610
  * Sends messages directly to affected sessions (excluding the originating session)
@@ -614,7 +705,55 @@ export class SyncProtocolHandler {
614
705
  }
615
706
  });
616
707
  session.lock = run.then(() => undefined, () => undefined);
617
- return run;
708
+ return this.withOperationTimeout(run);
709
+ }
710
+ withOperationTimeout(promise) {
711
+ if (this.operationTimeoutMs <= 0) {
712
+ return promise;
713
+ }
714
+ let timeoutHandle;
715
+ const timeoutPromise = new Promise((_, reject) => {
716
+ timeoutHandle = nativeTimers.setTimeout(() => {
717
+ reject(new Error(`Sync operation timed out after ${this.operationTimeoutMs}ms`));
718
+ }, this.operationTimeoutMs);
719
+ });
720
+ return Promise.race([promise, timeoutPromise]).finally(() => {
721
+ if (timeoutHandle) {
722
+ nativeTimers.clearTimeout(timeoutHandle);
723
+ }
724
+ });
725
+ }
726
+ consumeRateLimit(sessionId) {
727
+ const state = this.rateLimitStates.get(sessionId);
728
+ if (!state) {
729
+ return "allow";
730
+ }
731
+ const now = Date.now();
732
+ if (now - state.windowStartedAt >= this.rateLimitWindowMs) {
733
+ state.windowStartedAt = now;
734
+ state.messagesInWindow = 0;
735
+ }
736
+ state.messagesInWindow += 1;
737
+ if (state.messagesInWindow <= this.maxMessagesPerWindow) {
738
+ return "allow";
739
+ }
740
+ const hardLimit = Math.max(this.maxMessagesPerWindow + 1, this.maxMessagesPerWindow * RATE_LIMIT_HARD_MULTIPLIER);
741
+ if (state.messagesInWindow >= hardLimit) {
742
+ return "close";
743
+ }
744
+ return "reject";
745
+ }
746
+ computeProjectedActiveQueryCount(session, message) {
747
+ const projected = new Set(session.activeQueries.keys());
748
+ for (const mod of message.modifications) {
749
+ if (mod.type === "Add") {
750
+ projected.add(mod.queryId);
751
+ }
752
+ else if (mod.type === "Remove") {
753
+ projected.delete(mod.queryId);
754
+ }
755
+ }
756
+ return projected.size;
618
757
  }
619
758
  sendPing(session) {
620
759
  if (!session.websocket || session.websocket.readyState !== WEBSOCKET_READY_STATE_OPEN) {
@@ -1,3 +1,4 @@
1
+ import { nativeTimers } from "../kernel/native-timers";
1
2
  export class SessionBackpressureController {
2
3
  options;
3
4
  constructor(options) {
@@ -27,7 +28,7 @@ export class SessionBackpressureController {
27
28
  }
28
29
  clearDrainTimeout(session) {
29
30
  if (session.drainTimeout) {
30
- clearTimeout(session.drainTimeout);
31
+ nativeTimers.clearTimeout(session.drainTimeout);
31
32
  session.drainTimeout = undefined;
32
33
  }
33
34
  }
@@ -35,7 +36,7 @@ export class SessionBackpressureController {
35
36
  if (session.isDraining)
36
37
  return;
37
38
  session.isDraining = true;
38
- session.drainTimeout = setTimeout(() => {
39
+ session.drainTimeout = nativeTimers.setTimeout(() => {
39
40
  if (session.messageQueue.length > 0) {
40
41
  this.options.warn(`[SyncProtocolHandler] Slow client timeout - disconnecting. ` +
41
42
  `Queue depth: ${session.messageQueue.length}, dropped: ${session.droppedMessages}`);
@@ -68,7 +69,7 @@ export class SessionBackpressureController {
68
69
  this.clearDrainTimeout(session);
69
70
  }
70
71
  else {
71
- setTimeout(() => this.drainQueue(session), 10);
72
+ nativeTimers.setTimeout(() => this.drainQueue(session), 10);
72
73
  }
73
74
  }
74
75
  }
@@ -1,3 +1,4 @@
1
+ import { nativeTimers } from "../kernel/native-timers";
1
2
  export class SessionHeartbeatController {
2
3
  options;
3
4
  constructor(options) {
@@ -9,7 +10,7 @@ export class SessionHeartbeatController {
9
10
  }
10
11
  clear(session) {
11
12
  if (session.pingTimer) {
12
- clearTimeout(session.pingTimer);
13
+ nativeTimers.clearTimeout(session.pingTimer);
13
14
  session.pingTimer = undefined;
14
15
  }
15
16
  }
@@ -18,7 +19,7 @@ export class SessionHeartbeatController {
18
19
  if (!session.websocket || session.websocket.readyState !== this.options.websocketReadyStateOpen) {
19
20
  return;
20
21
  }
21
- session.pingTimer = setTimeout(() => {
22
+ session.pingTimer = nativeTimers.setTimeout(() => {
22
23
  this.options.onPing(session);
23
24
  }, this.options.intervalMs);
24
25
  }
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { v } from "convex/values";
23
23
  import { loadConvexModule, listRegisteredModules } from "../udf/module-loader/module-loader";
24
- import { isMissingSchemaModuleError } from "../kernel/schema-service";
24
+ import { isMissingSchemaModuleError } from "../kernel/missing-schema-error";
25
25
  import { listSystemFunctions } from "./function-introspection";
26
26
  import { getFullTableName } from "../tables/interface";
27
27
  import { queryExecutionLog, clearExecutionLog } from "./execution-log";
@@ -505,8 +505,11 @@ export function createSystemFunctions(deps) {
505
505
  systemListFunctions: query({
506
506
  args: { componentPath: v.optional(v.string()) },
507
507
  handler: async (ctx, args) => {
508
- const componentPath = args.componentPath ?? ctx?.componentPath ?? undefined;
509
- const functions = await listSystemFunctions({ componentPath });
508
+ const requestedComponentPath = args.componentPath ?? ctx?.componentPath ?? undefined;
509
+ const normalizedComponentPath = typeof requestedComponentPath === "string" && requestedComponentPath.trim().length === 0
510
+ ? undefined
511
+ : requestedComponentPath;
512
+ const functions = await listSystemFunctions({ componentPath: normalizedComponentPath });
510
513
  return functions.map((fn) => ({
511
514
  name: fn.name,
512
515
  path: fn.path,
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { hexToString, stringToHex } from "../utils/utils";
6
6
  import { loadConvexModule } from "../udf/module-loader/module-loader";
7
- import { isMissingSchemaModuleError } from "../kernel/schema-service";
7
+ import { isMissingSchemaModuleError } from "../kernel/missing-schema-error";
8
8
  import { listSystemFunctions } from "./function-introspection";
9
9
  import { Order } from "../docstore/interface";
10
10
  /**
@@ -1,7 +1,8 @@
1
1
  import { ConflictError } from "./transaction-manager";
2
2
  import { RangeSet } from "../queryengine/indexing/read-write-set";
3
3
  import { decodeIndexId, indexKeyspaceId, parseKeyspaceId } from "../utils/keyspace";
4
- import { arrayBufferToHex, readVersionKey } from "../utils/utils";
4
+ import { arrayBufferToHex } from "../utils/utils";
5
+ import { documentIdKey } from "../docstore/interface";
5
6
  import { UncommittedWrites } from "../ryow/uncommitted-writes";
6
7
  import { validateReadSetForCommit } from "./occ-validation";
7
8
  /**
@@ -73,7 +74,7 @@ export class OccMutationTransaction {
73
74
  * @returns The same latest document (for convenience)
74
75
  */
75
76
  recordDocumentRead(id, latest) {
76
- const key = readVersionKey(id);
77
+ const key = documentIdKey(id);
77
78
  // Only record the first read of each document
78
79
  if (!this.readSet.has(key)) {
79
80
  const version = latest ? latest.ts : null;
@@ -93,7 +94,7 @@ export class OccMutationTransaction {
93
94
  * @returns The same latest document (for convenience)
94
95
  */
95
96
  recordDocumentReadForOccOnly(id, latest) {
96
- const key = readVersionKey(id);
97
+ const key = documentIdKey(id);
97
98
  // Only record the first read of each document
98
99
  if (!this.readSet.has(key)) {
99
100
  const version = latest ? latest.ts : null;
@@ -121,7 +122,7 @@ export class OccMutationTransaction {
121
122
  // Add to staged writes - using Map to automatically merge multiple writes to same document
122
123
  // Only the latest write to each document will be kept, preventing duplicate timestamps
123
124
  for (const doc of documents) {
124
- const key = readVersionKey(doc.id);
125
+ const key = documentIdKey(doc.id);
125
126
  const existing = this.stagedDocuments.get(key);
126
127
  if (existing) {
127
128
  // When merging writes to the same document, preserve the original prev_ts
@@ -190,7 +191,7 @@ export class OccMutationTransaction {
190
191
  * @returns Latest document version, or null if document doesn't exist
191
192
  */
192
193
  async getLatest(id) {
193
- const key = readVersionKey(id);
194
+ const key = documentIdKey(id);
194
195
  // Check if we have a pending write for this document
195
196
  if (this.pendingDocuments.has(key)) {
196
197
  const pending = this.pendingDocuments.get(key);
@@ -244,7 +245,7 @@ export class OccMutationTransaction {
244
245
  const scanKey = `table_scan:${tableId}`;
245
246
  const documentIds = new Set();
246
247
  for (const doc of docs) {
247
- documentIds.add(readVersionKey(doc.value.id));
248
+ documentIds.add(documentIdKey(doc.value.id));
248
249
  }
249
250
  this.readSet.set(scanKey, {
250
251
  type: "table_scan",
@@ -262,7 +263,7 @@ export class OccMutationTransaction {
262
263
  const rangeKey = `index_range:${indexId}:${arrayBufferToHex(startKey)}`;
263
264
  const documentIds = new Set();
264
265
  for (const doc of docs) {
265
- documentIds.add(readVersionKey(doc.value.id));
266
+ documentIds.add(documentIdKey(doc.value.id));
266
267
  }
267
268
  this.readSet.set(rangeKey, {
268
269
  type: "index_range",
@@ -303,7 +304,7 @@ export class OccMutationTransaction {
303
304
  const docId = doc.value.id;
304
305
  // Record read for OCC validation (skip adding to readRanges since table scan covers it)
305
306
  this.recordDocumentReadForOccOnly(docId, doc);
306
- const key = readVersionKey(docId);
307
+ const key = documentIdKey(docId);
307
308
  visible.set(key, doc);
308
309
  }
309
310
  // Apply pending writes using consolidated RYOW helper
@@ -1,5 +1,5 @@
1
1
  import { Order } from "../docstore/interface";
2
- import { readVersionKey } from "../utils/utils";
2
+ import { documentIdKey } from "../docstore/interface";
3
3
  import { decodeIndexId } from "../utils/keyspace";
4
4
  import { ConflictError } from "./transaction-manager";
5
5
  export async function validateReadSetForCommit(docstore, readChecks, writtenDocKeys) {
@@ -43,7 +43,7 @@ async function validateTableScanRead(docstore, tableId, readDocumentIds, written
43
43
  const currentDocs = await docstore.scan(tableId);
44
44
  const currentDocIds = new Set();
45
45
  for (const doc of currentDocs) {
46
- currentDocIds.add(readVersionKey(doc.value.id));
46
+ currentDocIds.add(documentIdKey(doc.value.id));
47
47
  }
48
48
  for (const docId of currentDocIds) {
49
49
  if (!readDocumentIds.has(docId) && !writtenDocKeys.has(docId)) {
@@ -62,7 +62,7 @@ async function validateIndexRangeRead(docstore, indexId, startKey, endKey, readD
62
62
  const currentDocIds = new Set();
63
63
  for await (const [, doc] of docstore.index_scan(indexId, table, // table as hex-encoded tableId
64
64
  BigInt(Date.now()), interval, Order.Asc)) {
65
- currentDocIds.add(readVersionKey(doc.value.id));
65
+ currentDocIds.add(documentIdKey(doc.value.id));
66
66
  }
67
67
  for (const docId of currentDocIds) {
68
68
  if (!readDocumentIds.has(docId) && !writtenDocKeys.has(docId)) {
@@ -1,7 +1,7 @@
1
1
  import { hexToString, deserializeDeveloperId } from "../../utils/utils";
2
2
  import { getFullTableName, parseFullTableName } from "../../tables/interface";
3
3
  import { loadConvexModule } from "../module-loader/module-loader";
4
- import { isMissingSchemaModuleError } from "../../kernel/schema-service";
4
+ import { isMissingSchemaModuleError } from "../../kernel/missing-schema-error";
5
5
  export class ValidatorError extends Error {
6
6
  path;
7
7
  constructor(message, path = "") {
@@ -42,7 +42,7 @@ export declare class UdfExecutionAdapter {
42
42
  * @param requestId - Optional request ID for tracing
43
43
  * @returns UDF execution result
44
44
  */
45
- executeUdf(path: string, jsonArgs: JsonArgs, type: "query" | "mutation" | "action", auth?: AuthContext | UserIdentityAttributes, componentPath?: string, requestId?: string): Promise<UdfResult>;
45
+ executeUdf(path: string, jsonArgs: JsonArgs, type: "query" | "mutation" | "action", auth?: AuthContext | UserIdentityAttributes, componentPath?: string, requestId?: string, snapshotTimestamp?: bigint): Promise<UdfResult>;
46
46
  }
47
47
  /**
48
48
  * Factory function to create a client-side adapter (for HTTP/WebSocket requests)
@@ -58,7 +58,7 @@ export class UdfExecutionAdapter {
58
58
  * @param requestId - Optional request ID for tracing
59
59
  * @returns UDF execution result
60
60
  */
61
- async executeUdf(path, jsonArgs, type, auth, componentPath, requestId) {
61
+ async executeUdf(path, jsonArgs, type, auth, componentPath, requestId, snapshotTimestamp) {
62
62
  // Step 1: Convert args with type safety
63
63
  const convexArgs = convertClientArgs(jsonArgs);
64
64
  const target = normalizeExecutionTarget(path, componentPath);
@@ -104,7 +104,7 @@ export class UdfExecutionAdapter {
104
104
  const executeWithContext = this.callType === "client" ? runAsClientCall : runAsServerCall;
105
105
  // Step 4: Execute with all context properly set
106
106
  return executeWithContext(async () => {
107
- return await this.executor.execute(target.path, convexArgs, type, authContext ?? userIdentity, normalizeComponentPath(target.componentPath), requestId);
107
+ return await this.executor.execute(target.path, convexArgs, type, authContext ?? userIdentity, normalizeComponentPath(target.componentPath), requestId, snapshotTimestamp);
108
108
  });
109
109
  });
110
110
  }
@@ -1,7 +1,7 @@
1
1
  import type { UserIdentityAttributes } from "convex/server";
2
2
  import type { DocStore } from "../../docstore";
3
3
  import type { BlobStore } from "../../abstractions";
4
- import { type UdfResult } from "..";
4
+ import { type UdfResult } from "../runtime/udf-setup";
5
5
  import type { AuthContext } from "../../sync/protocol-handler";
6
6
  import type { UdfExec } from "./interface";
7
7
  import { type ModuleRegistry } from "../module-loader/module-loader";
@@ -22,7 +22,7 @@ export declare class InlineUdfExecutor implements UdfExec {
22
22
  private moduleRegistry?;
23
23
  private readonly logSink?;
24
24
  constructor(options: InlineUdfExecutorOptions);
25
- execute(functionPath: string, args: Record<string, any>, udfType: "query" | "mutation" | "action", auth?: AuthContext | UserIdentityAttributes, componentPath?: string, requestId?: string): Promise<UdfResult>;
25
+ execute(functionPath: string, args: Record<string, any>, udfType: "query" | "mutation" | "action", auth?: AuthContext | UserIdentityAttributes, componentPath?: string, requestId?: string, snapshotTimestamp?: bigint): Promise<UdfResult>;
26
26
  executeHttp(request: Request, auth?: AuthContext | UserIdentityAttributes, requestId?: string): Promise<Response>;
27
27
  setModuleRegistry(moduleRegistry?: ModuleRegistry): void;
28
28
  protected loadModule(moduleName: string, componentPath?: string): Promise<any>;
@@ -1,12 +1,16 @@
1
1
  import { convexToJson } from "convex/values";
2
- import { runUdfQuery, runUdfMutation, runUdfAction, runUdfHttpAction, loadConvexModule, validateValidator, ValidatorError, getCallContext, } from "..";
2
+ import { runUdfQuery, runUdfMutation, runUdfAction, runUdfHttpAction, } from "../runtime/udf-setup";
3
+ import { loadConvexModule } from "../module-loader/module-loader";
4
+ import { validateValidator, ValidatorError } from "../analysis/validator";
5
+ import { getCallContext } from "../module-loader/call-context";
3
6
  import { InternalFunctionAccessError } from "../../errors";
4
7
  import { withModuleRegistry } from "../module-loader/module-loader";
5
8
  import { pushExecutionLog } from "../../system/execution-log";
9
+ import { randomUuid } from "../../utils/crypto";
6
10
  let inlineRequestCounter = 0;
7
11
  function defaultRequestId(udfType, functionPath) {
8
12
  inlineRequestCounter += 1;
9
- return `${udfType}:${functionPath}:${Date.now()}:${inlineRequestCounter}:${crypto.randomUUID()}`;
13
+ return `${udfType}:${functionPath}:${Date.now()}:${inlineRequestCounter}:${randomUuid()}`;
10
14
  }
11
15
  export class InlineUdfExecutor {
12
16
  docstore;
@@ -21,7 +25,7 @@ export class InlineUdfExecutor {
21
25
  this.moduleRegistry = options.moduleRegistry;
22
26
  this.logSink = options.logSink;
23
27
  }
24
- async execute(functionPath, args, udfType, auth, componentPath, requestId) {
28
+ async execute(functionPath, args, udfType, auth, componentPath, requestId, snapshotTimestamp) {
25
29
  const [moduleName, functionName] = this.parseUdfPath(functionPath);
26
30
  const finalRequestId = requestId ?? this.requestIdFactory(udfType, functionPath);
27
31
  // Skip logging for system functions to avoid recursion
@@ -47,7 +51,7 @@ export class InlineUdfExecutor {
47
51
  const runWithType = () => {
48
52
  switch (udfType) {
49
53
  case "query":
50
- return runUdfQuery(this.docstore, runUdf, auth, this.blobstore, finalRequestId, this, componentPath);
54
+ return runUdfQuery(this.docstore, runUdf, auth, this.blobstore, finalRequestId, this, componentPath, snapshotTimestamp);
51
55
  case "mutation":
52
56
  return runUdfMutation(this.docstore, runUdf, auth, this.blobstore, finalRequestId, this, componentPath);
53
57
  case "action":
@@ -106,7 +110,7 @@ export class InlineUdfExecutor {
106
110
  async executeHttp(request, auth, requestId) {
107
111
  const url = new URL(request.url);
108
112
  const runHttpUdf = async () => {
109
- const httpModule = await this.loadModule("http", request.url);
113
+ const httpModule = await this.loadModule("http");
110
114
  const router = httpModule?.default;
111
115
  if (!router?.isRouter || typeof router.lookup !== "function") {
112
116
  throw new Error("convex/http.ts must export a default httpRouter()");
@@ -3,6 +3,6 @@ import type { AuthContext } from "../../sync/protocol-handler";
3
3
  import type { UserIdentityAttributes } from "convex/server";
4
4
  export type { UdfResult };
5
5
  export interface UdfExec {
6
- execute(path: string, args: Record<string, unknown>, type: "query" | "mutation" | "action", auth?: AuthContext | UserIdentityAttributes, componentPath?: string, requestId?: string): Promise<UdfResult>;
6
+ execute(path: string, args: Record<string, unknown>, type: "query" | "mutation" | "action", auth?: AuthContext | UserIdentityAttributes, componentPath?: string, requestId?: string, snapshotTimestamp?: bigint): Promise<UdfResult>;
7
7
  executeHttp(request: Request, auth?: AuthContext | UserIdentityAttributes, requestId?: string): Promise<Response>;
8
8
  }
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Call context tracking for UDF execution.
3
3
  *
4
- * This module provides AsyncLocalStorage-based tracking of whether a function
5
- * is being called from a client (HTTP request) or from another server function.
6
- * This is used to enforce that internal functions can only be called from server.
4
+ * This module tracks whether a function is being called from a client
5
+ * (HTTP request) or from another server function.
7
6
  */
8
- import { AsyncLocalStorage } from "node:async_hooks";
7
+ import { ContextStorage } from "../../kernel/context-storage";
9
8
  export type CallContext = {
10
9
  /**
11
10
  * Where the call originated from:
@@ -20,9 +19,9 @@ export type CallContext = {
20
19
  };
21
20
  /**
22
21
  * Use a Symbol.for singleton so separately bundled copies of this module
23
- * share the same AsyncLocalStorage instance.
22
+ * share the same context storage instance.
24
23
  */
25
- export declare const callContext: AsyncLocalStorage<CallContext>;
24
+ export declare const callContext: ContextStorage<CallContext>;
26
25
  /**
27
26
  * Get the current call context
28
27
  */
@@ -1,21 +1,17 @@
1
1
  /**
2
2
  * Call context tracking for UDF execution.
3
3
  *
4
- * This module provides AsyncLocalStorage-based tracking of whether a function
5
- * is being called from a client (HTTP request) or from another server function.
6
- * This is used to enforce that internal functions can only be called from server.
7
- */
8
- import { AsyncLocalStorage } from "node:async_hooks";
9
- /**
10
- * AsyncLocalStorage for call context
4
+ * This module tracks whether a function is being called from a client
5
+ * (HTTP request) or from another server function.
11
6
  */
7
+ import { ContextStorage } from "../../kernel/context-storage";
12
8
  const CALL_CONTEXT_SYMBOL = Symbol.for("@concavejs/core/call-context");
13
9
  const globalCallContext = globalThis;
14
10
  /**
15
11
  * Use a Symbol.for singleton so separately bundled copies of this module
16
- * share the same AsyncLocalStorage instance.
12
+ * share the same context storage instance.
17
13
  */
18
- export const callContext = globalCallContext[CALL_CONTEXT_SYMBOL] ?? new AsyncLocalStorage();
14
+ export const callContext = globalCallContext[CALL_CONTEXT_SYMBOL] ?? new ContextStorage();
19
15
  if (!globalCallContext[CALL_CONTEXT_SYMBOL]) {
20
16
  globalCallContext[CALL_CONTEXT_SYMBOL] = callContext;
21
17
  }
@@ -1,8 +1,27 @@
1
- import { getSystemFunctionsModule } from "../../system/system-functions-module";
2
- import { AsyncLocalStorage } from "node:async_hooks";
1
+ import { ContextStorage } from "../../kernel/context-storage";
3
2
  const MODULE_STATE_SYMBOL = Symbol.for("concave.moduleLoader.state");
4
3
  const MODULE_LOADER_METADATA_SYMBOL = Symbol.for("concave.moduleLoader.metadata");
5
- const MODULE_REGISTRY_CONTEXT = new AsyncLocalStorage();
4
+ const MODULE_REGISTRY_CONTEXT = new ContextStorage();
5
+ const SYSTEM_FUNCTIONS_MODULE_PATH = "../../system/system-functions-module.js";
6
+ let builtInSystemFunctionsModulePromise;
7
+ let browserConvexFunctionsAllowed = false;
8
+ function allowConvexFunctionsInBrowser() {
9
+ if (browserConvexFunctionsAllowed) {
10
+ return;
11
+ }
12
+ browserConvexFunctionsAllowed = true;
13
+ const globalAny = globalThis;
14
+ if (globalAny.window && typeof globalAny.window === "object") {
15
+ globalAny.window.__convexAllowFunctionsInBrowser = true;
16
+ }
17
+ }
18
+ async function getBuiltInSystemFunctionsModule() {
19
+ if (!builtInSystemFunctionsModulePromise) {
20
+ allowConvexFunctionsInBrowser();
21
+ builtInSystemFunctionsModulePromise = import("../../system/system-functions-module.js").then((mod) => mod.getSystemFunctionsModule());
22
+ }
23
+ return builtInSystemFunctionsModulePromise;
24
+ }
6
25
  class ModuleRegistry {
7
26
  scopedLoaders = new Map();
8
27
  constructor(defaults = []) {
@@ -55,6 +74,7 @@ class ModuleRegistry {
55
74
  };
56
75
  }
57
76
  async load(request) {
77
+ allowConvexFunctionsInBrowser();
58
78
  const scopes = expandComponentScopes(request.componentPath);
59
79
  const errors = [];
60
80
  let attempted = false;
@@ -160,6 +180,8 @@ function getModuleState() {
160
180
  export function resetModuleState() {
161
181
  const globalAny = globalThis;
162
182
  delete globalAny[MODULE_STATE_SYMBOL];
183
+ builtInSystemFunctionsModulePromise = undefined;
184
+ browserConvexFunctionsAllowed = false;
163
185
  }
164
186
  export function getModuleRegistry() {
165
187
  return MODULE_REGISTRY_CONTEXT.getStore() ?? getModuleState().registry;
@@ -206,7 +228,7 @@ export async function loadConvexModule(specifier, options = {}) {
206
228
  catch (error) {
207
229
  // Fallback to built-in system functions when the project hasn't provided its own module.
208
230
  if (error?.message?.includes("Unable to resolve")) {
209
- return getSystemFunctionsModule();
231
+ return await getBuiltInSystemFunctionsModule();
210
232
  }
211
233
  throw error;
212
234
  }
@@ -53,7 +53,7 @@ function udfCryptoRandomUUID(sfc32) {
53
53
  }
54
54
  function udfCryptoGetRandomValues(sfc32) {
55
55
  return (array) => {
56
- const bytes = new Uint8Array(array.buffer);
56
+ const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
57
57
  for (let i = 0; i < bytes.length; i++) {
58
58
  bytes[i] = Math.floor(sfc32() * 256);
59
59
  }