@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/src/pattern.ts CHANGED
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { Confidence, Value, Verb } from '@cavelang/core'
19
19
  import { Token } from '@cavelang/parser'
20
+ import { assertQueryUnicode } from './unicode.ts'
20
21
 
21
22
  /** A pattern slot: named variable, wildcard `_`, or a bound term. */
22
23
  export type Slot =
@@ -63,13 +64,19 @@ const filterOps: readonly FilterOp[] = ['>=', '<=', '!=', '=', '>', '<']
63
64
  const isFilterOp = (text: string): text is FilterOp =>
64
65
  filterOps.includes(text as FilterOp)
65
66
 
67
+ const variableName = (text: string): string => {
68
+ const name = text.slice(1)
69
+ if (name === '') throw new Error('CAVE-Q: variable requires a name after ?; use _ for a wildcard')
70
+ return name
71
+ }
72
+
66
73
  const slotOf = (token: Token.t): Slot => {
67
74
  if (token.kind === 'word') {
68
75
  if (token.text === '_') {
69
76
  return { kind: 'wildcard' }
70
77
  }
71
78
  if (token.text.startsWith('?')) {
72
- return { kind: 'var', name: token.text.slice(1) }
79
+ return { kind: 'var', name: variableName(token.text) }
73
80
  }
74
81
  return { kind: 'term', text: token.text }
75
82
  }
@@ -85,7 +92,7 @@ const verbSlotOf = (token: Token.t): undefined | VerbSlot => {
85
92
  return { kind: 'wildcard' }
86
93
  }
87
94
  if (token.text.startsWith('?')) {
88
- return { kind: 'var', name: token.text.slice(1) }
95
+ return { kind: 'var', name: variableName(token.text) }
89
96
  }
90
97
  const transitive = token.text.endsWith('+')
91
98
  const name = transitive ? token.text.slice(0, -1) : token.text
@@ -107,7 +114,7 @@ const parseFilter = (tokens: readonly Token.t[], lineNo: number): Filter => {
107
114
  switch (field.text) {
108
115
  case 'conf': {
109
116
  const conf = valueText.endsWith('%') ? Confidence.parse(valueText) : Number(valueText)
110
- if (conf === undefined || Number.isNaN(conf)) {
117
+ if (conf === undefined || !Number.isFinite(conf)) {
111
118
  return bad(`cannot parse confidence ${JSON.stringify(valueText)}`)
112
119
  }
113
120
  return { field: 'conf', op: op.text, value: conf }
@@ -148,13 +155,16 @@ const parseFilter = (tokens: readonly Token.t[], lineNo: number): Filter => {
148
155
  export const parse = (input: string): Pattern => {
149
156
  const lines = input
150
157
  .split(/\r?\n/)
151
- .map(line => line.trim())
152
- .filter(line => line !== '' && !line.startsWith(';'))
158
+ .map((line, index) => {
159
+ assertQueryUnicode(line, index + 1)
160
+ return { text: line.trim(), lineNo: index + 1 }
161
+ })
162
+ .filter(line => line.text !== '' && !line.text.startsWith(';'))
153
163
  if (lines.length === 0) {
154
164
  throw new Error('CAVE-Q: empty query')
155
165
  }
156
166
  const [patternLine, ...filterLines] = lines
157
- const { head } = Token.splitComment(patternLine!)
167
+ const { head } = Token.splitComment(patternLine!.text)
158
168
  const tokens = Token.tokenize(head)
159
169
  if (tokens.length < 2) {
160
170
  throw new Error('CAVE-Q: a pattern needs at least a subject and a verb (spec §12.1)')
@@ -214,13 +224,13 @@ export const parse = (input: string): Pattern => {
214
224
  } else {
215
225
  throw new Error(`CAVE-Q: cannot parse payload ${JSON.stringify(payloadTokens.map(token => token.text).join(' '))}`)
216
226
  }
217
- const filters = filterLines.map((line, at) => {
218
- const filterTokens = Token.tokenize(Token.splitComment(line).head)
227
+ const filters = filterLines.map(line => {
228
+ const filterTokens = Token.tokenize(Token.splitComment(line.text).head)
219
229
  const [keyword, ...rest_] = filterTokens
220
230
  if (keyword?.kind !== 'word' || keyword.text !== 'WHERE') {
221
- throw new Error(`CAVE-Q line ${at + 2}: expected WHERE, got ${JSON.stringify(keyword?.text ?? '')}`)
231
+ throw new Error(`CAVE-Q line ${line.lineNo}: expected WHERE, got ${JSON.stringify(keyword?.text ?? '')}`)
222
232
  }
223
- return parseFilter(rest_, at + 2)
233
+ return parseFilter(rest_, line.lineNo)
224
234
  })
225
235
  return { subject, verb, payload, negated, contexts, tags, filters }
226
236
  }
@@ -0,0 +1,23 @@
1
+ import type { Store } from '@cavelang/store/adapter'
2
+
3
+ const message = (error: unknown): string => {
4
+ try { return error instanceof Error ? String(error.message) : String(error) }
5
+ catch { return '[unprintable thrown value]' }
6
+ }
7
+
8
+ /** A deferred savepoint also nests within a caller-owned transaction. */
9
+ export const readSnapshot = <T>(store: Store, read: () => T): T => {
10
+ const savepoint = 'cave_query_read'
11
+ store.db.exec(`SAVEPOINT ${savepoint}`)
12
+ let result: T
13
+ try { result = read() } catch (error) {
14
+ try { store.db.exec(`RELEASE ${savepoint}`) } catch (releaseError) {
15
+ throw new AggregateError([error, releaseError],
16
+ `CAVE-Q matching failed: ${message(error)}; read snapshot release also failed: ${message(releaseError)}`,
17
+ { cause: error })
18
+ }
19
+ throw error
20
+ }
21
+ store.db.exec(`RELEASE ${savepoint}`)
22
+ return result
23
+ }
package/src/record.ts CHANGED
@@ -4,6 +4,8 @@ import { Record as ClaimRecord } from '@cavelang/store/adapter'
4
4
  import type { Store } from '@cavelang/store/adapter'
5
5
  import type { Match, Options } from './compile.ts'
6
6
  import { query } from './bounded.ts'
7
+ import { readSnapshot } from './read-snapshot.ts'
8
+ import { captureOptions } from './capture.ts'
7
9
 
8
10
  export const format = 'cave.query-match' as const
9
11
  export const version = 1 as const
@@ -17,38 +19,69 @@ export type t = {
17
19
  readonly at?: Match['at']
18
20
  }
19
21
 
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
- })
22
+ export const of = (store: Store, match: Match): t => {
23
+ const supplied = { bindings: match.bindings, row: match.row, rows: match.rows, at: match.at }
24
+ requireBindingPrototype(supplied.bindings)
25
+ const { bindings, row, rows, at } = structuredClone(supplied)
26
+ validateMetadata(bindings, rows, at)
27
+ if (rows !== undefined) for (const evidence of rows) {
28
+ if (!object(evidence)) throw new Error(`CAVE query record: malformed ${format}/v${version} support`)
29
+ }
30
+ return {
31
+ format,
32
+ version,
33
+ bindings,
34
+ ...(row === undefined ? {} : { claim: store.recordOf(row) }),
35
+ ...(rows === undefined ? {} : { support: rows.map(store.recordOf) }),
36
+ ...(at === undefined ? {} : { at }),
37
+ }
38
+ }
28
39
 
29
40
  const object = (value: unknown): value is Record<string, unknown> =>
30
41
  typeof value === 'object' && value !== null && !Array.isArray(value)
31
42
 
43
+ const requireBindingPrototype = (bindings: unknown): void => {
44
+ if (object(bindings) && Object.getPrototypeOf(bindings) !== Object.prototype &&
45
+ Object.getPrototypeOf(bindings) !== null) {
46
+ throw new Error(`CAVE query record: malformed ${format}/v${version}`)
47
+ }
48
+ }
49
+
50
+ const validateMetadata = (bindings: unknown, support: unknown, at: unknown): void => {
51
+ if (!object(bindings) ||
52
+ (Object.getPrototypeOf(bindings) !== Object.prototype && Object.getPrototypeOf(bindings) !== null) ||
53
+ !Object.values(bindings).every(binding => typeof binding === 'string') ||
54
+ (support !== undefined && !Array.isArray(support))) {
55
+ throw new Error(`CAVE query record: malformed ${format}/v${version}`)
56
+ }
57
+ if (at !== undefined && (!object(at) || typeof at['num'] !== 'number' || !Number.isFinite(at['num']) ||
58
+ typeof at['text'] !== 'string' || (at['unit'] !== undefined && typeof at['unit'] !== 'string'))) {
59
+ throw new Error(`CAVE query record: malformed ${format}/v${version} interpolation`)
60
+ }
61
+ }
62
+
32
63
  /** Decode a persisted query-match record and every nested claim record. */
33
64
  export const decode = (input: string | unknown): t => {
34
- const value: unknown = typeof input === 'string' ? JSON.parse(input) : input
65
+ let value: unknown = typeof input === 'string' ? JSON.parse(input) : object(input) ? { ...input } : input
66
+ if (typeof input !== 'string' && object(value)) {
67
+ // Check the supplied map before cloning normalizes custom prototypes.
68
+ requireBindingPrototype(value['bindings'])
69
+ value = structuredClone(value)
70
+ }
35
71
  if (!object(value) || value['format'] !== format) {
36
72
  throw new Error(`CAVE query record: expected format ${JSON.stringify(format)}`)
37
73
  }
38
74
  if (value['version'] !== version) {
39
75
  throw new Error(`CAVE query record: unsupported ${format} version ${JSON.stringify(value['version'])}`)
40
76
  }
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
- }
77
+ validateMetadata(value['bindings'], value['support'], value['at'])
46
78
  const decoded = value as t
47
79
  return {
48
80
  ...decoded,
49
81
  ...(decoded.claim === undefined ? {} : { claim: ClaimRecord.decode(decoded.claim) }),
50
82
  ...(decoded.support === undefined ? {} : {
51
- support: decoded.support.map(record => ClaimRecord.decode(record))
83
+ support: Array.from({ length: decoded.support.length },
84
+ (_, index) => ClaimRecord.decode(decoded.support![index]))
52
85
  }),
53
86
  }
54
87
  }
@@ -61,4 +94,7 @@ export const queryRecords = (
61
94
  store: Store,
62
95
  input: string,
63
96
  options: Options = {}
64
- ): t[] => query(store, input, options).map(match => of(store, match))
97
+ ): t[] => {
98
+ options = captureOptions(options)
99
+ return readSnapshot(store, () => query(store, input, options).map(match => of(store, match)))
100
+ }
package/src/unicode.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** Query strings must survive SQLite's UTF-8 encoding without replacement. */
2
+ export const assertQueryUnicode = (value: unknown, line?: number): void => {
3
+ if (typeof value === 'string' && /[\uD800-\uDFFF]/u.test(value)) {
4
+ throw new Error(`CAVE-Q${line === undefined ? '' : ` line ${line}`}: unpaired UTF-16 surrogate cannot be queried as UTF-8`)
5
+ }
6
+ }