@cavelang/store 0.35.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 (69) 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/locate.d.ts.map +1 -1
  18. package/dist/src/locate.js +33 -13
  19. package/dist/src/locate.js.map +1 -1
  20. package/dist/src/node-adapter.d.ts.map +1 -1
  21. package/dist/src/node-adapter.js +29 -1
  22. package/dist/src/node-adapter.js.map +1 -1
  23. package/dist/src/provenance.d.ts +4 -0
  24. package/dist/src/provenance.d.ts.map +1 -1
  25. package/dist/src/provenance.js +36 -1
  26. package/dist/src/provenance.js.map +1 -1
  27. package/dist/src/query-sql.d.ts.map +1 -1
  28. package/dist/src/query-sql.js +4 -2
  29. package/dist/src/query-sql.js.map +1 -1
  30. package/dist/src/read-snapshot.d.ts +4 -0
  31. package/dist/src/read-snapshot.d.ts.map +1 -0
  32. package/dist/src/read-snapshot.js +22 -0
  33. package/dist/src/read-snapshot.js.map +1 -0
  34. package/dist/src/record.d.ts.map +1 -1
  35. package/dist/src/record.js +102 -25
  36. package/dist/src/record.js.map +1 -1
  37. package/dist/src/resolve.d.ts.map +1 -1
  38. package/dist/src/resolve.js +42 -18
  39. package/dist/src/resolve.js.map +1 -1
  40. package/dist/src/row.d.ts.map +1 -1
  41. package/dist/src/row.js +45 -17
  42. package/dist/src/row.js.map +1 -1
  43. package/dist/src/runtime.d.ts.map +1 -1
  44. package/dist/src/runtime.js +8 -4
  45. package/dist/src/runtime.js.map +1 -1
  46. package/dist/src/schema.d.ts +1 -2
  47. package/dist/src/schema.d.ts.map +1 -1
  48. package/dist/src/schema.js +116 -15
  49. package/dist/src/schema.js.map +1 -1
  50. package/dist/src/store.d.ts +17 -5
  51. package/dist/src/store.d.ts.map +1 -1
  52. package/dist/src/store.js +451 -104
  53. package/dist/src/store.js.map +1 -1
  54. package/package.json +4 -4
  55. package/src/backup.ts +141 -65
  56. package/src/capture-claim.ts +30 -0
  57. package/src/descriptor.ts +19 -0
  58. package/src/error-message.ts +8 -0
  59. package/src/locate.ts +31 -12
  60. package/src/node-adapter.ts +28 -1
  61. package/src/provenance.ts +33 -1
  62. package/src/query-sql.ts +4 -2
  63. package/src/read-snapshot.ts +21 -0
  64. package/src/record.ts +102 -26
  65. package/src/resolve.ts +43 -20
  66. package/src/row.ts +29 -2
  67. package/src/runtime.ts +8 -4
  68. package/src/schema.ts +117 -18
  69. package/src/store.ts +452 -125
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))
package/src/schema.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /** Ordered, transactional storage schema migrations (spec §13). */
2
2
 
3
- import type { Capabilities, Database } from './adapter.ts'
3
+ import type { Capabilities, Database, Row } from './adapter.ts'
4
4
  import * as Provenance from './provenance.ts'
5
+ import { errorMessage } from './error-message.ts'
5
6
 
6
- export const currentVersion = 1
7
+ export const currentVersion = 2
7
8
 
8
9
  const ddlBeforeFts = `
9
10
  CREATE TABLE IF NOT EXISTS cave_claim (
@@ -88,7 +89,8 @@ CREATE VIRTUAL TABLE IF NOT EXISTS cave_fts USING ${fullText}(
88
89
  `
89
90
 
90
91
  /** Default Node/FTS5 schema SQL retained for callers that inspect the DDL. */
91
- export const ddl = ddlBeforeFts + ftsDdl('fts5')
92
+ const transactionIndexDdl = 'CREATE INDEX IF NOT EXISTS idx_cave_tx ON cave_claim (tx);'
93
+ export const ddl = ddlBeforeFts + ftsDdl('fts5') + transactionIndexDdl
92
94
 
93
95
  const ddlFor = (capabilities: Capabilities): string =>
94
96
  ddlBeforeFts + ftsDdl(capabilities.fullText)
@@ -105,7 +107,8 @@ const migrations: readonly Migration[] = [
105
107
  db.exec(ddlFor(capabilities))
106
108
  Provenance.backfill(db)
107
109
  }
108
- }
110
+ },
111
+ { version: 2, up: db => { db.exec(transactionIndexDdl) } }
109
112
  ]
110
113
 
111
114
  export const versionOf = (db: Database): number =>
@@ -116,10 +119,21 @@ const requiredTables = [
116
119
  ] as const
117
120
 
118
121
  const requiredIndexes = [
119
- 'idx_cave_claim_key_tx', 'idx_cave_subject', 'idx_cave_verb', 'idx_cave_object',
120
- 'idx_cave_attribute', 'idx_cave_conf', 'idx_cave_context', 'idx_cave_context_claim',
121
- 'idx_cave_provenance_lookup', 'idx_cave_tag_key', 'idx_cave_tag_claim',
122
- 'idx_cave_edge_parent', 'idx_cave_edge_child', 'idx_cave_edge_role'
122
+ ['idx_cave_claim_key_tx', 'cave_claim', ['claim_key', 'tx']],
123
+ ['idx_cave_subject', 'cave_claim', ['subject']],
124
+ ['idx_cave_verb', 'cave_claim', ['verb']],
125
+ ['idx_cave_object', 'cave_claim', ['object']],
126
+ ['idx_cave_attribute', 'cave_claim', ['attribute']],
127
+ ['idx_cave_conf', 'cave_claim', ['conf']],
128
+ ['idx_cave_context', 'cave_context', ['context']],
129
+ ['idx_cave_context_claim', 'cave_context', ['claim_id', 'context']],
130
+ ['idx_cave_provenance_lookup', 'cave_provenance', ['dimension', 'value', 'claim_id']],
131
+ ['idx_cave_tag_key', 'cave_tag', ['key', 'value']],
132
+ ['idx_cave_tag_claim', 'cave_tag', ['claim_id', 'key', 'value']],
133
+ ['idx_cave_edge_parent', 'cave_edge', ['parent_id']],
134
+ ['idx_cave_edge_child', 'cave_edge', ['child_id']],
135
+ ['idx_cave_edge_role', 'cave_edge', ['role']],
136
+ ['idx_cave_tx', 'cave_claim', ['tx']]
123
137
  ] as const
124
138
 
125
139
  const requiredColumns: Readonly<Record<string, readonly string[]>> = {
@@ -135,27 +149,111 @@ const requiredColumns: Readonly<Record<string, readonly string[]>> = {
135
149
  cave_fts: ['claim_id', 'subject', 'verb', 'object', 'attribute', 'value_text', 'comment', 'raw_line']
136
150
  }
137
151
 
152
+ const requiredPrimaryKeys: Readonly<Record<string, readonly string[]>> = {
153
+ cave_claim: ['id'],
154
+ cave_provenance: ['claim_id', 'dimension', 'value']
155
+ }
156
+
157
+ const numericClaimColumns = new Set([
158
+ 'negated', 'value_num', 'value_approx', 'delta_num', 'sigma_level', 'conf', 'importance'
159
+ ])
160
+ const fractionalClaimColumns = new Set(['value_num', 'delta_num', 'sigma_level', 'conf'])
161
+
162
+ // SQLite checks INT before CHAR/CLOB/TEXT: CHARINT is integer-affinity.
163
+ const affinityFamily = (type: unknown): 'TEXT' | 'numeric' | 'BLOB' => {
164
+ const declared = String(type).toUpperCase()
165
+ if (declared.includes('INT')) return 'numeric'
166
+ if (/CHAR|CLOB|TEXT/.test(declared)) return 'TEXT'
167
+ if (declared === '' || declared.includes('BLOB')) return 'BLOB'
168
+ // INTEGER, REAL and NUMERIC all retain numeric comparison semantics.
169
+ return 'numeric'
170
+ }
171
+
138
172
  export const validate = (db: Database, version: number, schema = 'main'): void => {
139
173
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(schema)) {
140
174
  throw new Error(`CAVE: invalid SQLite schema name ${JSON.stringify(schema)}`)
141
175
  }
176
+ const indexes = requiredIndexes.filter(([name]) => version >= 2 || name !== 'idx_cave_tx')
177
+ const tables = new Map(db.prepare(`PRAGMA ${schema}.table_list`).all().map(row => [row.name, row]))
178
+ const indexLists = new Map<string, Row[]>()
179
+ const indexesOf = (table: string): Row[] => {
180
+ let rows = indexLists.get(table)
181
+ if (rows === undefined) {
182
+ rows = db.prepare(`PRAGMA ${schema}.index_list(${table})`).all()
183
+ indexLists.set(table, rows)
184
+ }
185
+ return rows
186
+ }
142
187
  const objects = new Map(
143
188
  (db.prepare(`SELECT name, type FROM ${schema}.sqlite_schema WHERE name LIKE 'cave_%' OR name LIKE 'idx_cave_%'`)
144
189
  .all() as { name: string, type: string }[]).map(row => [row.name, row.type])
145
190
  )
146
191
  const problems = [
147
- ...requiredTables.flatMap(name => objects.has(name) ? [] : [`missing table ${name}`]),
148
- ...requiredIndexes.flatMap(name => objects.get(name) === 'index' ? [] : [`missing index ${name}`]),
192
+ ...requiredTables.flatMap(name => objects.get(name) === 'table' ? [] : [`missing table ${name}`]),
193
+ ...indexes
194
+ .flatMap(([name]) => objects.get(name) === 'index' ? [] : [`missing index ${name}`]),
149
195
  ...Object.entries(requiredColumns).flatMap(([table, required]) => {
150
196
  if (!objects.has(table)) {
151
197
  return []
152
198
  }
153
- const columns = new Set(
154
- (db.prepare(`PRAGMA ${schema}.table_info(${table})`).all() as { name: string }[]).map(row => row.name)
155
- )
156
- return required.flatMap(column => columns.has(column) ? [] : [`missing column ${table}.${column}`])
199
+ const info = db.prepare(`PRAGMA ${schema}.table_info(${table})`).all()
200
+ const columns = new Set(info.map(row => row.name))
201
+ const expectedKey = requiredPrimaryKeys[table]
202
+ const key = info.filter(row => Number(row.pk) > 0)
203
+ .sort((left, right) => Number(left.pk) - Number(right.pk)).map(row => row.name)
204
+ let binaryKey = true
205
+ if (expectedKey !== undefined) {
206
+ const primary = indexesOf(table).find(row => row.origin === 'pk')
207
+ const name = typeof primary?.name === 'string' ? primary.name : undefined
208
+ const indexed = name === undefined ? [] :
209
+ db.prepare(`PRAGMA ${schema}.index_xinfo("${name.replaceAll('"', '""')}")`).all()
210
+ .filter(row => Number(row.key) === 1)
211
+ binaryKey = indexed.length === expectedKey.length &&
212
+ indexed.every(row => String(row.coll).toUpperCase() === 'BINARY')
213
+ }
214
+ return [
215
+ ...required.flatMap(column => columns.has(column) ? [] : [`missing column ${table}.${column}`]),
216
+ ...info.flatMap(row => {
217
+ const column = String(row.name)
218
+ // FTS virtual columns have module-defined storage, not ordinary affinity.
219
+ if (table === 'cave_fts' || !required.includes(column)) return []
220
+ const strict = Number(tables.get(table)?.strict) === 1
221
+ const declared = String(row.type).toUpperCase()
222
+ if (strict && table === 'cave_claim' && fractionalClaimColumns.has(column) && declared !== 'REAL') {
223
+ return [`incompatible column ${table}.${column}: expected REAL in a STRICT table`]
224
+ }
225
+ const expected = table === 'cave_claim' && numericClaimColumns.has(column) ? 'numeric' : 'TEXT'
226
+ const affinity = strict && declared === 'ANY' ? 'BLOB' : affinityFamily(row.type)
227
+ return affinity === expected ? [] :
228
+ [`incompatible column ${table}.${column}: expected ${expected} affinity`]
229
+ }),
230
+ ...(expectedKey !== undefined && (!binaryKey || key.length !== expectedKey.length || key.some((name, index) => name !== expectedKey[index]))
231
+ ? [`incompatible table ${table}: expected primary key (${expectedKey.join(', ')}) with BINARY collation`] : [])
232
+ ]
157
233
  })
158
234
  ]
235
+ if (objects.get('cave_fts') === 'table') {
236
+ const searchTable = tables.get('cave_fts')
237
+ if (searchTable?.type !== 'virtual') {
238
+ problems.push('incompatible table cave_fts: expected a virtual table for full-text search')
239
+ } else {
240
+ const searchColumns = db.prepare(`PRAGMA ${schema}.table_xinfo(cave_fts)`).all()
241
+ if (!searchColumns.some(row => row.name === 'cave_fts' && Number(row.hidden) === 1)) {
242
+ problems.push('incompatible table cave_fts: missing the hidden full-text search column')
243
+ }
244
+ }
245
+ }
246
+ for (const [name, table, expected] of indexes) {
247
+ if (objects.get(name) !== 'index') continue
248
+ const columns = db.prepare(`PRAGMA ${schema}.index_xinfo(${name})`).all()
249
+ .filter(row => Number(row.key) === 1)
250
+ const index = indexesOf(table).find(row => row.name === name)
251
+ if (index === undefined || Number(index.unique) !== 0 || Number(index.partial) !== 0 ||
252
+ columns.length !== expected.length || columns.some((column, position) =>
253
+ column.name !== expected[position] || String(column.coll).toUpperCase() !== 'BINARY')) {
254
+ problems.push(`incompatible index ${name}: expected non-unique ${table}(${expected.map(column => `${column} COLLATE BINARY`).join(', ')}) without a partial predicate`)
255
+ }
256
+ }
159
257
  if (problems.length > 0) {
160
258
  throw new Error(`CAVE: schema version ${version} is incompatible: ${problems.join(', ')}`)
161
259
  }
@@ -214,11 +312,12 @@ export const init = (
214
312
  } catch (error) {
215
313
  try {
216
314
  db.exec('ROLLBACK')
217
- } catch {
218
- // SQLite may already have rolled back a failed statement.
315
+ } catch (rollbackError) {
316
+ throw new AggregateError([error, rollbackError],
317
+ `CAVE: schema migration ${version} -> ${migration.version} failed: ${errorMessage(error)}; rollback also failed: ${errorMessage(rollbackError)}`,
318
+ { cause: error })
219
319
  }
220
- const detail = error instanceof Error ? error.message : String(error)
221
- throw new Error(`CAVE: schema migration ${version} -> ${migration.version} failed: ${detail}`)
320
+ throw new Error(`CAVE: schema migration ${version} -> ${migration.version} failed: ${errorMessage(error)}`, { cause: error })
222
321
  }
223
322
  }
224
323
  validate(db, version)