@cavelang/query 0.27.14 → 0.29.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/Authors.md +5 -0
- package/License.md +6 -0
- package/README.md +76 -11
- package/bench/transitive.ts +42 -0
- package/dist/bench/transitive.d.ts +3 -0
- package/dist/bench/transitive.d.ts.map +1 -0
- package/dist/bench/transitive.js +38 -0
- package/dist/bench/transitive.js.map +1 -0
- package/dist/src/bounded.d.ts +3 -8
- package/dist/src/bounded.d.ts.map +1 -1
- package/dist/src/bounded.js +13 -11
- package/dist/src/bounded.js.map +1 -1
- package/dist/src/compile.d.ts +33 -4
- package/dist/src/compile.d.ts.map +1 -1
- package/dist/src/compile.js +153 -103
- package/dist/src/compile.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/page.d.ts +23 -0
- package/dist/src/page.d.ts.map +1 -0
- package/dist/src/page.js +131 -0
- package/dist/src/page.js.map +1 -0
- package/dist/src/record.d.ts +21 -0
- package/dist/src/record.d.ts.map +1 -0
- package/dist/src/record.js +41 -0
- package/dist/src/record.js.map +1 -0
- package/package.json +21 -8
- package/src/bounded.ts +26 -13
- package/src/compile.ts +151 -78
- package/src/index.ts +4 -0
- package/src/page.ts +149 -0
- package/src/record.ts +64 -0
package/src/page.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** Snapshot-stable, bounded CAVE-Q pages. */
|
|
2
|
+
|
|
3
|
+
import { QuerySql } from '@cavelang/store/adapter'
|
|
4
|
+
import type { Store } from '@cavelang/store/adapter'
|
|
5
|
+
import { Value } from '@cavelang/core'
|
|
6
|
+
import type { Options } from './compile.ts'
|
|
7
|
+
import { queryRecords } from './record.ts'
|
|
8
|
+
import { of as recordOf } from './record.ts'
|
|
9
|
+
import type { t as QueryRecord } from './record.ts'
|
|
10
|
+
import { window } from './bounded.ts'
|
|
11
|
+
import * as Pattern from './pattern.ts'
|
|
12
|
+
|
|
13
|
+
export const format = 'cave.query-page' as const
|
|
14
|
+
export const version = 1 as const
|
|
15
|
+
export const defaultLimit = 100
|
|
16
|
+
export const maxLimit = 1_000
|
|
17
|
+
|
|
18
|
+
export type PageOptions = Omit<Options, 'limit' | 'offset' | 'asOf' | 'support'> & {
|
|
19
|
+
readonly asOf?: string
|
|
20
|
+
readonly limit?: number
|
|
21
|
+
readonly cursor?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Page = {
|
|
25
|
+
readonly format: typeof format
|
|
26
|
+
readonly version: typeof version
|
|
27
|
+
readonly snapshot: null | string
|
|
28
|
+
readonly matches: readonly QueryRecord[]
|
|
29
|
+
readonly next?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Cursor = { readonly v: 1, readonly fingerprint: string, readonly snapshot: string, readonly offset: number }
|
|
33
|
+
|
|
34
|
+
const fingerprint = (input: string, options: PageOptions, limit: number): string => {
|
|
35
|
+
const text = JSON.stringify({
|
|
36
|
+
input, limit,
|
|
37
|
+
all: options.all === true,
|
|
38
|
+
aliases: options.aliases === true,
|
|
39
|
+
asOf: options.asOf ?? null,
|
|
40
|
+
at: options.at ?? null,
|
|
41
|
+
resolve: options.resolve === true,
|
|
42
|
+
})
|
|
43
|
+
let hash = 0xcbf29ce484222325n
|
|
44
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
45
|
+
hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 0x100000001b3n)
|
|
46
|
+
}
|
|
47
|
+
return hash.toString(16).padStart(16, '0')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const encodeCursor = (cursor: Cursor): string => encodeURIComponent(JSON.stringify(cursor))
|
|
51
|
+
|
|
52
|
+
const decodeCursor = (text: string): Cursor => {
|
|
53
|
+
try {
|
|
54
|
+
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
|
+
return value as Cursor
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error('CAVE-Q: invalid pagination cursor')
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const snapshotOf = (store: Store, asOf: string | undefined): null | string => {
|
|
64
|
+
const boundary = asOf === undefined ? undefined : QuerySql.asOfBoundary(asOf)
|
|
65
|
+
if (asOf !== undefined && boundary === undefined) throw new Error(`CAVE-Q: cannot parse as-of boundary ${JSON.stringify(asOf)}`)
|
|
66
|
+
const where = boundary === undefined ? '' : ` WHERE ${QuerySql.asOfCondition(boundary)}`
|
|
67
|
+
const row = store.db.prepare(`SELECT MAX(tx) AS tx FROM cave_claim${where}`).get()
|
|
68
|
+
return typeof row?.['tx'] === 'string' ? row['tx'] : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read one SQL-bounded page frozen at the first page's transaction boundary. */
|
|
72
|
+
export const page = (store: Store, input: string, options: PageOptions = {}): Page => {
|
|
73
|
+
const limit = options.limit ?? defaultLimit
|
|
74
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > maxLimit) {
|
|
75
|
+
throw new Error(`CAVE-Q: page limit must be an integer from 1 to ${maxLimit}`)
|
|
76
|
+
}
|
|
77
|
+
const expected = fingerprint(input, options, limit)
|
|
78
|
+
const cursor = options.cursor === undefined ? undefined : decodeCursor(options.cursor)
|
|
79
|
+
if (cursor !== undefined && cursor.fingerprint !== expected) {
|
|
80
|
+
throw new Error('CAVE-Q: pagination cursor does not match this query and its options')
|
|
81
|
+
}
|
|
82
|
+
const snapshot = cursor?.snapshot ?? snapshotOf(store, options.asOf)
|
|
83
|
+
if (snapshot === null) return { format, version, snapshot, matches: [] }
|
|
84
|
+
const offset = cursor?.offset ?? 0
|
|
85
|
+
const { cursor: _cursor, limit: _limit, ...queryOptions } = options
|
|
86
|
+
const pattern = Pattern.parse(input)
|
|
87
|
+
const exactNumeric = pattern.payload.kind === 'attribute' && pattern.payload.value.kind === 'term' &&
|
|
88
|
+
Value.parse(pattern.payload.value.text).kind === 'number'
|
|
89
|
+
let matches: readonly QueryRecord[]
|
|
90
|
+
let nextOffset: undefined | number
|
|
91
|
+
if (options.at === undefined && !exactNumeric) {
|
|
92
|
+
const found = queryRecords(store, input, {
|
|
93
|
+
...queryOptions,
|
|
94
|
+
asOf: snapshot,
|
|
95
|
+
limit: limit + 1,
|
|
96
|
+
offset,
|
|
97
|
+
})
|
|
98
|
+
matches = found.slice(0, limit)
|
|
99
|
+
if (found.length > limit) nextOffset = offset + limit
|
|
100
|
+
} else {
|
|
101
|
+
// 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
|
|
119
|
+
}
|
|
120
|
+
if (result.matches.length > 0) {
|
|
121
|
+
found.push(recordOf(store, result.matches[0]!))
|
|
122
|
+
}
|
|
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
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
format,
|
|
142
|
+
version,
|
|
143
|
+
snapshot,
|
|
144
|
+
matches,
|
|
145
|
+
...nextOffset === undefined ? {} : {
|
|
146
|
+
next: encodeCursor({ v: 1, fingerprint: expected, snapshot, offset: nextOffset })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/record.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Versioned JSON projection of CAVE-Q matches. */
|
|
2
|
+
|
|
3
|
+
import { Record as ClaimRecord } from '@cavelang/store/adapter'
|
|
4
|
+
import type { Store } from '@cavelang/store/adapter'
|
|
5
|
+
import type { Match, Options } from './compile.ts'
|
|
6
|
+
import { query } from './bounded.ts'
|
|
7
|
+
|
|
8
|
+
export const format = 'cave.query-match' as const
|
|
9
|
+
export const version = 1 as const
|
|
10
|
+
|
|
11
|
+
export type t = {
|
|
12
|
+
readonly format: typeof format
|
|
13
|
+
readonly version: typeof version
|
|
14
|
+
readonly bindings: Readonly<Record<string, string>>
|
|
15
|
+
readonly claim?: ClaimRecord.t
|
|
16
|
+
readonly support?: readonly ClaimRecord.t[]
|
|
17
|
+
readonly at?: Match['at']
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const of = (store: Store, match: Match): t => ({
|
|
21
|
+
format,
|
|
22
|
+
version,
|
|
23
|
+
bindings: match.bindings,
|
|
24
|
+
...(match.row === undefined ? {} : { claim: store.recordOf(match.row) }),
|
|
25
|
+
...(match.rows === undefined ? {} : { support: match.rows.map(store.recordOf) }),
|
|
26
|
+
...(match.at === undefined ? {} : { at: match.at }),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const object = (value: unknown): value is Record<string, unknown> =>
|
|
30
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
31
|
+
|
|
32
|
+
/** Decode a persisted query-match record and every nested claim record. */
|
|
33
|
+
export const decode = (input: string | unknown): t => {
|
|
34
|
+
const value: unknown = typeof input === 'string' ? JSON.parse(input) : input
|
|
35
|
+
if (!object(value) || value['format'] !== format) {
|
|
36
|
+
throw new Error(`CAVE query record: expected format ${JSON.stringify(format)}`)
|
|
37
|
+
}
|
|
38
|
+
if (value['version'] !== version) {
|
|
39
|
+
throw new Error(`CAVE query record: unsupported ${format} version ${JSON.stringify(value['version'])}`)
|
|
40
|
+
}
|
|
41
|
+
if (!object(value['bindings']) ||
|
|
42
|
+
!Object.values(value['bindings']).every(binding => typeof binding === 'string') ||
|
|
43
|
+
(value['support'] !== undefined && !Array.isArray(value['support']))) {
|
|
44
|
+
throw new Error(`CAVE query record: malformed ${format}/v${version}`)
|
|
45
|
+
}
|
|
46
|
+
const decoded = value as t
|
|
47
|
+
return {
|
|
48
|
+
...decoded,
|
|
49
|
+
...(decoded.claim === undefined ? {} : { claim: ClaimRecord.decode(decoded.claim) }),
|
|
50
|
+
...(decoded.support === undefined ? {} : {
|
|
51
|
+
support: decoded.support.map(record => ClaimRecord.decode(record))
|
|
52
|
+
}),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const encode = (record: t, space?: number): string =>
|
|
57
|
+
JSON.stringify(record, undefined, space)
|
|
58
|
+
|
|
59
|
+
/** Stable library/JSON query surface; raw `query()` remains storage-oriented. */
|
|
60
|
+
export const queryRecords = (
|
|
61
|
+
store: Store,
|
|
62
|
+
input: string,
|
|
63
|
+
options: Options = {}
|
|
64
|
+
): t[] => query(store, input, options).map(match => of(store, match))
|