@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.
- package/dist/auth/auth-context.d.ts +4 -15
- package/dist/auth/auth-context.js +6 -15
- package/dist/auth/jwt.d.ts +5 -0
- package/dist/auth/jwt.js +37 -5
- package/dist/docstore/index.d.ts +1 -0
- package/dist/docstore/index.js +1 -0
- package/dist/docstore/interface.d.ts +8 -0
- package/dist/docstore/search-interfaces.d.ts +29 -0
- package/dist/docstore/search-interfaces.js +8 -0
- package/dist/http/api-router.d.ts +2 -0
- package/dist/http/api-router.js +60 -4
- package/dist/http/http-handler.js +19 -4
- package/dist/id-codec/document-id.js +4 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/kernel/context-storage.d.ts +1 -0
- package/dist/kernel/context-storage.js +29 -11
- package/dist/kernel/docstore-gateway.d.ts +31 -6
- package/dist/kernel/docstore-gateway.js +105 -31
- package/dist/kernel/index.d.ts +4 -3
- package/dist/kernel/index.js +4 -3
- package/dist/kernel/missing-schema-error.d.ts +5 -0
- package/dist/kernel/missing-schema-error.js +25 -0
- package/dist/kernel/native-timers.d.ts +7 -0
- package/dist/kernel/native-timers.js +14 -0
- package/dist/kernel/schema-service.d.ts +0 -5
- package/dist/kernel/schema-service.js +1 -25
- package/dist/kernel/udf-kernel.d.ts +11 -1
- package/dist/kernel/udf-kernel.js +10 -10
- package/dist/query/query-runtime.js +6 -3
- package/dist/queryengine/cursor.js +3 -2
- package/dist/queryengine/index.d.ts +2 -2
- package/dist/queryengine/index.js +1 -1
- package/dist/ryow/uncommitted-writes.js +4 -5
- package/dist/scheduler/cron-executor.d.ts +6 -2
- package/dist/scheduler/cron-executor.js +56 -27
- package/dist/scheduler/scheduled-function-executor.d.ts +6 -2
- package/dist/scheduler/scheduled-function-executor.js +61 -42
- package/dist/subscriptions/subscription-manager.d.ts +22 -0
- package/dist/subscriptions/subscription-manager.js +131 -44
- package/dist/sync/protocol-handler.d.ts +30 -0
- package/dist/sync/protocol-handler.js +140 -1
- package/dist/sync/session-backpressure.js +4 -3
- package/dist/sync/session-heartbeat.js +3 -2
- package/dist/system/internal.js +6 -3
- package/dist/system/system-functions.js +1 -1
- package/dist/transactor/occ-transaction.js +9 -8
- package/dist/transactor/occ-validation.js +3 -3
- package/dist/udf/analysis/validator.js +1 -1
- package/dist/udf/execution-adapter.d.ts +1 -1
- package/dist/udf/execution-adapter.js +2 -2
- package/dist/udf/executor/inline.d.ts +2 -2
- package/dist/udf/executor/inline.js +9 -5
- package/dist/udf/executor/interface.d.ts +1 -1
- package/dist/udf/module-loader/call-context.d.ts +5 -6
- package/dist/udf/module-loader/call-context.js +5 -9
- package/dist/udf/module-loader/module-loader.js +26 -4
- package/dist/udf/runtime/udf-rand.js +1 -1
- package/dist/udf/runtime/udf-setup.d.ts +22 -1
- package/dist/udf/runtime/udf-setup.js +151 -55
- package/dist/utils/base64.d.ts +4 -0
- package/dist/utils/base64.js +58 -0
- package/dist/utils/crypto.d.ts +2 -0
- package/dist/utils/crypto.js +40 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/utils.d.ts +6 -1
- package/dist/utils/utils.js +6 -1
- 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
|
}
|
package/dist/system/internal.js
CHANGED
|
@@ -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-
|
|
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
|
|
509
|
-
const
|
|
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-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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 =
|
|
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 {
|
|
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(
|
|
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(
|
|
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-
|
|
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,
|
|
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}:${
|
|
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"
|
|
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
|
|
5
|
-
*
|
|
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 {
|
|
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
|
|
22
|
+
* share the same context storage instance.
|
|
24
23
|
*/
|
|
25
|
-
export declare const 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
|
|
5
|
-
*
|
|
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
|
|
12
|
+
* share the same context storage instance.
|
|
17
13
|
*/
|
|
18
|
-
export const callContext = globalCallContext[CALL_CONTEXT_SYMBOL] ?? new
|
|
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 {
|
|
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
|
|
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
|
|
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
|
}
|