@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.
- package/README.md +884 -15
- package/dist/src/backup.d.ts.map +1 -1
- package/dist/src/backup.js +143 -70
- package/dist/src/backup.js.map +1 -1
- package/dist/src/capture-claim.d.ts +4 -0
- package/dist/src/capture-claim.d.ts.map +1 -0
- package/dist/src/capture-claim.js +26 -0
- package/dist/src/capture-claim.js.map +1 -0
- package/dist/src/descriptor.d.ts +3 -0
- package/dist/src/descriptor.d.ts.map +1 -0
- package/dist/src/descriptor.js +21 -0
- package/dist/src/descriptor.js.map +1 -0
- package/dist/src/error-message.d.ts +3 -0
- package/dist/src/error-message.d.ts.map +1 -0
- package/dist/src/error-message.js +10 -0
- package/dist/src/error-message.js.map +1 -0
- package/dist/src/locate.d.ts.map +1 -1
- package/dist/src/locate.js +33 -13
- package/dist/src/locate.js.map +1 -1
- package/dist/src/node-adapter.d.ts.map +1 -1
- package/dist/src/node-adapter.js +29 -1
- package/dist/src/node-adapter.js.map +1 -1
- package/dist/src/provenance.d.ts +4 -0
- package/dist/src/provenance.d.ts.map +1 -1
- package/dist/src/provenance.js +36 -1
- package/dist/src/provenance.js.map +1 -1
- package/dist/src/query-sql.d.ts.map +1 -1
- package/dist/src/query-sql.js +4 -2
- package/dist/src/query-sql.js.map +1 -1
- package/dist/src/read-snapshot.d.ts +4 -0
- package/dist/src/read-snapshot.d.ts.map +1 -0
- package/dist/src/read-snapshot.js +22 -0
- package/dist/src/read-snapshot.js.map +1 -0
- package/dist/src/record.d.ts.map +1 -1
- package/dist/src/record.js +102 -25
- package/dist/src/record.js.map +1 -1
- package/dist/src/resolve.d.ts.map +1 -1
- package/dist/src/resolve.js +42 -18
- package/dist/src/resolve.js.map +1 -1
- package/dist/src/row.d.ts.map +1 -1
- package/dist/src/row.js +45 -17
- package/dist/src/row.js.map +1 -1
- package/dist/src/runtime.d.ts.map +1 -1
- package/dist/src/runtime.js +8 -4
- package/dist/src/runtime.js.map +1 -1
- package/dist/src/schema.d.ts +1 -2
- package/dist/src/schema.d.ts.map +1 -1
- package/dist/src/schema.js +116 -15
- package/dist/src/schema.js.map +1 -1
- package/dist/src/store.d.ts +17 -5
- package/dist/src/store.d.ts.map +1 -1
- package/dist/src/store.js +451 -104
- package/dist/src/store.js.map +1 -1
- package/package.json +4 -4
- package/src/backup.ts +141 -65
- package/src/capture-claim.ts +30 -0
- package/src/descriptor.ts +19 -0
- package/src/error-message.ts +8 -0
- package/src/locate.ts +31 -12
- package/src/node-adapter.ts +28 -1
- package/src/provenance.ts +33 -1
- package/src/query-sql.ts +4 -2
- package/src/read-snapshot.ts +21 -0
- package/src/record.ts +102 -26
- package/src/resolve.ts +43 -20
- package/src/row.ts +29 -2
- package/src/runtime.ts +8 -4
- package/src/schema.ts +117 -18
- 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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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 {
|
|
172
|
-
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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(
|
|
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(
|
|
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
|
|
253
|
-
`
|
|
254
|
-
|
|
255
|
-
const
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
75
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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', '
|
|
120
|
-
'
|
|
121
|
-
'
|
|
122
|
-
'
|
|
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.
|
|
148
|
-
...
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|