@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.
@@ -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
- await storage.delete(docId.internalId);
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: docId, value: null, prev_ts: latest.ts };
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, docId);
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 { stringToHex } from "../utils/utils";
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
- this.mutationTransaction.recordTableScan(stringToHex(tableName), []);
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
- const indexId = indexKeyspaceId(tableName, indexDescriptor);
63
- this.mutationTransaction.recordIndexRangeScan(indexId, startKey, endKey, []);
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: docId, value: newValue };
69
+ const resolvedDocument = { id: canonicalDocId, value: newValue };
69
70
  const timestamp = this.docStore.allocateTimestamp();
70
- const entry = { ts: timestamp, id: docId, value: resolvedDocument, prev_ts: latest.ts };
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(docId, this.context.tableRegistry);
73
- this.context.recordLocalWrite(id, tableName, resolvedDocument.value, docId);
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: docId, value: null, prev_ts: latest.ts };
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, docId, null, latest.value.value, indexes);
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, docId);
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: docId, value: newValue };
199
+ const resolvedDocument = { id: canonicalDocId, value: newValue };
198
200
  const timestamp = this.docStore.allocateTimestamp();
199
- const entry = { ts: timestamp, id: docId, value: resolvedDocument, prev_ts: latest.ts };
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, docId, resolvedDocument.value, existingValue, indexes);
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, docId);
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: docId, value: newValue };
234
+ const resolvedDocument = { id: canonicalDocId, value: newValue };
232
235
  const timestamp = this.docStore.allocateTimestamp();
233
- const entry = { ts: timestamp, id: docId, value: resolvedDocument, prev_ts: latest.ts };
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, docId, resolvedDocument.value, latest.value.value, indexes);
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, docId);
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 { InternalDocumentId } from "../docstore/interface";
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
- if (!this.docStore.hasTransaction()) {
62
- const { start, end } = rangeExpressionsToIndexBounds(plan.expressions, plan.indexFields);
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.context.recordTableRead(plan.fullTableName);
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 documents = searchResults
123
- .filter(({ doc }) => doc.ts <= this.context.snapshotTimestamp)
124
- .map(({ doc, score }) => ({
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 ?? BigInt(Date.now());
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 ?? BigInt(Date.now());
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 ?? BigInt(Date.now());
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
- BigInt(Date.now()), interval, Order.Asc)) {
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
- const httpModule = await this.loadModule("http");
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
- throw new Error("convex/http.ts must export a default httpRouter()");
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
- // ContextStorage.run returns R (UdfResult here) but in practice the async
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@concavejs/core",
3
- "version": "0.0.1-alpha.8",
3
+ "version": "0.0.1-alpha.9",
4
4
  "license": "FSL-1.1-Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"