@concavejs/core 0.0.1-alpha.8 → 0.0.1-alpha.9
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/docstore/interface.js +6 -0
- package/dist/http/http-handler.js +6 -0
- package/dist/kernel/blob-store-gateway.js +4 -3
- package/dist/kernel/kernel-context.js +5 -5
- package/dist/kernel/scheduler-gateway.js +5 -4
- package/dist/kernel/syscalls/database-syscalls.js +14 -11
- package/dist/query/query-runtime.d.ts +1 -1
- package/dist/query/query-runtime.js +30 -11
- package/dist/queryengine/index-query.d.ts +2 -2
- package/dist/queryengine/index-query.js +2 -1
- package/dist/sync/protocol-handler.d.ts +8 -1
- package/dist/sync/protocol-handler.js +24 -6
- package/dist/sync/sync-handler.js +2 -0
- package/dist/transactor/occ-validation.js +13 -3
- package/dist/udf/executor/inline.js +9 -2
- package/dist/udf/runtime/udf-setup.js +1 -4
- package/package.json +1 -1
|
@@ -18,6 +18,12 @@ export function parseDocumentIdKey(key) {
|
|
|
18
18
|
const internalId = key.substring(colonIndex + 1);
|
|
19
19
|
if (!table || !internalId)
|
|
20
20
|
return null;
|
|
21
|
+
if (table.startsWith("#")) {
|
|
22
|
+
const tableNumber = Number.parseInt(table.slice(1), 10);
|
|
23
|
+
if (Number.isInteger(tableNumber) && tableNumber > 0) {
|
|
24
|
+
return { table, internalId, tableNumber };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
21
27
|
return { table, internalId };
|
|
22
28
|
}
|
|
23
29
|
/**
|
|
@@ -296,6 +296,12 @@ export class HttpHandler {
|
|
|
296
296
|
if (url.pathname === "/health") {
|
|
297
297
|
return apply(Response.json({ status: "ok", runtime: this.runtimeName }));
|
|
298
298
|
}
|
|
299
|
+
// Fallback: forward any unmatched, non-reserved path to the HTTP router.
|
|
300
|
+
// In Convex, HTTP actions are accessible at any path on the deployment URL
|
|
301
|
+
// (e.g. path: "/" matches requests to the root), not just under /api/http/*.
|
|
302
|
+
if (!isReservedApiPath(url.pathname)) {
|
|
303
|
+
return forwardHttpRequest(request, "HTTP action");
|
|
304
|
+
}
|
|
299
305
|
return apply(Response.json({ error: "Not found" }, { status: 404 }));
|
|
300
306
|
}
|
|
301
307
|
}
|
|
@@ -93,11 +93,12 @@ export class BlobStoreGateway {
|
|
|
93
93
|
if (!latest || latest.ts > this.context.snapshotTimestamp) {
|
|
94
94
|
return;
|
|
95
95
|
}
|
|
96
|
-
|
|
96
|
+
const canonicalDocId = latest.value.id;
|
|
97
|
+
await storage.delete(canonicalDocId.internalId);
|
|
97
98
|
const timestamp = this.docStore.allocateTimestamp();
|
|
98
|
-
const entry = { ts: timestamp, id:
|
|
99
|
+
const entry = { ts: timestamp, id: canonicalDocId, value: null, prev_ts: latest.ts };
|
|
99
100
|
await this.docStore.applyWrites([entry], new Set(), "Error");
|
|
100
|
-
this.context.recordLocalWrite(storageId, "_storage", null,
|
|
101
|
+
this.context.recordLocalWrite(storageId, "_storage", null, canonicalDocId);
|
|
101
102
|
}
|
|
102
103
|
requireStorage() {
|
|
103
104
|
if (!this.storage) {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { serializeKeyRange } from "../queryengine/indexing/read-write-set";
|
|
2
2
|
import { AccessLog } from "./access-log";
|
|
3
|
-
import {
|
|
4
|
-
import { decodeIndexId, indexKeyspaceId } from "../utils/keyspace";
|
|
3
|
+
import { decodeIndexId } from "../utils/keyspace";
|
|
5
4
|
import { parseDeveloperId } from "../queryengine/developer-id";
|
|
6
5
|
import { getGlobalTableRegistry } from "../tables/memory-table-registry";
|
|
7
6
|
/**
|
|
@@ -46,7 +45,8 @@ export class KernelContext {
|
|
|
46
45
|
}
|
|
47
46
|
recordTableRead(tableName) {
|
|
48
47
|
if (this.mutationTransaction) {
|
|
49
|
-
|
|
48
|
+
// Transactional reads must be recorded with concrete documents by the
|
|
49
|
+
// transaction-aware read path (for example scanTable/index query handlers).
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
52
|
this.readLog.addTableScan(tableName);
|
|
@@ -59,8 +59,8 @@ export class KernelContext {
|
|
|
59
59
|
}
|
|
60
60
|
recordIndexRange(tableName, indexDescriptor, startKey, endKey) {
|
|
61
61
|
if (this.mutationTransaction) {
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
// Transactional reads must be recorded with concrete documents by the
|
|
63
|
+
// transaction-aware query runtime path.
|
|
64
64
|
return;
|
|
65
65
|
}
|
|
66
66
|
this.readLog.addIndexRange(tableName, indexDescriptor, startKey, endKey);
|
|
@@ -61,15 +61,16 @@ export class SchedulerGateway {
|
|
|
61
61
|
if (!latest) {
|
|
62
62
|
throw new Error(`Scheduled job with id ${id} not found.`);
|
|
63
63
|
}
|
|
64
|
+
const canonicalDocId = latest.value.id;
|
|
64
65
|
const newValue = {
|
|
65
66
|
...latest.value.value,
|
|
66
67
|
state: state ?? { kind: "canceled" },
|
|
67
68
|
};
|
|
68
|
-
const resolvedDocument = { id:
|
|
69
|
+
const resolvedDocument = { id: canonicalDocId, value: newValue };
|
|
69
70
|
const timestamp = this.docStore.allocateTimestamp();
|
|
70
|
-
const entry = { ts: timestamp, id:
|
|
71
|
+
const entry = { ts: timestamp, id: canonicalDocId, value: resolvedDocument, prev_ts: latest.ts };
|
|
71
72
|
await this.docStore.applyWrites([entry], new Set(), "Error");
|
|
72
|
-
const tableName = await resolveTableName(
|
|
73
|
-
this.context.recordLocalWrite(id, tableName, resolvedDocument.value,
|
|
73
|
+
const tableName = await resolveTableName(canonicalDocId, this.context.tableRegistry);
|
|
74
|
+
this.context.recordLocalWrite(id, tableName, resolvedDocument.value, canonicalDocId);
|
|
74
75
|
}
|
|
75
76
|
}
|
|
@@ -131,13 +131,14 @@ export class DatabaseSyscalls {
|
|
|
131
131
|
if (!latest) {
|
|
132
132
|
throw new Error(`Document with id ${id} not found.`);
|
|
133
133
|
}
|
|
134
|
+
const canonicalDocId = latest.value.id;
|
|
134
135
|
const timestamp = this.docStore.allocateTimestamp();
|
|
135
|
-
const entry = { ts: timestamp, id:
|
|
136
|
+
const entry = { ts: timestamp, id: canonicalDocId, value: null, prev_ts: latest.ts };
|
|
136
137
|
const indexes = await this.schemaService.getAllIndexesForTable(bareTableName);
|
|
137
|
-
const indexUpdates = generateIndexUpdates(fullTableName,
|
|
138
|
+
const indexUpdates = generateIndexUpdates(fullTableName, canonicalDocId, null, latest.value.value, indexes);
|
|
138
139
|
const indexEntries = new Set(indexUpdates.map((update) => ({ ts: timestamp, update })));
|
|
139
140
|
await this.docStore.applyWrites([entry], indexEntries, "Error");
|
|
140
|
-
this.context.recordLocalWrite(id, fullTableName, null,
|
|
141
|
+
this.context.recordLocalWrite(id, fullTableName, null, canonicalDocId);
|
|
141
142
|
return {};
|
|
142
143
|
}
|
|
143
144
|
async handleShallowMerge(args) {
|
|
@@ -165,6 +166,7 @@ export class DatabaseSyscalls {
|
|
|
165
166
|
if (!latest) {
|
|
166
167
|
throw new Error(`Document with id ${id} not found.`);
|
|
167
168
|
}
|
|
169
|
+
const canonicalDocId = latest.value.id;
|
|
168
170
|
const existingValue = latest.value.value;
|
|
169
171
|
const newValue = { ...existingValue };
|
|
170
172
|
if (typeof value === "object" && value !== null && "$undefined" in value) {
|
|
@@ -194,14 +196,14 @@ export class DatabaseSyscalls {
|
|
|
194
196
|
}
|
|
195
197
|
}
|
|
196
198
|
await this.schemaService.validate(bareTableName, newValue);
|
|
197
|
-
const resolvedDocument = { id:
|
|
199
|
+
const resolvedDocument = { id: canonicalDocId, value: newValue };
|
|
198
200
|
const timestamp = this.docStore.allocateTimestamp();
|
|
199
|
-
const entry = { ts: timestamp, id:
|
|
201
|
+
const entry = { ts: timestamp, id: canonicalDocId, value: resolvedDocument, prev_ts: latest.ts };
|
|
200
202
|
const indexes = await this.schemaService.getAllIndexesForTable(bareTableName);
|
|
201
|
-
const indexUpdates = generateIndexUpdates(fullTableName,
|
|
203
|
+
const indexUpdates = generateIndexUpdates(fullTableName, canonicalDocId, resolvedDocument.value, existingValue, indexes);
|
|
202
204
|
const indexEntries = new Set(indexUpdates.map((update) => ({ ts: timestamp, update })));
|
|
203
205
|
await this.docStore.applyWrites([entry], indexEntries, "Error");
|
|
204
|
-
this.context.recordLocalWrite(id, fullTableName, resolvedDocument.value,
|
|
206
|
+
this.context.recordLocalWrite(id, fullTableName, resolvedDocument.value, canonicalDocId);
|
|
205
207
|
return {};
|
|
206
208
|
}
|
|
207
209
|
async handleReplace(args) {
|
|
@@ -225,17 +227,18 @@ export class DatabaseSyscalls {
|
|
|
225
227
|
if (!latest) {
|
|
226
228
|
throw new Error(`Document with id ${id} not found.`);
|
|
227
229
|
}
|
|
230
|
+
const canonicalDocId = latest.value.id;
|
|
228
231
|
const { _id, _creationTime } = latest.value.value;
|
|
229
232
|
const newValue = { ...replaceValue, _id, _creationTime };
|
|
230
233
|
await this.schemaService.validate(bareTableName, newValue);
|
|
231
|
-
const resolvedDocument = { id:
|
|
234
|
+
const resolvedDocument = { id: canonicalDocId, value: newValue };
|
|
232
235
|
const timestamp = this.docStore.allocateTimestamp();
|
|
233
|
-
const entry = { ts: timestamp, id:
|
|
236
|
+
const entry = { ts: timestamp, id: canonicalDocId, value: resolvedDocument, prev_ts: latest.ts };
|
|
234
237
|
const indexes = await this.schemaService.getAllIndexesForTable(bareTableName);
|
|
235
|
-
const indexUpdates = generateIndexUpdates(fullTableName,
|
|
238
|
+
const indexUpdates = generateIndexUpdates(fullTableName, canonicalDocId, resolvedDocument.value, latest.value.value, indexes);
|
|
236
239
|
const indexEntries = new Set(indexUpdates.map((update) => ({ ts: timestamp, update })));
|
|
237
240
|
await this.docStore.applyWrites([entry], indexEntries, "Error");
|
|
238
|
-
this.context.recordLocalWrite(id, fullTableName, newValue,
|
|
241
|
+
this.context.recordLocalWrite(id, fullTableName, newValue, canonicalDocId);
|
|
239
242
|
return {};
|
|
240
243
|
}
|
|
241
244
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { KernelContext } from "../kernel/kernel-context";
|
|
2
2
|
import type { DocStoreGateway } from "../kernel/docstore-gateway";
|
|
3
3
|
import type { SchemaService } from "../kernel/schema-service";
|
|
4
|
-
import type
|
|
4
|
+
import { type InternalDocumentId } from "../docstore/interface";
|
|
5
5
|
import type { PaginatedResult, SearchActionQuery, VectorSearchActionQuery } from "./types";
|
|
6
6
|
export type { SerializedRangeExpression, SerializedSearchFilter } from "./types";
|
|
7
7
|
export declare class QueryRuntime {
|
|
@@ -3,7 +3,9 @@ import { UncommittedWrites } from "../ryow/uncommitted-writes";
|
|
|
3
3
|
import { stringToHex } from "../utils/utils";
|
|
4
4
|
import { evaluateFilter } from "../queryengine/filters";
|
|
5
5
|
import { executeIndexQuery, rangeExpressionsToIndexBounds, evaluateRangeExpression, } from "../queryengine/index-query";
|
|
6
|
+
import { documentIdKey } from "../docstore/interface";
|
|
6
7
|
import { isSearchCapable } from "../docstore/search-interfaces";
|
|
8
|
+
import { indexKeyspaceId } from "../utils/keyspace";
|
|
7
9
|
import { buildQueryPlan } from "./planner";
|
|
8
10
|
import { paginateByCursor, sortByCreationTimeAndId, sortByIndexFields } from "./execution";
|
|
9
11
|
import { applyQueryOperators } from "./postprocess";
|
|
@@ -58,13 +60,27 @@ export class QueryRuntime {
|
|
|
58
60
|
return paginateByCursor(sortedResults, cursor, limit);
|
|
59
61
|
}
|
|
60
62
|
async handleIndexRange(plan, cursor, limit) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
const { start, end } = rangeExpressionsToIndexBounds(plan.expressions, plan.indexFields);
|
|
64
|
+
const mutationTransaction = this.docStore.getTransaction();
|
|
65
|
+
if (!mutationTransaction) {
|
|
63
66
|
this.context.recordIndexRange(plan.fullTableName, plan.indexDescriptor, start, end);
|
|
64
67
|
}
|
|
68
|
+
const observedDocuments = mutationTransaction ? new Map() : null;
|
|
65
69
|
let paginationResult;
|
|
66
70
|
try {
|
|
67
|
-
const indexResult = await executeIndexQuery(this.docStore.getDocStore(), plan.fullTableName, plan.indexDescriptor, plan.indexFields, plan.expressions, plan.order, limit, cursor, this.context.snapshotTimestamp)
|
|
71
|
+
const indexResult = await executeIndexQuery(this.docStore.getDocStore(), plan.fullTableName, plan.indexDescriptor, plan.indexFields, plan.expressions, plan.order, limit, cursor, this.context.snapshotTimestamp, (latestDoc) => {
|
|
72
|
+
if (!observedDocuments) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
observedDocuments.set(documentIdKey(latestDoc.value.id), latestDoc);
|
|
76
|
+
});
|
|
77
|
+
if (mutationTransaction && observedDocuments) {
|
|
78
|
+
const observed = Array.from(observedDocuments.values());
|
|
79
|
+
mutationTransaction.recordIndexRangeScan(indexKeyspaceId(plan.fullTableName, plan.indexDescriptor), start, end, observed);
|
|
80
|
+
for (const doc of observed) {
|
|
81
|
+
mutationTransaction.recordDocumentRead(doc.value.id, doc);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
68
84
|
const uncommittedWrites = UncommittedWrites.fromContext(this.docStore.getTransaction(), this.context.getLocalWrites());
|
|
69
85
|
if (uncommittedWrites) {
|
|
70
86
|
const tableId = stringToHex(plan.fullTableName);
|
|
@@ -99,12 +115,13 @@ export class QueryRuntime {
|
|
|
99
115
|
const sortedResults = sortByIndexFields(filteredResults, plan.indexFields, plan.order);
|
|
100
116
|
paginationResult = paginateByCursor(sortedResults, cursor, limit);
|
|
101
117
|
}
|
|
102
|
-
// Index range already recorded above - no need for individual document reads
|
|
103
|
-
// The index range covers all matching documents
|
|
104
118
|
return paginationResult;
|
|
105
119
|
}
|
|
106
120
|
async handleSearch(plan, _cursor, limit) {
|
|
107
|
-
this.
|
|
121
|
+
const mutationTransaction = this.docStore.getTransaction();
|
|
122
|
+
if (!mutationTransaction) {
|
|
123
|
+
this.context.recordTableRead(plan.fullTableName);
|
|
124
|
+
}
|
|
108
125
|
const limitOperator = plan.query.operators?.find((operator) => "limit" in operator);
|
|
109
126
|
const operatorLimit = limitOperator?.limit;
|
|
110
127
|
const combinedLimit = typeof limit === "number" && limit > 0
|
|
@@ -119,14 +136,16 @@ export class QueryRuntime {
|
|
|
119
136
|
throw new Error("DocStore does not support full-text search");
|
|
120
137
|
}
|
|
121
138
|
const searchResults = await rawDocStore.search(plan.indexIdHex, plan.searchTerm, plan.filterMap, { limit: combinedLimit });
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
139
|
+
const visibleResults = searchResults.filter(({ doc }) => doc.ts <= this.context.snapshotTimestamp);
|
|
140
|
+
if (mutationTransaction) {
|
|
141
|
+
for (const { doc } of visibleResults) {
|
|
142
|
+
mutationTransaction.recordDocumentRead(doc.value.id, doc);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const documents = visibleResults.map(({ doc, score }) => ({
|
|
125
146
|
...doc.value.value,
|
|
126
147
|
_score: score,
|
|
127
148
|
}));
|
|
128
|
-
// Table read already recorded above - no need for individual document reads
|
|
129
|
-
// The full table range covers all search results
|
|
130
149
|
return {
|
|
131
150
|
documents,
|
|
132
151
|
nextCursor: null,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Provides efficient query execution using database indexes.
|
|
5
5
|
* Handles range queries, equality filters, and ordered scans.
|
|
6
6
|
*/
|
|
7
|
-
import { type DocStore } from "../docstore/interface";
|
|
7
|
+
import { type DocStore, type LatestDocument } from "../docstore/interface";
|
|
8
8
|
import type { Value } from "convex/values";
|
|
9
9
|
/**
|
|
10
10
|
* Range expression for index queries (from Convex wire protocol)
|
|
@@ -45,7 +45,7 @@ export declare function rangeExpressionsToIndexBounds(expressions: RangeExpressi
|
|
|
45
45
|
* @param cursor Pagination cursor (serialized index position)
|
|
46
46
|
* @returns Query results with pagination
|
|
47
47
|
*/
|
|
48
|
-
export declare function executeIndexQuery(docstore: DocStore, tableName: string, indexName: string, indexFields: string[], expressions: RangeExpression[], order: "asc" | "desc", limit: number | null, cursor: string | null, snapshotTimestamp: bigint): Promise<IndexQueryResult>;
|
|
48
|
+
export declare function executeIndexQuery(docstore: DocStore, tableName: string, indexName: string, indexFields: string[], expressions: RangeExpression[], order: "asc" | "desc", limit: number | null, cursor: string | null, snapshotTimestamp: bigint, onDocumentRead?: (document: LatestDocument) => void): Promise<IndexQueryResult>;
|
|
49
49
|
/**
|
|
50
50
|
* Evaluate range expressions against a document (fallback for non-indexed filters).
|
|
51
51
|
*/
|
|
@@ -100,7 +100,7 @@ export function rangeExpressionsToIndexBounds(expressions, indexFields) {
|
|
|
100
100
|
* @param cursor Pagination cursor (serialized index position)
|
|
101
101
|
* @returns Query results with pagination
|
|
102
102
|
*/
|
|
103
|
-
export async function executeIndexQuery(docstore, tableName, indexName, indexFields, expressions, order, limit, cursor, snapshotTimestamp) {
|
|
103
|
+
export async function executeIndexQuery(docstore, tableName, indexName, indexFields, expressions, order, limit, cursor, snapshotTimestamp, onDocumentRead) {
|
|
104
104
|
const tableId = stringToHex(tableName);
|
|
105
105
|
const indexId = stringToHex(`${tableName}:${indexName}`);
|
|
106
106
|
const indexOrder = order === "asc" ? Order.Asc : Order.Desc;
|
|
@@ -131,6 +131,7 @@ export async function executeIndexQuery(docstore, tableName, indexName, indexFie
|
|
|
131
131
|
try {
|
|
132
132
|
const generator = docstore.index_scan(indexId, tableId, readTimestamp, interval, indexOrder);
|
|
133
133
|
for await (const [indexKey, document] of generator) {
|
|
134
|
+
onDocumentRead?.(document);
|
|
134
135
|
const doc = document.value.value;
|
|
135
136
|
// Skip until we're past the cursor position
|
|
136
137
|
if (skipping) {
|
|
@@ -33,6 +33,8 @@ export interface SyncUdfExecutor {
|
|
|
33
33
|
logLines?: string[];
|
|
34
34
|
/** The commit timestamp of this mutation (for OCC-aware subscriptions) */
|
|
35
35
|
commitTimestamp?: bigint;
|
|
36
|
+
/** Snapshot timestamp observed during execution (fallback when commit timestamp is unavailable) */
|
|
37
|
+
snapshotTimestamp?: bigint;
|
|
36
38
|
}>;
|
|
37
39
|
executeAction(path: string, args: Record<string, any>, auth: AuthContext, componentPath?: string): Promise<{
|
|
38
40
|
result: JSONValue;
|
|
@@ -41,6 +43,8 @@ export interface SyncUdfExecutor {
|
|
|
41
43
|
logLines?: string[];
|
|
42
44
|
/** The commit timestamp of this action's writes (for OCC-aware subscriptions) */
|
|
43
45
|
commitTimestamp?: bigint;
|
|
46
|
+
/** Snapshot timestamp observed during execution (fallback when commit timestamp is unavailable) */
|
|
47
|
+
snapshotTimestamp?: bigint;
|
|
44
48
|
}>;
|
|
45
49
|
}
|
|
46
50
|
/**
|
|
@@ -129,7 +133,9 @@ export declare class SyncProtocolHandler {
|
|
|
129
133
|
private readonly rateLimitWindowMs;
|
|
130
134
|
private readonly operationTimeoutMs;
|
|
131
135
|
private readonly maxActiveQueriesPerSession;
|
|
136
|
+
private lastObservedWriteTimestamp;
|
|
132
137
|
constructor(instanceName: string, udfExecutor: SyncUdfExecutor, options?: SyncProtocolOptions);
|
|
138
|
+
private resolveWriteTimestamp;
|
|
133
139
|
/**
|
|
134
140
|
* Create a new client session
|
|
135
141
|
*/
|
|
@@ -156,8 +162,9 @@ export declare class SyncProtocolHandler {
|
|
|
156
162
|
* @param writtenRanges - The serialized ranges that were written
|
|
157
163
|
* @param writtenTables - The tables that were written (alternative to writtenRanges)
|
|
158
164
|
* @param commitTimestamp - The commit timestamp of the write (for OCC-aware subscriptions)
|
|
165
|
+
* @param snapshotTimestamp - Snapshot timestamp used as a fallback ordering signal
|
|
159
166
|
*/
|
|
160
|
-
notifyWrites(writtenRanges?: SerializedKeyRange[], writtenTables?: string[], commitTimestamp?: bigint): Promise<void>;
|
|
167
|
+
notifyWrites(writtenRanges?: SerializedKeyRange[], writtenTables?: string[], commitTimestamp?: bigint, snapshotTimestamp?: bigint): Promise<void>;
|
|
161
168
|
private handleConnect;
|
|
162
169
|
private handleModifyQuerySet;
|
|
163
170
|
private handleMutation;
|
|
@@ -112,6 +112,7 @@ export class SyncProtocolHandler {
|
|
|
112
112
|
rateLimitWindowMs;
|
|
113
113
|
operationTimeoutMs;
|
|
114
114
|
maxActiveQueriesPerSession;
|
|
115
|
+
lastObservedWriteTimestamp = 0n;
|
|
115
116
|
constructor(instanceName, udfExecutor, options) {
|
|
116
117
|
this.udfExecutor = udfExecutor;
|
|
117
118
|
this.instanceName = instanceName;
|
|
@@ -137,6 +138,22 @@ export class SyncProtocolHandler {
|
|
|
137
138
|
onPing: (session) => this.sendPing(session),
|
|
138
139
|
});
|
|
139
140
|
}
|
|
141
|
+
resolveWriteTimestamp(commitTimestamp, snapshotTimestamp) {
|
|
142
|
+
const wallClock = BigInt(Date.now());
|
|
143
|
+
const monotonicFloor = this.lastObservedWriteTimestamp + 1n;
|
|
144
|
+
let resolved = wallClock;
|
|
145
|
+
if (commitTimestamp !== undefined && commitTimestamp > resolved) {
|
|
146
|
+
resolved = commitTimestamp;
|
|
147
|
+
}
|
|
148
|
+
if (snapshotTimestamp !== undefined && snapshotTimestamp > resolved) {
|
|
149
|
+
resolved = snapshotTimestamp;
|
|
150
|
+
}
|
|
151
|
+
if (monotonicFloor > resolved) {
|
|
152
|
+
resolved = monotonicFloor;
|
|
153
|
+
}
|
|
154
|
+
this.lastObservedWriteTimestamp = resolved;
|
|
155
|
+
return resolved;
|
|
156
|
+
}
|
|
140
157
|
/**
|
|
141
158
|
* Create a new client session
|
|
142
159
|
*/
|
|
@@ -246,15 +263,16 @@ export class SyncProtocolHandler {
|
|
|
246
263
|
* @param writtenRanges - The serialized ranges that were written
|
|
247
264
|
* @param writtenTables - The tables that were written (alternative to writtenRanges)
|
|
248
265
|
* @param commitTimestamp - The commit timestamp of the write (for OCC-aware subscriptions)
|
|
266
|
+
* @param snapshotTimestamp - Snapshot timestamp used as a fallback ordering signal
|
|
249
267
|
*/
|
|
250
|
-
async notifyWrites(writtenRanges, writtenTables, commitTimestamp) {
|
|
268
|
+
async notifyWrites(writtenRanges, writtenTables, commitTimestamp, snapshotTimestamp) {
|
|
251
269
|
const ranges = writtenRanges
|
|
252
270
|
? writtenRanges.map(deserializeKeyRange)
|
|
253
271
|
: convertTablesToRanges(writtenTables);
|
|
254
272
|
this.logRanges("/notify", ranges, { writtenTables });
|
|
255
273
|
if (ranges.length > 0) {
|
|
256
274
|
// Record the write timestamp for OCC-aware subscription registration
|
|
257
|
-
const ts = commitTimestamp
|
|
275
|
+
const ts = this.resolveWriteTimestamp(commitTimestamp, snapshotTimestamp);
|
|
258
276
|
this.subscriptionManager.recordWrites(ranges, ts);
|
|
259
277
|
await this.broadcastUpdates(ranges);
|
|
260
278
|
}
|
|
@@ -366,8 +384,8 @@ export class SyncProtocolHandler {
|
|
|
366
384
|
if (message.componentPath && session.auth.tokenType !== "Admin" && session.auth.tokenType !== "System") {
|
|
367
385
|
throw new Error("Only admin or system auth can execute component functions");
|
|
368
386
|
}
|
|
369
|
-
const { result, writtenRanges, writtenTables, commitTimestamp, logLines } = await this.udfExecutor.executeMutation(message.udfPath, (message.args[0] ?? {}), session.auth, message.componentPath);
|
|
370
|
-
const now = commitTimestamp
|
|
387
|
+
const { result, writtenRanges, writtenTables, commitTimestamp, snapshotTimestamp, logLines } = await this.udfExecutor.executeMutation(message.udfPath, (message.args[0] ?? {}), session.auth, message.componentPath);
|
|
388
|
+
const now = this.resolveWriteTimestamp(commitTimestamp, snapshotTimestamp);
|
|
371
389
|
const successResponse = {
|
|
372
390
|
type: "MutationResponse",
|
|
373
391
|
requestId: message.requestId,
|
|
@@ -421,8 +439,8 @@ export class SyncProtocolHandler {
|
|
|
421
439
|
if (message.componentPath && session.auth.tokenType !== "Admin" && session.auth.tokenType !== "System") {
|
|
422
440
|
throw new Error("Only admin or system auth can mutate component functions");
|
|
423
441
|
}
|
|
424
|
-
const { result, writtenRanges, writtenTables, commitTimestamp, logLines } = await this.udfExecutor.executeAction(message.udfPath, (message.args[0] ?? {}), session.auth, message.componentPath);
|
|
425
|
-
const now = commitTimestamp
|
|
442
|
+
const { result, writtenRanges, writtenTables, commitTimestamp, snapshotTimestamp, logLines } = await this.udfExecutor.executeAction(message.udfPath, (message.args[0] ?? {}), session.auth, message.componentPath);
|
|
443
|
+
const now = this.resolveWriteTimestamp(commitTimestamp, snapshotTimestamp);
|
|
426
444
|
const successResponse = {
|
|
427
445
|
type: "ActionResponse",
|
|
428
446
|
requestId: message.requestId,
|
|
@@ -48,6 +48,7 @@ export function createSyncUdfExecutor(adapter) {
|
|
|
48
48
|
writtenTables: writtenTablesFromRanges(result.writtenRanges),
|
|
49
49
|
logLines: result.logLines,
|
|
50
50
|
commitTimestamp: result.commitTimestamp,
|
|
51
|
+
snapshotTimestamp: result.snapshotTimestamp,
|
|
51
52
|
};
|
|
52
53
|
},
|
|
53
54
|
executeAction: async (path, args, auth, componentPath) => {
|
|
@@ -67,6 +68,7 @@ export function createSyncUdfExecutor(adapter) {
|
|
|
67
68
|
writtenTables: writtenTablesFromRanges(result.writtenRanges),
|
|
68
69
|
logLines: result.logLines,
|
|
69
70
|
commitTimestamp: result.commitTimestamp,
|
|
71
|
+
snapshotTimestamp: result.snapshotTimestamp,
|
|
70
72
|
};
|
|
71
73
|
},
|
|
72
74
|
};
|
|
@@ -12,7 +12,7 @@ export async function validateReadSetForCommit(docstore, readChecks, writtenDocK
|
|
|
12
12
|
await validateTableScanRead(docstore, readEntry.tableId, readEntry.documentIds, writtenDocKeys);
|
|
13
13
|
continue;
|
|
14
14
|
}
|
|
15
|
-
await validateIndexRangeRead(docstore, readEntry.indexId, readEntry.startKey, readEntry.endKey, readEntry.documentIds, writtenDocKeys);
|
|
15
|
+
await validateIndexRangeRead(docstore, readEntry.indexId, readEntry.startKey, readEntry.endKey, readEntry.readTimestamp, readEntry.documentIds, writtenDocKeys);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
async function validateDocumentRead(docstore, key, readVersion, writtenDocKeys) {
|
|
@@ -56,12 +56,13 @@ async function validateTableScanRead(docstore, tableId, readDocumentIds, written
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
-
async function validateIndexRangeRead(docstore, indexId, startKey, endKey, readDocumentIds, writtenDocKeys) {
|
|
59
|
+
async function validateIndexRangeRead(docstore, indexId, startKey, endKey, readTimestamp, readDocumentIds, writtenDocKeys) {
|
|
60
60
|
const { table } = decodeIndexId(indexId);
|
|
61
61
|
const interval = { start: startKey, end: endKey };
|
|
62
|
+
const validationReadTimestamp = resolveValidationReadTimestamp(docstore, readTimestamp);
|
|
62
63
|
const currentDocIds = new Set();
|
|
63
64
|
for await (const [, doc] of docstore.index_scan(indexId, table, // table as hex-encoded tableId
|
|
64
|
-
|
|
65
|
+
validationReadTimestamp, interval, Order.Asc)) {
|
|
65
66
|
currentDocIds.add(documentIdKey(doc.value.id));
|
|
66
67
|
}
|
|
67
68
|
for (const docId of currentDocIds) {
|
|
@@ -75,3 +76,12 @@ async function validateIndexRangeRead(docstore, indexId, startKey, endKey, readD
|
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
}
|
|
79
|
+
function resolveValidationReadTimestamp(docstore, minimumTimestamp) {
|
|
80
|
+
const maybeDocStore = docstore;
|
|
81
|
+
const oracleTimestamp = maybeDocStore.timestampOracle?.getCurrentTimestamp?.();
|
|
82
|
+
const wallClockTimestamp = BigInt(Date.now());
|
|
83
|
+
const latestKnownTimestamp = oracleTimestamp !== undefined && oracleTimestamp > wallClockTimestamp
|
|
84
|
+
? oracleTimestamp
|
|
85
|
+
: wallClockTimestamp;
|
|
86
|
+
return latestKnownTimestamp > minimumTimestamp ? latestKnownTimestamp : minimumTimestamp;
|
|
87
|
+
}
|
|
@@ -110,10 +110,17 @@ export class InlineUdfExecutor {
|
|
|
110
110
|
async executeHttp(request, auth, requestId) {
|
|
111
111
|
const url = new URL(request.url);
|
|
112
112
|
const runHttpUdf = async () => {
|
|
113
|
-
|
|
113
|
+
let httpModule;
|
|
114
|
+
try {
|
|
115
|
+
httpModule = await this.loadModule("http");
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// No convex/http.ts module — no HTTP routes available
|
|
119
|
+
return new Response("Not found", { status: 404 });
|
|
120
|
+
}
|
|
114
121
|
const router = httpModule?.default;
|
|
115
122
|
if (!router?.isRouter || typeof router.lookup !== "function") {
|
|
116
|
-
|
|
123
|
+
return new Response("Not found", { status: 404 });
|
|
117
124
|
}
|
|
118
125
|
const match = router.lookup(url.pathname, request.method);
|
|
119
126
|
if (!match) {
|
|
@@ -377,10 +377,7 @@ export function runUdfMutation(docstore, fn, auth, storage, requestId, udfExecut
|
|
|
377
377
|
// execute within the same transaction scope
|
|
378
378
|
const existingIdGenerator = idGeneratorContext.getStore();
|
|
379
379
|
const idGenerator = existingIdGenerator ?? createDeterministicIdGenerator(seed);
|
|
380
|
-
|
|
381
|
-
// callback produces a Promise<UdfResult>. We need to attach a .catch for
|
|
382
|
-
// savepoint rollback, so cast to the runtime-accurate Promise type.
|
|
383
|
-
const nestedResult = transactionContext.run(parentTransaction, () => idGeneratorContext.run(idGenerator, () => runUdfAndGetLogs(docstore, fn, ops, auth, "mutation", storage, seed, parentTransaction, udfExecutor, componentPath)));
|
|
380
|
+
const nestedResult = Promise.resolve(transactionContext.run(parentTransaction, () => idGeneratorContext.run(idGenerator, () => runUdfAndGetLogs(docstore, fn, ops, auth, "mutation", storage, seed, parentTransaction, udfExecutor, componentPath))));
|
|
384
381
|
return nestedResult.catch((error) => {
|
|
385
382
|
parentTransaction.restoreSavepoint(savepoint);
|
|
386
383
|
throw error;
|