@cavelang/store 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.
Files changed (73) hide show
  1. package/README.md +884 -15
  2. package/dist/src/backup.d.ts.map +1 -1
  3. package/dist/src/backup.js +143 -70
  4. package/dist/src/backup.js.map +1 -1
  5. package/dist/src/capture-claim.d.ts +4 -0
  6. package/dist/src/capture-claim.d.ts.map +1 -0
  7. package/dist/src/capture-claim.js +26 -0
  8. package/dist/src/capture-claim.js.map +1 -0
  9. package/dist/src/descriptor.d.ts +3 -0
  10. package/dist/src/descriptor.d.ts.map +1 -0
  11. package/dist/src/descriptor.js +21 -0
  12. package/dist/src/descriptor.js.map +1 -0
  13. package/dist/src/error-message.d.ts +3 -0
  14. package/dist/src/error-message.d.ts.map +1 -0
  15. package/dist/src/error-message.js +10 -0
  16. package/dist/src/error-message.js.map +1 -0
  17. package/dist/src/index.d.ts +1 -1
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/locate.d.ts +13 -1
  20. package/dist/src/locate.d.ts.map +1 -1
  21. package/dist/src/locate.js +35 -13
  22. package/dist/src/locate.js.map +1 -1
  23. package/dist/src/node-adapter.d.ts.map +1 -1
  24. package/dist/src/node-adapter.js +29 -1
  25. package/dist/src/node-adapter.js.map +1 -1
  26. package/dist/src/provenance.d.ts +4 -0
  27. package/dist/src/provenance.d.ts.map +1 -1
  28. package/dist/src/provenance.js +36 -1
  29. package/dist/src/provenance.js.map +1 -1
  30. package/dist/src/query-sql.d.ts.map +1 -1
  31. package/dist/src/query-sql.js +4 -2
  32. package/dist/src/query-sql.js.map +1 -1
  33. package/dist/src/read-snapshot.d.ts +4 -0
  34. package/dist/src/read-snapshot.d.ts.map +1 -0
  35. package/dist/src/read-snapshot.js +22 -0
  36. package/dist/src/read-snapshot.js.map +1 -0
  37. package/dist/src/record.d.ts.map +1 -1
  38. package/dist/src/record.js +102 -25
  39. package/dist/src/record.js.map +1 -1
  40. package/dist/src/resolve.d.ts.map +1 -1
  41. package/dist/src/resolve.js +42 -18
  42. package/dist/src/resolve.js.map +1 -1
  43. package/dist/src/row.d.ts.map +1 -1
  44. package/dist/src/row.js +45 -17
  45. package/dist/src/row.js.map +1 -1
  46. package/dist/src/runtime.d.ts.map +1 -1
  47. package/dist/src/runtime.js +8 -4
  48. package/dist/src/runtime.js.map +1 -1
  49. package/dist/src/schema.d.ts +1 -2
  50. package/dist/src/schema.d.ts.map +1 -1
  51. package/dist/src/schema.js +116 -15
  52. package/dist/src/schema.js.map +1 -1
  53. package/dist/src/store.d.ts +17 -5
  54. package/dist/src/store.d.ts.map +1 -1
  55. package/dist/src/store.js +451 -104
  56. package/dist/src/store.js.map +1 -1
  57. package/package.json +4 -4
  58. package/src/backup.ts +141 -65
  59. package/src/capture-claim.ts +30 -0
  60. package/src/descriptor.ts +19 -0
  61. package/src/error-message.ts +8 -0
  62. package/src/index.ts +1 -1
  63. package/src/locate.ts +45 -13
  64. package/src/node-adapter.ts +28 -1
  65. package/src/provenance.ts +33 -1
  66. package/src/query-sql.ts +4 -2
  67. package/src/read-snapshot.ts +21 -0
  68. package/src/record.ts +102 -26
  69. package/src/resolve.ts +43 -20
  70. package/src/row.ts +29 -2
  71. package/src/runtime.ts +8 -4
  72. package/src/schema.ts +117 -18
  73. package/src/store.ts +452 -125
@@ -2,9 +2,33 @@
2
2
 
3
3
  import { DatabaseSync } from 'node:sqlite'
4
4
  import type { Adapter, Database } from './adapter.ts'
5
+ import { errorMessage } from './error-message.ts'
5
6
 
6
7
  const asNodeDatabase = (db: Database): DatabaseSync => db as DatabaseSync
7
8
 
9
+ let verifiedText = false
10
+
11
+ /** Refuse lossy native text decoding before opening or creating caller data. */
12
+ const verifyText = (): void => {
13
+ if (verifiedText) return
14
+ const probe = new DatabaseSync(':memory:')
15
+ try {
16
+ const expected = 'before\0after'
17
+ if (probe.prepare('SELECT ? AS value').get(expected)?.value !== expected) {
18
+ throw new Error('CAVE: node:sqlite truncates embedded NUL text on this runtime; use Node 24.16.0+ (24.x) or 26.1.0+ (26.x)')
19
+ }
20
+ } catch (error) {
21
+ try { probe.close() } catch (closeError) {
22
+ throw new AggregateError([error, closeError],
23
+ `CAVE native text probe failed: ${errorMessage(error)}; database close also failed: ${errorMessage(closeError)}`,
24
+ { cause: error })
25
+ }
26
+ throw error
27
+ }
28
+ probe.close()
29
+ verifiedText = true
30
+ }
31
+
8
32
  export const nodeSqliteAdapter: Adapter = {
9
33
  name: 'node:sqlite',
10
34
  capabilities: {
@@ -19,5 +43,8 @@ export const nodeSqliteAdapter: Adapter = {
19
43
  },
20
44
  },
21
45
  },
22
- open: (path, options = {}) => new DatabaseSync(path, options),
46
+ open: (path, options = {}) => {
47
+ verifyText()
48
+ return new DatabaseSync(path, options)
49
+ },
23
50
  }
package/src/provenance.ts CHANGED
@@ -86,6 +86,33 @@ export const fromEntries = (values: readonly Entry[]): t => ({
86
86
  domains: values.filter(entry => entry.dimension === 'domain').map(entry => entry.value)
87
87
  })
88
88
 
89
+ /** Stable set representation for interchange and identity comparison. */
90
+ export const normalize = (value: t): t => ({
91
+ actors: [...new Set(value.actors)].sort(),
92
+ sources: [...new Set(value.sources)].sort(),
93
+ runs: [...new Set(value.runs)].sort(),
94
+ domains: [...new Set(value.domains)].sort(),
95
+ })
96
+
97
+ /** Parse a complete explicit provenance object; reject unknown dimensions and malformed entries. */
98
+ export const parse = (value: unknown): undefined | t => {
99
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
100
+ const object = value as Record<string, unknown>
101
+ const keys = ['actors', 'sources', 'runs', 'domains'] as const
102
+ const fields = Object.keys(object)
103
+ if (fields.length !== keys.length || keys.some(key => !fields.includes(key))) return undefined
104
+ const captured: Record<(typeof keys)[number], string[]> = { actors: [], sources: [], runs: [], domains: [] }
105
+ for (const key of keys) {
106
+ const dimension = object[key]
107
+ if (!Array.isArray(dimension)) return undefined
108
+ const values: unknown[] = Array.from(dimension)
109
+ if (!values.every((entry): entry is string => typeof entry === 'string' && entry.length > 0 &&
110
+ !/[\uD800-\uDFFF]/u.test(entry))) return undefined
111
+ captured[key] = values
112
+ }
113
+ return normalize(captured)
114
+ }
115
+
89
116
  /** Idempotently derives dimensions for rows written by older CAVE versions. */
90
117
  export const backfill = (db: Database): void => {
91
118
  const missing = db.prepare(`
@@ -96,7 +123,12 @@ export const backfill = (db: Database): void => {
96
123
  `).all() as { id: string, context: null | string }[]
97
124
  const grouped = new Map<string, string[]>()
98
125
  for (const row of missing) {
99
- grouped.set(row.id, [...grouped.get(row.id) ?? [], ...row.context === null ? [] : [row.context]])
126
+ let contexts = grouped.get(row.id)
127
+ if (contexts === undefined) {
128
+ contexts = []
129
+ grouped.set(row.id, contexts)
130
+ }
131
+ if (row.context !== null) contexts.push(row.context)
100
132
  }
101
133
  const insert = db.prepare(`
102
134
  INSERT OR IGNORE INTO cave_provenance (claim_id, dimension, value) VALUES (?, ?, ?)
package/src/query-sql.ts CHANGED
@@ -24,9 +24,11 @@ export type AsOfBoundary = {
24
24
  export const transactionBounds = (text: string): TransactionBounds | undefined => {
25
25
  const boundary = Time.parseBoundary(text)
26
26
  if (boundary === undefined) return undefined
27
+ // UUID transactions cannot precede the Unix epoch. Earlier calendar
28
+ // intervals are empty; intervals crossing the epoch retain their overlap.
27
29
  return {
28
- lo: Uuidv7.at(boundary.start, 0, new Uint8Array(8)),
29
- hi: Uuidv7.at(boundary.end, 0, new Uint8Array(8)),
30
+ lo: Uuidv7.at(Math.max(0, boundary.start), 0, new Uint8Array(8)),
31
+ hi: Uuidv7.at(Math.max(0, boundary.end), 0, new Uint8Array(8)),
30
32
  }
31
33
  }
32
34
 
@@ -0,0 +1,21 @@
1
+ import type { Database } from './adapter.ts'
2
+ import { errorMessage } from './error-message.ts'
3
+
4
+ /** Keep a multi-query read on one deferred snapshot, including inside caller transactions. */
5
+ export const readSnapshot = <T>(db: Database, operation: 'export' | 'resolution' | 'reverse', read: () => T): T => {
6
+ const savepoint = `cave_${operation}_read`
7
+ db.exec(`SAVEPOINT ${savepoint}`)
8
+ let result: T
9
+ try {
10
+ result = read()
11
+ } catch (error) {
12
+ try { db.exec(`RELEASE ${savepoint}`) } catch (releaseError) {
13
+ throw new AggregateError([error, releaseError],
14
+ `CAVE ${operation} failed: ${errorMessage(error)}; read snapshot release also failed: ${errorMessage(releaseError)}`,
15
+ { cause: error })
16
+ }
17
+ throw error
18
+ }
19
+ db.exec(`RELEASE ${savepoint}`)
20
+ return result
21
+ }
package/src/record.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /** Versioned, storage-independent claim and transaction records. */
2
2
 
3
- import { Key, Uuidv7 } from '@cavelang/core'
3
+ import { Key, Uuidv7, Value } from '@cavelang/core'
4
4
  import type { Claim } from '@cavelang/core'
5
5
  import { emitClaim } from '@cavelang/canonical'
6
6
  import type * as Provenance from './provenance.ts'
7
+ import { parse as parseProvenance } from './provenance.ts'
7
8
  import type * as Row from './row.ts'
8
9
 
9
10
  export const format = 'cave.claim' as const
@@ -29,49 +30,124 @@ export type V1 = {
29
30
 
30
31
  export type t = V1
31
32
 
32
- export const of = (row: Row.t, claim: Claim.t, provenance: Provenance.t): t => ({
33
- format,
34
- version,
35
- id: row.id,
36
- tx: row.tx,
37
- key: row.claim_key,
38
- canonical: emitClaim(claim),
39
- claim,
40
- provenance,
41
- })
33
+ const requireTransactionIdentity = (id: unknown, tx: unknown): void => {
34
+ if (typeof id !== 'string' || !Uuidv7.is(id) || tx !== id) {
35
+ throw new Error(`CAVE record: malformed ${format}/v${version} transaction identity`)
36
+ }
37
+ }
38
+
39
+ export const of = (row: Row.t, claim: Claim.t, provenance: Provenance.t): t => {
40
+ const id = row.id, tx = row.tx
41
+ requireTransactionIdentity(id, tx)
42
+ const key = row.claim_key
43
+ const capturedClaim = structuredClone(claim)
44
+ validateClaim(capturedClaim)
45
+ if (Key.of(capturedClaim) !== key) {
46
+ throw new Error(`CAVE record: malformed ${format}/v${version} semantic identity`)
47
+ }
48
+ const capturedProvenance = structuredClone(provenance)
49
+ if (parseProvenance(capturedProvenance) === undefined) {
50
+ throw new Error(`CAVE record: malformed ${format}/v${version} provenance`)
51
+ }
52
+ return {
53
+ format,
54
+ version,
55
+ id,
56
+ tx,
57
+ key,
58
+ canonical: emitClaim(capturedClaim),
59
+ claim: capturedClaim,
60
+ provenance: capturedProvenance,
61
+ }
62
+ }
42
63
 
43
64
  const object = (value: unknown): value is Record<string, unknown> =>
44
65
  typeof value === 'object' && value !== null && !Array.isArray(value)
45
66
 
46
- const strings = (value: unknown): value is readonly string[] =>
47
- Array.isArray(value) && value.every(entry => typeof entry === 'string')
67
+ const strings = (value: unknown): value is readonly string[] => {
68
+ if (!Array.isArray(value)) return false
69
+ for (const entry of value) if (typeof entry !== 'string') return false
70
+ return true
71
+ }
72
+
73
+ const term = (value: unknown): boolean => object(value) &&
74
+ (value['kind'] === 'entity' || value['kind'] === 'text' || value['kind'] === 'code') &&
75
+ typeof value['text'] === 'string'
76
+
77
+ const semanticValue = (value: unknown): boolean => {
78
+ if (!object(value) || typeof value['kind'] !== 'string' ||
79
+ !['number', 'trajectory', 'date', 'atom', 'text', 'code'].includes(value['kind']) ||
80
+ typeof value['raw'] !== 'string' || typeof value['approx'] !== 'boolean' ||
81
+ (value['unit'] !== undefined && typeof value['unit'] !== 'string')) return false
82
+ for (const field of ['num', 'from', 'to']) {
83
+ const number = value[field]
84
+ if (number !== undefined && (typeof number !== 'number' || !Number.isFinite(number))) return false
85
+ }
86
+ const parsed = value['kind'] === 'text' ? Value.ofText(value['raw']) :
87
+ value['kind'] === 'code' ? Value.ofCode(value['raw']) : Value.parse(value['raw'])
88
+ return value['kind'] === parsed.kind && value['approx'] === parsed.approx &&
89
+ value['num'] === parsed.num && value['from'] === parsed.from && value['to'] === parsed.to &&
90
+ value['unit'] === parsed.unit
91
+ }
92
+
93
+ const payload = (value: Record<string, unknown>): boolean => {
94
+ switch (value['kind']) {
95
+ case 'none': return true
96
+ case 'relation': return term(value['object'])
97
+ case 'attribute': return typeof value['attribute'] === 'string' && semanticValue(value['value'])
98
+ case 'metric': return semanticValue(value['value'])
99
+ default: return false
100
+ }
101
+ }
102
+
103
+ const validateClaim = (claim: unknown): void => {
104
+ if (!object(claim) || !object(claim['subject']) || !object(claim['payload']) ||
105
+ typeof claim['verb'] !== 'string' || typeof claim['negated'] !== 'boolean' ||
106
+ !strings(claim['contexts']) || !Array.isArray(claim['tags']) ||
107
+ typeof claim['conf'] !== 'number' || typeof claim['importance'] !== 'boolean' ||
108
+ typeof claim['raw'] !== 'string') {
109
+ throw new Error(`CAVE record: malformed ${format}/v${version}`)
110
+ }
111
+ for (const tag of claim['tags']) {
112
+ if (!object(tag) || typeof tag['key'] !== 'string' ||
113
+ (tag['value'] !== undefined && typeof tag['value'] !== 'string')) {
114
+ throw new Error(`CAVE record: malformed ${format}/v${version} tag`)
115
+ }
116
+ }
117
+ if (!term(claim['subject']) ||
118
+ (claim['payload']['kind'] === 'relation' && !term(claim['payload']['object']))) {
119
+ throw new Error(`CAVE record: malformed ${format}/v${version} term`)
120
+ }
121
+ if (!payload(claim['payload'])) {
122
+ throw new Error(`CAVE record: malformed ${format}/v${version} payload`)
123
+ }
124
+ const sigmaLevel = claim['sigmaLevel']
125
+ const delta = claim['delta']
126
+ if ((delta !== undefined && (!semanticValue(delta) || !object(delta) ||
127
+ typeof delta['num'] !== 'number' || delta['num'] <= 0)) ||
128
+ (sigmaLevel !== undefined && (typeof sigmaLevel !== 'number' || !Number.isFinite(sigmaLevel) || sigmaLevel <= 0)) ||
129
+ (claim['comment'] !== undefined && typeof claim['comment'] !== 'string')) {
130
+ throw new Error(`CAVE record: malformed ${format}/v${version} metadata`)
131
+ }
132
+ }
48
133
 
49
134
  /** Decode a persisted v1 fixture, rejecting unknown future formats loudly. */
50
135
  export const decode = (input: string | unknown): t => {
51
- const value: unknown = typeof input === 'string' ? JSON.parse(input) : input
136
+ const value: unknown = typeof input === 'string' ? JSON.parse(input) : structuredClone(input)
52
137
  if (!object(value) || value['format'] !== format) {
53
138
  throw new Error(`CAVE record: expected format ${JSON.stringify(format)}`)
54
139
  }
55
140
  if (value['version'] !== version) {
56
141
  throw new Error(`CAVE record: unsupported ${format} version ${JSON.stringify(value['version'])}`)
57
142
  }
58
- const claim = value['claim']
59
- const provenance = value['provenance']
60
143
  if (typeof value['id'] !== 'string' || typeof value['tx'] !== 'string' ||
61
144
  typeof value['key'] !== 'string' || typeof value['canonical'] !== 'string' ||
62
- !object(claim) || !object(claim['subject']) || !object(claim['payload']) ||
63
- typeof claim['verb'] !== 'string' || typeof claim['negated'] !== 'boolean' ||
64
- !strings(claim['contexts']) || !Array.isArray(claim['tags']) ||
65
- typeof claim['conf'] !== 'number' || typeof claim['importance'] !== 'boolean' ||
66
- typeof claim['raw'] !== 'string' || !object(provenance) ||
67
- !strings(provenance['actors']) || !strings(provenance['sources']) ||
68
- !strings(provenance['runs']) || !strings(provenance['domains'])) {
145
+ parseProvenance(value['provenance']) === undefined) {
69
146
  throw new Error(`CAVE record: malformed ${format}/v${version}`)
70
147
  }
148
+ validateClaim(value['claim'])
71
149
  const decoded = value as t
72
- if (!Uuidv7.is(decoded.id) || !Uuidv7.is(decoded.tx)) {
73
- throw new Error(`CAVE record: malformed ${format}/v${version} transaction identity`)
74
- }
150
+ requireTransactionIdentity(decoded.id, decoded.tx)
75
151
  if (Key.of(decoded.claim) !== decoded.key || emitClaim(decoded.claim) !== decoded.canonical) {
76
152
  throw new Error(`CAVE record: malformed ${format}/v${version} semantic identity`)
77
153
  }
package/src/resolve.ts CHANGED
@@ -103,9 +103,10 @@ const rootOf = (entries: readonly Entry[], dimension: 'precedence' | 'reliabilit
103
103
  */
104
104
  const classOf = (entries: readonly Entry[], paths: readonly string[]): number => {
105
105
  const root = rootOf(entries, 'precedence') ?? 0
106
- return paths.length === 0 ?
107
- root :
108
- Math.max(...paths.map(path => lookup(entries, path, 'precedence') ?? root))
106
+ if (paths.length === 0) return root
107
+ let maximum = Number.NEGATIVE_INFINITY
108
+ for (const path of paths) maximum = Math.max(maximum, lookup(entries, path, 'precedence') ?? root)
109
+ return maximum
109
110
  }
110
111
 
111
112
  type DeclarationRow = {
@@ -155,8 +156,7 @@ export const readPolicy = (db: Database, currentSql: string): Entry[] => {
155
156
  AND (c.subject = 'source' OR substr(c.subject, 1, 7) = 'source/')
156
157
  `).all() as unknown as DeclarationRow[]
157
158
  const sourcesOf = db.prepare(`
158
- SELECT CASE WHEN instr(substr(context, 5), '#') = 0 THEN substr(context, 5)
159
- ELSE substr(substr(context, 5), 1, instr(substr(context, 5), '#') - 1) END AS path
159
+ SELECT context
160
160
  FROM cave_context
161
161
  WHERE claim_id = ? AND substr(context, 1, 4) = 'src:'
162
162
  `)
@@ -168,8 +168,9 @@ export const readPolicy = (db: Database, currentSql: string): Entry[] => {
168
168
  if (value === undefined) {
169
169
  continue
170
170
  }
171
- const paths = (sourcesOf.all(row.id) as { path: string }[]).map(source => source.path)
172
- const key = `${row.subject}\0${row.attribute}`
171
+ const paths = (sourcesOf.all(row.id) as { context: string }[])
172
+ .map(source => source.context.slice(4).split('#', 1)[0]!)
173
+ const key = JSON.stringify([row.subject, row.attribute])
173
174
  const candidates = contests.get(key) ?? []
174
175
  candidates.push({ row, value, cls: classOf(builtins, paths) })
175
176
  contests.set(key, candidates)
@@ -179,7 +180,7 @@ export const readPolicy = (db: Database, currentSql: string): Entry[] => {
179
180
  builtins.map(entry => [entry.prefix, { ...entry }])
180
181
  )
181
182
  for (const [key, candidates] of contests) {
182
- const [subject, attribute] = key.split('\0') as [string, string]
183
+ const [subject, attribute] = JSON.parse(key) as [string, string]
183
184
  const winner = candidates.sort((a, b) =>
184
185
  b.cls - a.cls || b.row.conf - a.row.conf || (a.row.tx < b.row.tx ? 1 : a.row.tx > b.row.tx ? -1 : 0))[0]!
185
186
  const prefix = subject === 'source' ? '' : subject.slice('source/'.length)
@@ -190,8 +191,13 @@ export const readPolicy = (db: Database, currentSql: string): Entry[] => {
190
191
  return [...merged.values()].sort((a, b) => a.prefix < b.prefix ? -1 : a.prefix > b.prefix ? 1 : 0)
191
192
  }
192
193
 
193
- const sqlString = (text: string): string =>
194
- `'${text.replaceAll("'", "''")}'`
194
+ const sqlString = (text: string): string => {
195
+ if (text.includes('\0')) {
196
+ const hex = Array.from(new TextEncoder().encode(text), byte => byte.toString(16).padStart(2, '0')).join('')
197
+ return `CAST(X'${hex}' AS TEXT)`
198
+ }
199
+ return `'${text.replaceAll("'", "''")}'`
200
+ }
195
201
 
196
202
  const sqlNumber = (value: undefined | number): string =>
197
203
  value !== undefined && Number.isFinite(value) ? String(value) : 'NULL'
@@ -218,8 +224,20 @@ export const rankedSql = (
218
224
  currentSql: string,
219
225
  options: { aliases?: boolean } = {}
220
226
  ): string => {
221
- const values = entries.map(entry =>
222
- `(${sqlString(entry.prefix)}, ${sqlNumber(entry.precedence)}, ${sqlNumber(entry.reliability)})`)
227
+ let policySql: string
228
+ const materialized = entries.length > 16
229
+ if (materialized) {
230
+ const finite = (value: undefined | number): null | number =>
231
+ value !== undefined && Number.isFinite(value) ? value : null
232
+ const data = JSON.stringify(entries.map(entry =>
233
+ [entry.prefix, finite(entry.precedence), finite(entry.reliability)]))
234
+ policySql = `SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]'), ` +
235
+ `json_extract(value, '$[2]') FROM json_each(${sqlString(data)})`
236
+ } else {
237
+ const values = entries.map(entry =>
238
+ `(${sqlString(entry.prefix)}, ${sqlNumber(entry.precedence)}, ${sqlNumber(entry.reliability)})`)
239
+ policySql = values.length === 0 ? 'SELECT NULL, NULL, NULL WHERE 0' : `VALUES ${values.join(', ')}`
240
+ }
223
241
  const rootClass = sqlNumber(rootOf(entries, 'precedence') ?? 0)
224
242
  const rootReliability = sqlNumber(rootOf(entries, 'reliability') ?? 1)
225
243
  // Alias representative: the closure group's smallest member name —
@@ -228,11 +246,14 @@ export const rankedSql = (
228
246
  `min(${expr}, COALESCE((SELECT MIN(ap.b) FROM alias_pair ap WHERE ap.a = ${expr}), ${expr}))`
229
247
  const subjectPart = `json_extract(c.claim_key, '$[0]')`
230
248
  const payloadPart = `json_extract(c.claim_key, '$[3]')`
249
+ // Strip the ASCII key marker in bytes; TEXT substr stops at an embedded NUL.
250
+ const entityName = (part: string, start: number): string =>
251
+ `CAST(substr(CAST(${part} AS BLOB), ${start}) AS TEXT)`
231
252
  const subjectExpr = options.aliases === true ?
232
- `CASE WHEN substr(${subjectPart}, 1, 2) = 'e:' THEN 'e:' || ${representative(`substr(${subjectPart}, 3)`)} ELSE ${subjectPart} END` :
253
+ `CASE WHEN substr(${subjectPart}, 1, 2) = 'e:' THEN 'e:' || ${representative(entityName(subjectPart, 3))} ELSE ${subjectPart} END` :
233
254
  subjectPart
234
255
  const payloadExpr = options.aliases === true ?
235
- `CASE WHEN substr(${payloadPart}, 1, 4) = 'r:e:' THEN 'r:e:' || ${representative(`substr(${payloadPart}, 5)`)} ELSE ${payloadPart} END` :
256
+ `CASE WHEN substr(${payloadPart}, 1, 4) = 'r:e:' THEN 'r:e:' || ${representative(entityName(payloadPart, 5))} ELSE ${payloadPart} END` :
236
257
  payloadPart
237
258
  // Claim key modulo polarity and src: contexts (spec §26.1). Contexts in
238
259
  // the key are already sorted; filtering preserves the order.
@@ -249,14 +270,16 @@ export const rankedSql = (
249
270
  // Longest-prefix match of one source path against the policy (spec §26.3).
250
271
  const matched = (column: string): string =>
251
272
  `(SELECT y.${column} FROM cave_policy y WHERE y.${column} IS NOT NULL AND ` +
252
- `(y.prefix = '' OR x.path = y.prefix OR substr(x.path, 1, length(y.prefix) + 1) = y.prefix || '/') ` +
253
- `ORDER BY length(y.prefix) DESC LIMIT 1)`
254
- const classExpr = `COALESCE((SELECT MAX(${matched('cls')}) FROM (${paths}) x), ${rootClass})`
255
- const reliabilityExpr = `COALESCE((SELECT MIN(${matched('rel')}) FROM (${paths}) x), ${rootReliability})`
273
+ `(y.prefix = '' OR x.path = y.prefix OR ` +
274
+ `substr(CAST(x.path AS BLOB), 1, length(CAST(y.prefix AS BLOB)) + 1) = CAST(y.prefix || '/' AS BLOB)) ` +
275
+ `ORDER BY length(CAST(y.prefix AS BLOB)) DESC LIMIT 1)`
276
+ const classExpr = `COALESCE((SELECT MAX(COALESCE(${matched('cls')}, ${rootClass})) FROM (${paths}) x), ${rootClass})`
277
+ const reliabilityExpr = `COALESCE((SELECT MIN(COALESCE(${matched('rel')}, ${rootReliability})) FROM (${paths}) x), ${rootReliability})`
256
278
  const order = `${classExpr} DESC, c.conf * ${reliabilityExpr} DESC, c.tx DESC`
279
+ // REAL projection keeps finite JS numbers out of the driver's int64 decoder.
257
280
  return `
258
- WITH cave_policy(prefix, cls, rel) AS (VALUES ${values.join(', ')})
259
- SELECT c.*, ${groupExpr} AS res_group, ${classExpr} AS res_class, c.conf * ${reliabilityExpr} AS res_conf,
281
+ WITH cave_policy(prefix, cls, rel) AS ${materialized ? 'MATERIALIZED ' : ''}(${policySql})
282
+ SELECT c.*, ${groupExpr} AS res_group, CAST(${classExpr} AS REAL) AS res_class, c.conf * ${reliabilityExpr} AS res_conf,
260
283
  ROW_NUMBER() OVER (PARTITION BY ${groupExpr} ORDER BY ${order}) AS res_rank
261
284
  FROM (${currentSql}) c
262
285
  WHERE c.conf > 0`
package/src/row.ts CHANGED
@@ -124,8 +124,19 @@ export const toClaim = (
124
124
  row: Row,
125
125
  contexts: readonly string[],
126
126
  tags: readonly { key: string, value: null | string }[]
127
- ): Claim.t =>
128
- Claim.of({
127
+ ): Claim.t => {
128
+ for (const field of ['negated', 'importance', 'value_approx'] as const) {
129
+ if (row[field] !== 0 && row[field] !== 1) {
130
+ throw new TypeError(`stored claim ${field} must be 0 or 1`)
131
+ }
132
+ }
133
+ if (row.object !== null && (row.attribute !== null || row.value_text !== null)) {
134
+ throw new TypeError('stored claim payload cannot combine an object with an attribute or value')
135
+ }
136
+ if (row.attribute !== null && row.value_text === null) {
137
+ throw new TypeError('stored claim attribute payload requires a value')
138
+ }
139
+ const claim = Claim.of({
129
140
  subject: parseTerm(row.subject),
130
141
  verb: row.verb,
131
142
  negated: row.negated !== 0,
@@ -143,3 +154,19 @@ export const toClaim = (
143
154
  ...row.comment !== null ? { comment: row.comment } : {},
144
155
  raw: row.raw_line
145
156
  })
157
+ const value = claim.payload.kind === 'attribute' || claim.payload.kind === 'metric'
158
+ ? claim.payload.value : undefined
159
+ const expected = {
160
+ value_num: value?.num ?? null,
161
+ value_unit: value?.unit ?? null,
162
+ value_approx: value?.approx === true ? 1 : 0,
163
+ delta_num: claim.delta?.num ?? null,
164
+ delta_unit: claim.delta?.unit ?? null
165
+ }
166
+ for (const field of Object.keys(expected) as (keyof typeof expected)[]) {
167
+ if (row[field] !== expected[field]) {
168
+ throw new TypeError(`stored claim ${field} does not agree with its authored value`)
169
+ }
170
+ }
171
+ return claim
172
+ }
package/src/runtime.ts CHANGED
@@ -34,9 +34,12 @@ const declarationsAsOf = (store: BaseStore, asOf: string): Declaration[] => {
34
34
  SELECT subject, verb, object FROM cave_claim
35
35
  WHERE tx ${boundary.operator} ?
36
36
  AND negated = 0 AND object IS NOT NULL AND verb IN ('REVERSE', 'RENAMED-TO', 'IS')
37
- AND id NOT IN (SELECT child_id FROM cave_edge WHERE role IN ('WHEN', 'VIA', 'BECAUSE'))
37
+ AND id NOT IN (
38
+ SELECT e.child_id FROM cave_edge e JOIN cave_claim parent ON parent.id = e.parent_id
39
+ WHERE e.role IN ('WHEN', 'VIA', 'BECAUSE') AND parent.tx ${boundary.operator} ?
40
+ )
38
41
  ORDER BY tx
39
- `).all(boundary.tx) as Declaration[]
42
+ `).all(boundary.tx, boundary.tx) as Declaration[]
40
43
  }
41
44
 
42
45
  const applyDeclarations = (
@@ -71,8 +74,9 @@ export const openWith = (
71
74
  path: string = ':memory:',
72
75
  options: OpenOptions = {}
73
76
  ): Store => {
74
- const baseRegistry = options.registry ?? Canonical.standardRegistry
75
- const store = openStore(adapter, path, options)
77
+ const { registry, access } = options
78
+ const baseRegistry = registry ?? Canonical.standardRegistry
79
+ const store = openStore(adapter, path, { registry: baseRegistry, access })
76
80
  return Object.assign(store, {
77
81
  registryAsOf: (asOf: string): Canonical.Registry.t =>
78
82
  applyDeclarations(baseRegistry, declarationsAsOf(store, asOf))