@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/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))