@cavelang/query 0.35.0 → 0.36.1
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/README.md +498 -9
- package/dist/src/bounded.d.ts +1 -1
- package/dist/src/bounded.d.ts.map +1 -1
- package/dist/src/bounded.js +14 -7
- package/dist/src/bounded.js.map +1 -1
- package/dist/src/capture.d.ts +7 -0
- package/dist/src/capture.d.ts.map +1 -0
- package/dist/src/capture.js +52 -0
- package/dist/src/capture.js.map +1 -0
- package/dist/src/compile.d.ts +3 -3
- package/dist/src/compile.d.ts.map +1 -1
- package/dist/src/compile.js +107 -54
- package/dist/src/compile.js.map +1 -1
- package/dist/src/page.d.ts.map +1 -1
- package/dist/src/page.js +103 -45
- package/dist/src/page.js.map +1 -1
- package/dist/src/pattern.d.ts.map +1 -1
- package/dist/src/pattern.js +20 -10
- package/dist/src/pattern.js.map +1 -1
- package/dist/src/read-snapshot.d.ts +4 -0
- package/dist/src/read-snapshot.d.ts.map +1 -0
- package/dist/src/read-snapshot.js +29 -0
- package/dist/src/read-snapshot.js.map +1 -0
- package/dist/src/record.d.ts.map +1 -1
- package/dist/src/record.js +51 -16
- package/dist/src/record.js.map +1 -1
- package/dist/src/unicode.d.ts +3 -0
- package/dist/src/unicode.d.ts.map +1 -0
- package/dist/src/unicode.js +7 -0
- package/dist/src/unicode.js.map +1 -0
- package/package.json +6 -6
- package/src/bounded.ts +23 -8
- package/src/capture.ts +59 -0
- package/src/compile.ts +102 -56
- package/src/page.ts +102 -44
- package/src/pattern.ts +20 -10
- package/src/read-snapshot.ts +23 -0
- package/src/record.ts +52 -16
- package/src/unicode.ts +6 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
53
|
+
/** Positive safe-integer row bound. Prefer the snapshot-aware page API for continuations. */
|
|
53
54
|
readonly limit?: number
|
|
54
|
-
/**
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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,
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
params.push(filter.value)
|
|
387
|
+
addFilter(`c.value_num ${filter.op} ?`, [filter.value])
|
|
347
388
|
if (filter.unit !== undefined) {
|
|
348
|
-
|
|
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
|
-
|
|
357
|
-
params.push(hi)
|
|
396
|
+
addFilter('c.tx >= ?', [hi])
|
|
358
397
|
break
|
|
359
398
|
case '>=':
|
|
360
|
-
|
|
361
|
-
params.push(lo)
|
|
399
|
+
addFilter('c.tx >= ?', [lo])
|
|
362
400
|
break
|
|
363
401
|
case '<':
|
|
364
|
-
|
|
365
|
-
params.push(lo)
|
|
402
|
+
addFilter('c.tx < ?', [lo])
|
|
366
403
|
break
|
|
367
404
|
case '<=':
|
|
368
|
-
|
|
369
|
-
params.push(hi)
|
|
405
|
+
addFilter('c.tx < ?', [hi])
|
|
370
406
|
break
|
|
371
407
|
case '=':
|
|
372
|
-
|
|
373
|
-
params.push(lo, hi)
|
|
408
|
+
addFilter('(c.tx >= ? AND c.tx < ?)', [lo, hi])
|
|
374
409
|
break
|
|
375
410
|
case '!=':
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
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 !==
|
|
56
|
-
!Number.
|
|
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
|
-
|
|
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.
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 (
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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:
|
|
204
|
+
next: encodeCursor({ v: 2, fingerprint: expected, snapshot, offset: nextOffset, revision })
|
|
147
205
|
}
|
|
148
206
|
}
|
|
149
207
|
}
|