@cavelang/query 0.34.0 → 0.36.0

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/src/bounded.ts CHANGED
@@ -2,6 +2,8 @@ import { Value } from '@cavelang/core'
2
2
  import type { Store } from '@cavelang/store/adapter'
3
3
  import * as Compile from './compile.ts'
4
4
  import * as Pattern from './pattern.ts'
5
+ import { captureOptions, capturePattern } from './capture.ts'
6
+ import { readSnapshot } from './read-snapshot.ts'
5
7
 
6
8
  export type { Match, Options } from './compile.ts'
7
9
  export type { Window } from './compile.ts'
@@ -29,14 +31,26 @@ const storeAtBoundary = (store: Store, options: Compile.Options): Store => {
29
31
  const execute = (
30
32
  store: Store,
31
33
  pattern: Pattern.t,
32
- options: Compile.Options = {}
34
+ options: Compile.Options = {},
35
+ positions?: WeakMap<object, number>
36
+ ): Compile.Window => {
37
+ // Use the same caller values for historical vocabulary, SQL and post-filters.
38
+ options = captureOptions(options)
39
+ return readSnapshot(store, () => executeCaptured(store, pattern, options, positions))
40
+ }
41
+
42
+ const executeCaptured = (
43
+ store: Store,
44
+ pattern: Pattern.t,
45
+ options: Compile.Options,
46
+ positions?: WeakMap<object, number>
33
47
  ): Compile.Window => {
34
48
  if (pattern.payload.kind !== 'attribute' || pattern.payload.value.kind !== 'term') {
35
- return Compile.window(storeAtBoundary(store, options), pattern, options)
49
+ return Compile.window(storeAtBoundary(store, options), pattern, options, positions)
36
50
  }
37
51
  const value = Value.parse(pattern.payload.value.text)
38
52
  if (value.kind !== 'number' || value.num === undefined) {
39
- return Compile.window(storeAtBoundary(store, options), pattern, options)
53
+ return Compile.window(storeAtBoundary(store, options), pattern, options, positions)
40
54
  }
41
55
  const expectedNum = value.num
42
56
  const filter: Pattern.Filter = {
@@ -54,7 +68,7 @@ const execute = (
54
68
  }
55
69
  const expectedUnit = value.unit ?? null
56
70
  const expectedApprox = value.approx ? 1 : 0
57
- const result = Compile.window(storeAtBoundary(store, options), normalized, options)
71
+ const result = Compile.window(storeAtBoundary(store, options), normalized, options, positions)
58
72
  return { ...result, matches: result.matches.filter(match => {
59
73
  const row = match.row
60
74
  return row !== undefined &&
@@ -67,18 +81,19 @@ const execute = (
67
81
  export const window = (
68
82
  store: Store,
69
83
  pattern: Pattern.t,
70
- options: Compile.Options = {}
71
- ): Compile.Window => execute(store, pattern, options)
84
+ options: Compile.Options = {},
85
+ positions?: WeakMap<object, number>
86
+ ): Compile.Window => execute(store, pattern, options, positions)
72
87
 
73
88
  export const match = (
74
89
  store: Store,
75
90
  pattern: Pattern.t,
76
91
  options: Compile.Options = {}
77
- ): Compile.Match[] => execute(store, pattern, options).matches
92
+ ): Compile.Match[] => execute(store, capturePattern(pattern), options).matches
78
93
 
79
94
  export const query = (
80
95
  store: Store,
81
96
  input: string,
82
97
  options: Compile.Options = {}
83
98
  ): Compile.Match[] =>
84
- match(store, Pattern.parse(input), options)
99
+ execute(store, Pattern.parse(input), options).matches
package/src/capture.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { Options } from './compile.ts'
2
+ import type * as Pattern from './pattern.ts'
3
+
4
+ const slot = (value: Pattern.Slot): Pattern.Slot => {
5
+ switch (value.kind) {
6
+ case 'wildcard': return { kind: 'wildcard' }
7
+ case 'var': return { kind: 'var', name: value.name }
8
+ case 'term': return { kind: 'term', text: value.text }
9
+ }
10
+ }
11
+
12
+ const verb = (value: Pattern.VerbSlot): Pattern.VerbSlot => {
13
+ switch (value.kind) {
14
+ case 'wildcard': return { kind: 'wildcard' }
15
+ case 'var': return { kind: 'var', name: value.name }
16
+ case 'verb': return { kind: 'verb', name: value.name, transitive: value.transitive }
17
+ }
18
+ }
19
+
20
+ const payload = (value: Pattern.PayloadPattern): Pattern.PayloadPattern => {
21
+ switch (value.kind) {
22
+ case 'any': return { kind: 'any' }
23
+ case 'object': return { kind: 'object', object: slot(value.object) }
24
+ case 'attribute': return { kind: 'attribute', attribute: value.attribute, value: slot(value.value) }
25
+ }
26
+ }
27
+
28
+ const filter = (value: Pattern.Filter): Pattern.Filter => {
29
+ switch (value.field) {
30
+ case 'conf': return { field: 'conf', op: value.op, value: value.value }
31
+ case 'tag': return { field: 'tag', op: value.op, key: value.key, value: value.value }
32
+ case 'context': return { field: 'context', op: value.op, value: value.value }
33
+ case 'value': return { field: 'value', op: value.op, value: value.value, unit: value.unit }
34
+ case 'tx': return { field: 'tx', op: value.op, value: value.value }
35
+ }
36
+ }
37
+
38
+ /** Capture caller-owned AST fields before numeric detection or SQL compilation. */
39
+ export const capturePattern = (value: Pattern.t): Pattern.t => ({
40
+ subject: slot(value.subject),
41
+ verb: verb(value.verb),
42
+ payload: payload(value.payload),
43
+ negated: value.negated,
44
+ contexts: [...value.contexts],
45
+ tags: value.tags.map(tag => ({ key: tag.key, value: tag.value })),
46
+ filters: value.filters.map(filter),
47
+ })
48
+
49
+ /** Capture caller-owned query options before database callbacks can change them. */
50
+ export const captureOptions = (options: Options): Options => ({
51
+ all: options.all,
52
+ aliases: options.aliases,
53
+ asOf: options.asOf,
54
+ at: options.at,
55
+ resolve: options.resolve,
56
+ limit: options.limit,
57
+ offset: options.offset,
58
+ support: options.support,
59
+ })
package/src/compile.ts CHANGED
@@ -24,6 +24,7 @@ import { Registry } from '@cavelang/canonical'
24
24
  import { QuerySql, Resolve, Row } from '@cavelang/store/adapter'
25
25
  import type { Store } from '@cavelang/store/adapter'
26
26
  import * as Pattern from './pattern.ts'
27
+ import { assertQueryUnicode } from './unicode.ts'
27
28
 
28
29
  /**
29
30
  * One storage-oriented query solution. `row`/`rows` expose SQLite-shaped
@@ -49,9 +50,9 @@ export type Match = {
49
50
  }
50
51
 
51
52
  export type Options = {
52
- /** Maximum rows SQLite may return. Prefer the snapshot-aware page API for continuations. */
53
+ /** Positive safe-integer row bound. Prefer the snapshot-aware page API for continuations. */
53
54
  readonly limit?: number
54
- /** Internal SQL offset used by the snapshot-aware page API. */
55
+ /** Non-negative safe-integer SQL offset used by the snapshot-aware page API. */
55
56
  readonly offset?: number
56
57
  /** Match all appended rows, not only current beliefs. */
57
58
  readonly all?: boolean
@@ -198,18 +199,33 @@ type Compiled = {
198
199
 
199
200
  const bounded = (compiled: Compiled, options: Options): Compiled => {
200
201
  if (options.limit === undefined) return compiled
201
- if (!Number.isInteger(options.limit) || options.limit < 1 ||
202
- !Number.isInteger(options.offset ?? 0) || (options.offset ?? 0) < 0) {
203
- throw new Error('CAVE-Q: limit must be positive and offset must be non-negative')
202
+ const offset = options.offset === undefined ? 0 : options.offset
203
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1 ||
204
+ !Number.isSafeInteger(offset) || offset < 0) {
205
+ throw new Error('CAVE-Q: limit must be a positive safe integer and offset must be a non-negative safe integer')
204
206
  }
205
207
  return {
206
208
  ...compiled,
207
209
  sql: `${compiled.sql}\nLIMIT ? OFFSET ?`,
208
- params: [...compiled.params, options.limit, options.offset ?? 0]
210
+ params: [...compiled.params, options.limit, offset]
209
211
  }
210
212
  }
211
213
 
212
214
  export const compile = (pattern: Pattern.t, registry: Registry.t, options: Options, policy?: readonly Resolve.Entry[]): Compiled => {
215
+ // Structured callers can bypass Pattern.parse; validate every string-bearing
216
+ // slot and filter before SQL parameters or traversal state are constructed.
217
+ const parts = [pattern.subject, pattern.verb, pattern.payload, ...pattern.tags, ...pattern.filters,
218
+ ...pattern.payload.kind === 'object' ? [pattern.payload.object] :
219
+ pattern.payload.kind === 'attribute' ? [pattern.payload.value] : []]
220
+ for (const part of parts) {
221
+ if (Reflect.get(part, 'kind') === 'var' && Reflect.get(part, 'name') === '') {
222
+ throw new Error('CAVE-Q: variable requires a name after ?; use _ for a wildcard')
223
+ }
224
+ for (const field of ['text', 'name', 'attribute', 'key', 'value', 'unit']) {
225
+ assertQueryUnicode(Reflect.get(part, field))
226
+ }
227
+ }
228
+ for (const context of pattern.contexts) assertQueryUnicode(context)
213
229
  // Inverse resolution (spec §12.1): swap the pattern's endpoint slots and
214
230
  // query the primary verb.
215
231
  let verb = pattern.verb
@@ -309,72 +325,90 @@ export const compile = (pattern: Pattern.t, registry: Registry.t, options: Optio
309
325
  )
310
326
  }
311
327
  }
312
- for (const context of pattern.contexts) {
313
- conditions.push('EXISTS (SELECT 1 FROM cave_context x WHERE x.claim_id = c.id AND x.context = ?)')
314
- params.push(context)
315
- }
316
- for (const tag of pattern.tags) {
317
- if (tag.value === undefined) {
318
- conditions.push('EXISTS (SELECT 1 FROM cave_tag t WHERE t.claim_id = c.id AND t.key = ? AND t.value IS NULL)')
319
- params.push(tag.key)
328
+ // Keep ordinary indexed lookups direct; only large lists need a bound array
329
+ // to avoid SQLite's expression-depth and parameter limits.
330
+ if (pattern.contexts.length <= 16) {
331
+ for (const context of pattern.contexts) {
332
+ conditions.push('EXISTS (SELECT 1 FROM cave_context x WHERE x.claim_id = c.id AND x.context = ?)')
333
+ params.push(context)
334
+ }
335
+ } else {
336
+ conditions.push(`NOT EXISTS (SELECT 1 FROM json_each(?) required
337
+ WHERE NOT EXISTS (SELECT 1 FROM cave_context x
338
+ WHERE x.claim_id = c.id AND x.context = required.value))`)
339
+ params.push(JSON.stringify(pattern.contexts))
340
+ }
341
+ if (pattern.tags.length <= 16) {
342
+ for (const tag of pattern.tags) {
343
+ if (tag.value === undefined) {
344
+ conditions.push('EXISTS (SELECT 1 FROM cave_tag t WHERE t.claim_id = c.id AND t.key = ? AND t.value IS NULL)')
345
+ params.push(tag.key)
346
+ } else {
347
+ conditions.push('EXISTS (SELECT 1 FROM cave_tag t WHERE t.claim_id = c.id AND t.key = ? AND t.value = ?)')
348
+ params.push(tag.key, tag.value)
349
+ }
350
+ }
351
+ } else {
352
+ conditions.push(`NOT EXISTS (SELECT 1 FROM json_each(?) required
353
+ WHERE NOT EXISTS (SELECT 1 FROM cave_tag t WHERE t.claim_id = c.id
354
+ AND t.key = json_extract(required.value, '$[0]')
355
+ AND t.value IS json_extract(required.value, '$[1]')))`)
356
+ params.push(JSON.stringify(pattern.tags.map(tag => [tag.key, tag.value ?? null])))
357
+ }
358
+ // Small lists retain direct predicates. Larger lists group the finite set
359
+ // of SQL predicate shapes and bind each group's operands as JSON rows.
360
+ const filterGroups = new Map<string, (string | number)[][]>()
361
+ const addFilter = (sql: string, values: (string | number)[]): void => {
362
+ if (pattern.filters.length <= 16) {
363
+ conditions.push(sql)
364
+ for (const value of values) params.push(value)
320
365
  } else {
321
- conditions.push('EXISTS (SELECT 1 FROM cave_tag t WHERE t.claim_id = c.id AND t.key = ? AND t.value = ?)')
322
- params.push(tag.key, tag.value)
366
+ let group = filterGroups.get(sql)
367
+ if (group === undefined) { group = []; filterGroups.set(sql, group) }
368
+ group.push(values)
323
369
  }
324
370
  }
325
371
  for (const filter of pattern.filters) {
326
372
  switch (filter.field) {
327
373
  case 'conf':
328
- conditions.push(`c.conf ${filter.op} ?`)
329
- params.push(filter.value)
374
+ addFilter(`c.conf ${filter.op} ?`, [filter.value])
330
375
  break
331
376
  case 'tag':
332
377
  if (filter.value === undefined) {
333
- conditions.push('EXISTS (SELECT 1 FROM cave_tag t WHERE t.claim_id = c.id AND t.key = ?)')
334
- params.push(filter.key)
378
+ addFilter('EXISTS (SELECT 1 FROM cave_tag t WHERE t.claim_id = c.id AND t.key = ?)', [filter.key])
335
379
  } else {
336
- conditions.push('EXISTS (SELECT 1 FROM cave_tag t WHERE t.claim_id = c.id AND t.key = ? AND t.value = ?)')
337
- params.push(filter.key, filter.value)
380
+ addFilter('EXISTS (SELECT 1 FROM cave_tag t WHERE t.claim_id = c.id AND t.key = ? AND t.value = ?)', [filter.key, filter.value])
338
381
  }
339
382
  break
340
383
  case 'context':
341
- conditions.push('EXISTS (SELECT 1 FROM cave_context x WHERE x.claim_id = c.id AND x.context = ?)')
342
- params.push(filter.value)
384
+ addFilter('EXISTS (SELECT 1 FROM cave_context x WHERE x.claim_id = c.id AND x.context = ?)', [filter.value])
343
385
  break
344
386
  case 'value':
345
- conditions.push(`c.value_num ${filter.op} ?`)
346
- params.push(filter.value)
387
+ addFilter(`c.value_num ${filter.op} ?`, [filter.value])
347
388
  if (filter.unit !== undefined) {
348
- conditions.push('c.value_unit = ?')
349
- params.push(filter.unit)
389
+ addFilter('c.value_unit = ?', [filter.unit])
350
390
  }
351
391
  break
352
392
  case 'tx': {
353
393
  const { lo, hi } = txBounds(filter.value)
354
394
  switch (filter.op) {
355
395
  case '>':
356
- conditions.push('c.tx >= ?')
357
- params.push(hi)
396
+ addFilter('c.tx >= ?', [hi])
358
397
  break
359
398
  case '>=':
360
- conditions.push('c.tx >= ?')
361
- params.push(lo)
399
+ addFilter('c.tx >= ?', [lo])
362
400
  break
363
401
  case '<':
364
- conditions.push('c.tx < ?')
365
- params.push(lo)
402
+ addFilter('c.tx < ?', [lo])
366
403
  break
367
404
  case '<=':
368
- conditions.push('c.tx < ?')
369
- params.push(hi)
405
+ addFilter('c.tx < ?', [hi])
370
406
  break
371
407
  case '=':
372
- conditions.push('(c.tx >= ? AND c.tx < ?)')
373
- params.push(lo, hi)
408
+ addFilter('(c.tx >= ? AND c.tx < ?)', [lo, hi])
374
409
  break
375
410
  case '!=':
376
- conditions.push('(c.tx < ? OR c.tx >= ?)')
377
- params.push(lo, hi)
411
+ addFilter('(c.tx < ? OR c.tx >= ?)', [lo, hi])
378
412
  break
379
413
  }
380
414
  break
@@ -382,6 +416,16 @@ export const compile = (pattern: Pattern.t, registry: Registry.t, options: Optio
382
416
  }
383
417
  }
384
418
 
419
+ for (const [predicate, values] of filterGroups) {
420
+ let index = 0
421
+ // These predicates are compiler-owned SQL; question marks are operands.
422
+ const bound = predicate.replace(/\?/g, () => `json_extract(required_filter.value, '$[${index++}]')`)
423
+ // A NULL comparison fails a WHERE requirement too; NOT alone would lose it.
424
+ conditions.push(`NOT EXISTS (SELECT 1 FROM json_each(?) required_filter
425
+ WHERE (${bound}) IS NOT TRUE)`)
426
+ params.push(JSON.stringify(values))
427
+ }
428
+
385
429
  // Positive patterns match supported beliefs: a retracted (@ 0%) current
386
430
  // belief has no current support (§9.3) and is skipped — mirroring the
387
431
  // transitive CTE and store traversal — unless the query asks about
@@ -390,15 +434,18 @@ export const compile = (pattern: Pattern.t, registry: Registry.t, options: Optio
390
434
  conditions.push('c.conf > 0')
391
435
  }
392
436
 
393
- const base = baseSql(options, policy)
437
+ // Bounded direct reads benefit from tuple membership instead of joining
438
+ // wide current rows. Retain both identity columns and the complete historical
439
+ // source: post-filters must never revive older revisions or retractions.
440
+ const base = options.limit !== undefined && options.all !== true && options.resolve !== true ?
441
+ `SELECT c.* FROM cave_claim c WHERE (c.claim_key, c.tx) IN (
442
+ SELECT claim_key, MAX(tx) FROM ${claimsSql(options.asOf)} GROUP BY claim_key)` :
443
+ baseSql(options, policy)
394
444
  const withClause = aliases ? `WITH RECURSIVE ${aliasPairSql(options.asOf)} ` : ''
395
445
  const sql = `${withClause}SELECT c.* FROM (${base}) c WHERE ${conditions.join(' AND ')} ORDER BY c.tx`
396
446
  const bind = (row: Record<string, unknown>): Record<string, string> => {
397
- const bindings: Record<string, string> = {}
398
- for (const [name, columns] of varColumns) {
399
- bindings[name] = String(row[columns[0]!])
400
- }
401
- return bindings
447
+ return Object.fromEntries([...varColumns].map(([name, columns]) =>
448
+ [name, String(row[columns[0]!])]))
402
449
  }
403
450
  const valueVars = [...varColumns]
404
451
  .filter(([, columns]) => columns[0] === 'value_text')
@@ -519,14 +566,10 @@ ${aliasedSelect}
519
566
  ${conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''}
520
567
  ORDER BY src, dst`
521
568
  const bind = (row: Record<string, unknown>): Record<string, string> => {
522
- const bindings: Record<string, string> = {}
523
- if (subjectSlot.kind === 'var') {
524
- bindings[subjectSlot.name] = String(row['src'])
525
- }
526
- if (objectSlot?.kind === 'var') {
527
- bindings[objectSlot.name] = String(row['dst'])
569
+ return {
570
+ ...subjectSlot.kind === 'var' ? { [subjectSlot.name]: String(row['src']) } : {},
571
+ ...objectSlot?.kind === 'var' ? { [objectSlot.name]: String(row['dst']) } : {}
528
572
  }
529
- return bindings
530
573
  }
531
574
  return { sql, params, bind, transitive: true, valueVars: [] }
532
575
  }
@@ -588,7 +631,7 @@ export type Window = {
588
631
  readonly scanned: number
589
632
  }
590
633
 
591
- const run = (store: Store, pattern: Pattern.t, options: Options = {}): Window => {
634
+ const run = (store: Store, pattern: Pattern.t, options: Options = {}, positions?: WeakMap<object, number>): Window => {
592
635
  const instant = options.at === undefined ? undefined : Time.parseInstant(options.at)
593
636
  if (options.at !== undefined && instant === undefined) {
594
637
  throw new Error(
@@ -604,6 +647,9 @@ const run = (store: Store, pattern: Pattern.t, options: Options = {}): Window =>
604
647
  throw new Error('CAVE-Q: at does not compose with transitive patterns — hop edges are not valid-time filtered (spec §32.4)')
605
648
  }
606
649
  const rows = store.db.prepare(compiled.sql).all(...compiled.params) as Record<string, unknown>[]
650
+ // Pagination needs original positions after post-filters discard rows.
651
+ // Keep this optional so ordinary queries allocate no position index.
652
+ if (positions !== undefined) rows.forEach((row, index) => positions.set(row, index))
607
653
  if (compiled.transitive && options.support === true) {
608
654
  // One match per distinct (src, dst) pair, its supporting edge rows
609
655
  // attached; under aliases identical binding sets still collapse to
@@ -676,8 +722,8 @@ const run = (store: Store, pattern: Pattern.t, options: Options = {}): Window =>
676
722
  }
677
723
 
678
724
  /** Execute one SQL window and report how many pre-filter rows it consumed. */
679
- export const window = (store: Store, pattern: Pattern.t, options: Options = {}): Window =>
680
- run(store, pattern, options)
725
+ export const window = (store: Store, pattern: Pattern.t, options: Options = {}, positions?: WeakMap<object, number>): Window =>
726
+ run(store, pattern, options, positions)
681
727
 
682
728
  export const match = (store: Store, pattern: Pattern.t, options: Options = {}): Match[] =>
683
729
  run(store, pattern, options).matches
package/src/page.ts CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  import { QuerySql } from '@cavelang/store/adapter'
4
4
  import type { Store } from '@cavelang/store/adapter'
5
- import { Value } from '@cavelang/core'
5
+ import { Uuidv7, Value } from '@cavelang/core'
6
6
  import type { Options } from './compile.ts'
7
7
  import { queryRecords } from './record.ts'
8
8
  import { of as recordOf } from './record.ts'
9
9
  import type { t as QueryRecord } from './record.ts'
10
10
  import { window } from './bounded.ts'
11
+ import { readSnapshot } from './read-snapshot.ts'
11
12
  import * as Pattern from './pattern.ts'
12
13
 
13
14
  export const format = 'cave.query-page' as const
@@ -29,7 +30,8 @@ export type Page = {
29
30
  readonly next?: string
30
31
  }
31
32
 
32
- type Cursor = { readonly v: 1, readonly fingerprint: string, readonly snapshot: string, readonly offset: number }
33
+ type Revision = readonly [number, number, number, number]
34
+ type Cursor = { readonly v: 2, readonly fingerprint: string, readonly snapshot: string, readonly offset: number, readonly revision: Revision }
33
35
 
34
36
  const fingerprint = (input: string, options: PageOptions, limit: number): string => {
35
37
  const text = JSON.stringify({
@@ -52,11 +54,13 @@ const encodeCursor = (cursor: Cursor): string => encodeURIComponent(JSON.stringi
52
54
  const decodeCursor = (text: string): Cursor => {
53
55
  try {
54
56
  const value = JSON.parse(decodeURIComponent(text)) as Partial<Cursor>
55
- if (value.v !== 1 || typeof value.fingerprint !== 'string' || typeof value.snapshot !== 'string' ||
56
- !Number.isInteger(value.offset) || value.offset! < 0) throw new Error('invalid')
57
+ if (value.v !== 2 || typeof value.fingerprint !== 'string' || typeof value.snapshot !== 'string' ||
58
+ !Uuidv7.is(value.snapshot) || !Number.isSafeInteger(value.offset) || value.offset! < 0 ||
59
+ !Array.isArray(value.revision) || value.revision.length !== 4 ||
60
+ !value.revision.every(entry => Number.isSafeInteger(entry) && entry >= 0)) throw new Error('invalid')
57
61
  return value as Cursor
58
62
  } catch {
59
- throw new Error('CAVE-Q: invalid pagination cursor')
63
+ throw new Error('CAVE-Q: invalid pagination cursor; restart from the first page')
60
64
  }
61
65
  }
62
66
 
@@ -68,9 +72,44 @@ const snapshotOf = (store: Store, asOf: string | undefined): null | string => {
68
72
  return typeof row?.['tx'] === 'string' ? row['tx'] : null
69
73
  }
70
74
 
75
+ /**
76
+ * Detect changes to an append-only historical universe, including edges from
77
+ * future parents attached to old rows. This deliberately covers all touching
78
+ * lineage, beyond vocabulary alone. One statement reads both sets consistently;
79
+ * wholly future rows and edges are irrelevant.
80
+ * Local rowid tails supplement counts and keep the token constant-sized.
81
+ */
82
+ const revisionOf = (store: Store, snapshot: string): Revision => {
83
+ const value = store.db.prepare(`
84
+ SELECT
85
+ (SELECT COUNT(*) FROM cave_claim WHERE tx <= ?) AS claims,
86
+ (SELECT COALESCE(MAX(rowid), 0) FROM cave_claim WHERE tx <= ?) AS claim_tail,
87
+ COUNT(*) AS edges, COALESCE(MAX(e.rowid), 0) AS edge_tail
88
+ FROM cave_edge e
89
+ WHERE EXISTS (SELECT 1 FROM cave_claim c WHERE c.id = e.parent_id AND c.tx <= ?)
90
+ OR EXISTS (SELECT 1 FROM cave_claim c WHERE c.id = e.child_id AND c.tx <= ?)
91
+ `).get(snapshot, snapshot, snapshot, snapshot) as { claims: number, claim_tail: number, edges: number, edge_tail: number }
92
+ return [value.claims, value.claim_tail, value.edges, value.edge_tail]
93
+ }
94
+
95
+ const sameRevision = (a: Revision, b: Revision): boolean => a.every((value, i) => value === b[i])
96
+ const changedSnapshot = (): never => { throw new Error('CAVE-Q: pagination snapshot changed; restart from the first page') }
97
+
71
98
  /** Read one SQL-bounded page frozen at the first page's transaction boundary. */
72
99
  export const page = (store: Store, input: string, options: PageOptions = {}): Page => {
73
- const limit = options.limit ?? defaultLimit
100
+ // Read the declared fields once, including inherited/non-enumerable values.
101
+ // Fingerprinting, snapshot selection and SQL must use the same options even
102
+ // when the caller supplies accessors whose values can change between reads.
103
+ options = {
104
+ all: options.all,
105
+ aliases: options.aliases,
106
+ asOf: options.asOf,
107
+ at: options.at,
108
+ resolve: options.resolve,
109
+ limit: options.limit,
110
+ cursor: options.cursor,
111
+ }
112
+ const limit = options.limit === undefined ? defaultLimit : options.limit
74
113
  if (!Number.isInteger(limit) || limit < 1 || limit > maxLimit) {
75
114
  throw new Error(`CAVE-Q: page limit must be an integer from 1 to ${maxLimit}`)
76
115
  }
@@ -80,10 +119,17 @@ export const page = (store: Store, input: string, options: PageOptions = {}): Pa
80
119
  throw new Error('CAVE-Q: pagination cursor does not match this query and its options')
81
120
  }
82
121
  const snapshot = cursor?.snapshot ?? snapshotOf(store, options.asOf)
83
- if (snapshot === null) return { format, version, snapshot, matches: [] }
84
- const offset = cursor?.offset ?? 0
85
122
  const { cursor: _cursor, limit: _limit, ...queryOptions } = options
86
123
  const pattern = Pattern.parse(input)
124
+ if (snapshot === null) {
125
+ // Use ordinary compilation/validation even when no rows exist at the
126
+ // requested boundary. A bounded read avoids a separate validation policy.
127
+ window(store, pattern, { ...queryOptions, limit: 1 })
128
+ return { format, version, snapshot, matches: [] }
129
+ }
130
+ const revision = revisionOf(store, snapshot)
131
+ if (cursor !== undefined && !sameRevision(cursor.revision, revision)) changedSnapshot()
132
+ const offset = cursor?.offset ?? 0
87
133
  const exactNumeric = pattern.payload.kind === 'attribute' && pattern.payload.value.kind === 'term' &&
88
134
  Value.parse(pattern.payload.value.text).kind === 'number'
89
135
  let matches: readonly QueryRecord[]
@@ -99,51 +145,63 @@ export const page = (store: Store, input: string, options: PageOptions = {}): Pa
99
145
  if (found.length > limit) nextOffset = offset + limit
100
146
  } else {
101
147
  // Valid-time coverage and exact numeric approximation checks happen
102
- // after SQLite returns. Consume one SQL row at a time so a rejected row
103
- // advances the cursor while the first match of the next page does not.
104
- const found: QueryRecord[] = []
105
- let rawOffset = offset
106
- let scanned = 0
107
- const scanBudget = Math.max(defaultLimit, limit)
108
- let exhausted = false
109
- while (found.length < limit && scanned < scanBudget) {
110
- const result = window(store, pattern, {
111
- ...queryOptions,
112
- asOf: snapshot,
113
- limit: 1,
114
- offset: rawOffset,
115
- })
116
- if (result.scanned === 0) {
117
- exhausted = true
118
- break
148
+ // after SQLite returns. Original row positions let a full candidate
149
+ // batch stop at the last selected match without consuming later rows.
150
+ matches = readSnapshot(store, () => {
151
+ const found: QueryRecord[] = []
152
+ let rawOffset = offset
153
+ let scanned = 0
154
+ const scanBudget = Math.max(defaultLimit, limit)
155
+ let exhausted = false
156
+ let unread = false
157
+ while (found.length < limit && scanned < scanBudget) {
158
+ const positions = new WeakMap<object, number>()
159
+ const result = window(store, pattern, {
160
+ ...queryOptions,
161
+ asOf: snapshot,
162
+ limit: scanBudget - scanned,
163
+ offset: rawOffset,
164
+ }, positions)
165
+ if (result.scanned === 0) {
166
+ exhausted = true
167
+ break
168
+ }
169
+ const selected = result.matches.slice(0, limit - found.length)
170
+ for (const match of selected) found.push(recordOf(store, match))
171
+ // This path has physical rows: valid-time transitive queries are
172
+ // rejected, and exact numeric patterns are attribute queries.
173
+ const consumed = found.length === limit
174
+ ? positions.get(selected.at(-1)!.row!)! + 1 : result.scanned
175
+ rawOffset += consumed
176
+ scanned += consumed
177
+ unread = consumed < result.scanned
119
178
  }
120
- if (result.matches.length > 0) {
121
- found.push(recordOf(store, result.matches[0]!))
179
+ if (unread) nextOffset = rawOffset
180
+ else if (!exhausted) {
181
+ // A single bounded probe distinguishes a genuinely finished page from
182
+ // one that filled its match/scan budget. It need not pass post-filters:
183
+ // the continuation resumes before it and applies them normally.
184
+ const probe = window(store, pattern, {
185
+ ...queryOptions,
186
+ asOf: snapshot,
187
+ limit: 1,
188
+ offset: rawOffset,
189
+ })
190
+ if (probe.scanned > 0) nextOffset = rawOffset
122
191
  }
123
- rawOffset += result.scanned
124
- scanned += result.scanned
125
- }
126
- if (!exhausted) {
127
- // A single bounded probe distinguishes a genuinely finished page from
128
- // one that filled its match/scan budget. It need not pass post-filters:
129
- // the continuation resumes before it and applies them normally.
130
- const probe = window(store, pattern, {
131
- ...queryOptions,
132
- asOf: snapshot,
133
- limit: 1,
134
- offset: rawOffset,
135
- })
136
- if (probe.scanned > 0) nextOffset = rawOffset
137
- }
138
- matches = found
192
+ return found
193
+ })
139
194
  }
195
+ // No mixed result escapes if a peer or callback changes history while the
196
+ // page's SQL windows and record projections are being evaluated.
197
+ if (!sameRevision(revision, revisionOf(store, snapshot))) changedSnapshot()
140
198
  return {
141
199
  format,
142
200
  version,
143
201
  snapshot,
144
202
  matches,
145
203
  ...nextOffset === undefined ? {} : {
146
- next: encodeCursor({ v: 1, fingerprint: expected, snapshot, offset: nextOffset })
204
+ next: encodeCursor({ v: 2, fingerprint: expected, snapshot, offset: nextOffset, revision })
147
205
  }
148
206
  }
149
207
  }