@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.
- 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/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/locate.d.ts +13 -1
- package/dist/src/locate.d.ts.map +1 -1
- package/dist/src/locate.js +35 -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/index.ts +1 -1
- package/src/locate.ts +45 -13
- 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/node-adapter.ts
CHANGED
|
@@ -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 = {}) =>
|
|
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
|
-
|
|
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))
|