@cavelang/store 0.27.14 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Authors.md +5 -0
- package/License.md +6 -0
- package/README.md +121 -14
- package/dist/src/adapter-entry.d.ts +9 -0
- package/dist/src/adapter-entry.d.ts.map +1 -0
- package/dist/src/adapter-entry.js +7 -0
- package/dist/src/adapter-entry.js.map +1 -0
- package/dist/src/adapter.d.ts +51 -0
- package/dist/src/adapter.d.ts.map +1 -0
- package/dist/src/adapter.js +3 -0
- package/dist/src/adapter.js.map +1 -0
- package/dist/src/backup.d.ts +23 -0
- package/dist/src/backup.d.ts.map +1 -0
- package/dist/src/backup.js +164 -0
- package/dist/src/backup.js.map +1 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/node-adapter-entry.d.ts +3 -0
- package/dist/src/node-adapter-entry.d.ts.map +1 -0
- package/dist/src/node-adapter-entry.js +3 -0
- package/dist/src/node-adapter-entry.js.map +1 -0
- package/dist/src/node-adapter.d.ts +4 -0
- package/dist/src/node-adapter.d.ts.map +1 -0
- package/dist/src/node-adapter.js +20 -0
- package/dist/src/node-adapter.js.map +1 -0
- package/dist/src/open.d.ts +3 -18
- package/dist/src/open.d.ts.map +1 -1
- package/dist/src/open.js +4 -52
- package/dist/src/open.js.map +1 -1
- package/dist/src/provenance.d.ts +24 -0
- package/dist/src/provenance.d.ts.map +1 -0
- package/dist/src/provenance.js +75 -0
- package/dist/src/provenance.js.map +1 -0
- package/dist/src/query-sql.d.ts +45 -0
- package/dist/src/query-sql.d.ts.map +1 -0
- package/dist/src/query-sql.js +77 -0
- package/dist/src/query-sql.js.map +1 -0
- package/dist/src/record.d.ts +29 -0
- package/dist/src/record.d.ts.map +1 -0
- package/dist/src/record.js +50 -0
- package/dist/src/record.js.map +1 -0
- package/dist/src/resolve.d.ts +2 -2
- package/dist/src/resolve.d.ts.map +1 -1
- package/dist/src/resolve.js +0 -0
- package/dist/src/resolve.js.map +1 -1
- package/dist/src/runtime.d.ts +16 -0
- package/dist/src/runtime.d.ts.map +1 -0
- package/dist/src/runtime.js +43 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/schema.d.ts +10 -6
- package/dist/src/schema.d.ts.map +1 -1
- package/dist/src/schema.js +111 -9
- package/dist/src/schema.js.map +1 -1
- package/dist/src/sensitivity.d.ts +24 -0
- package/dist/src/sensitivity.d.ts.map +1 -0
- package/dist/src/sensitivity.js +34 -0
- package/dist/src/sensitivity.js.map +1 -0
- package/dist/src/store.d.ts +37 -13
- package/dist/src/store.d.ts.map +1 -1
- package/dist/src/store.js +107 -72
- package/dist/src/store.js.map +1 -1
- package/package.json +25 -6
- package/src/adapter-entry.ts +18 -0
- package/src/adapter.ts +57 -0
- package/src/backup.ts +188 -0
- package/src/index.ts +8 -0
- package/src/node-adapter-entry.ts +3 -0
- package/src/node-adapter.ts +23 -0
- package/src/open.ts +7 -80
- package/src/provenance.ts +109 -0
- package/src/query-sql.ts +99 -0
- package/src/record.ts +82 -0
- package/src/resolve.ts +0 -0
- package/src/runtime.ts +78 -0
- package/src/schema.ts +135 -10
- package/src/sensitivity.ts +42 -0
- package/src/store.ts +134 -80
package/src/backup.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/** Exact SQLite snapshot backup, verification, and atomic restore. */
|
|
2
|
+
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
4
|
+
import {
|
|
5
|
+
closeSync, copyFileSync, existsSync, fsyncSync, linkSync, openSync, readSync,
|
|
6
|
+
realpathSync, renameSync, rmSync, statSync
|
|
7
|
+
} from 'node:fs'
|
|
8
|
+
import { basename, dirname, resolve } from 'node:path'
|
|
9
|
+
import type { Store } from './store.ts'
|
|
10
|
+
import { nodeSqliteAdapter } from './node-adapter.ts'
|
|
11
|
+
import * as Schema from './schema.ts'
|
|
12
|
+
|
|
13
|
+
export type Snapshot = {
|
|
14
|
+
readonly path: string
|
|
15
|
+
readonly bytes: number
|
|
16
|
+
readonly sha256: string
|
|
17
|
+
readonly schemaVersion: number
|
|
18
|
+
readonly rows: number
|
|
19
|
+
readonly maxTx: null | string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type WriteOptions = {
|
|
23
|
+
/** Replace an existing destination atomically. Never permits source = destination. */
|
|
24
|
+
readonly force?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const temporaryPath = (destination: string): string => {
|
|
28
|
+
const target = resolve(destination)
|
|
29
|
+
return resolve(dirname(target), `.${basename(target)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const hashFile = (path: string): string => {
|
|
33
|
+
const hash = createHash('sha256')
|
|
34
|
+
const fd = openSync(path, 'r')
|
|
35
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
|
36
|
+
try {
|
|
37
|
+
for (;;) {
|
|
38
|
+
const size = readSync(fd, buffer, 0, buffer.length, null)
|
|
39
|
+
if (size === 0) break
|
|
40
|
+
hash.update(buffer.subarray(0, size))
|
|
41
|
+
}
|
|
42
|
+
} finally {
|
|
43
|
+
closeSync(fd)
|
|
44
|
+
}
|
|
45
|
+
return hash.digest('hex')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const syncFile = (path: string): void => {
|
|
49
|
+
const fd = openSync(path, 'r')
|
|
50
|
+
try {
|
|
51
|
+
fsyncSync(fd)
|
|
52
|
+
} finally {
|
|
53
|
+
closeSync(fd)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const syncDirectory = (path: string): void => {
|
|
58
|
+
let fd: undefined | number
|
|
59
|
+
try {
|
|
60
|
+
fd = openSync(dirname(resolve(path)), 'r')
|
|
61
|
+
fsyncSync(fd)
|
|
62
|
+
} catch {
|
|
63
|
+
// Some platforms do not permit opening/fsyncing a directory. The fully
|
|
64
|
+
// written database itself is still synced before publication.
|
|
65
|
+
} finally {
|
|
66
|
+
if (fd !== undefined) closeSync(fd)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const publish = (temporary: string, destination: string, force: boolean): void => {
|
|
71
|
+
const target = resolve(destination)
|
|
72
|
+
if (force) {
|
|
73
|
+
renameSync(temporary, target)
|
|
74
|
+
syncDirectory(target)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
// A hard link publishes the fully written inode atomically and fails if a
|
|
78
|
+
// racing process created the destination after the initial existence check.
|
|
79
|
+
linkSync(temporary, target)
|
|
80
|
+
rmSync(temporary)
|
|
81
|
+
syncDirectory(target)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const samePath = (left: string, right: string): boolean =>
|
|
85
|
+
existsSync(left) && existsSync(right) ?
|
|
86
|
+
realpathSync(left) === realpathSync(right) :
|
|
87
|
+
resolve(left) === resolve(right)
|
|
88
|
+
|
|
89
|
+
/** Validate a standalone snapshot without migrating or mutating it. */
|
|
90
|
+
export const verifyBackup = (path: string, expectedSha256?: string): Snapshot => {
|
|
91
|
+
const target = resolve(path)
|
|
92
|
+
if (!existsSync(target)) {
|
|
93
|
+
throw new Error(`CAVE backup: ${target}: no such file`)
|
|
94
|
+
}
|
|
95
|
+
const db = nodeSqliteAdapter.open(target, { readOnly: true })
|
|
96
|
+
let schemaVersion = 0
|
|
97
|
+
let rows = 0
|
|
98
|
+
let maxTx: null | string = null
|
|
99
|
+
try {
|
|
100
|
+
const integrity = db.prepare('PRAGMA integrity_check').all() as { integrity_check: string }[]
|
|
101
|
+
if (integrity.length !== 1 || integrity[0]?.integrity_check !== 'ok') {
|
|
102
|
+
throw new Error(`integrity_check failed: ${integrity.map(row => row.integrity_check).join('; ')}`)
|
|
103
|
+
}
|
|
104
|
+
const foreignKeys = db.prepare('PRAGMA foreign_key_check').all()
|
|
105
|
+
if (foreignKeys.length > 0) {
|
|
106
|
+
throw new Error(`foreign_key_check failed for ${foreignKeys.length} row(s)`)
|
|
107
|
+
}
|
|
108
|
+
schemaVersion = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version
|
|
109
|
+
if (schemaVersion !== Schema.currentVersion) {
|
|
110
|
+
throw new Error(`schema version ${schemaVersion} is not the exact-backup format ${Schema.currentVersion}`)
|
|
111
|
+
}
|
|
112
|
+
Schema.validate(db, schemaVersion)
|
|
113
|
+
const summary = db.prepare('SELECT COUNT(*) AS rows, MAX(tx) AS max_tx FROM cave_claim').get() as
|
|
114
|
+
{ rows: number, max_tx: null | string }
|
|
115
|
+
rows = summary.rows
|
|
116
|
+
maxTx = summary.max_tx
|
|
117
|
+
} finally {
|
|
118
|
+
db.close()
|
|
119
|
+
}
|
|
120
|
+
const sha256 = hashFile(target)
|
|
121
|
+
if (expectedSha256 !== undefined && sha256 !== expectedSha256.toLowerCase()) {
|
|
122
|
+
throw new Error(`CAVE backup: SHA-256 mismatch: expected ${expectedSha256.toLowerCase()}, got ${sha256}`)
|
|
123
|
+
}
|
|
124
|
+
return { path: target, bytes: statSync(target).size, sha256, schemaVersion, rows, maxTx }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Create and atomically publish a verified, point-in-time SQLite snapshot. */
|
|
128
|
+
export const backup = (store: Store, destination: string, options: WriteOptions = {}): Snapshot => {
|
|
129
|
+
const target = resolve(destination)
|
|
130
|
+
const capability = store.adapter.capabilities.backup
|
|
131
|
+
if (capability === undefined) {
|
|
132
|
+
throw new Error(`CAVE backup: SQLite adapter ${JSON.stringify(store.adapter.name)} does not support exact snapshots`)
|
|
133
|
+
}
|
|
134
|
+
const source = capability.location(store.db)
|
|
135
|
+
if (source !== null && samePath(source, target)) {
|
|
136
|
+
throw new Error('CAVE backup: destination is the source database')
|
|
137
|
+
}
|
|
138
|
+
if (existsSync(target) && options.force !== true) {
|
|
139
|
+
throw new Error(`CAVE backup: ${target} already exists; pass force to replace it`)
|
|
140
|
+
}
|
|
141
|
+
if (capability.inTransaction(store.db)) {
|
|
142
|
+
throw new Error('CAVE backup: cannot snapshot inside an open transaction')
|
|
143
|
+
}
|
|
144
|
+
const temporary = temporaryPath(target)
|
|
145
|
+
try {
|
|
146
|
+
capability.write(store.db, temporary)
|
|
147
|
+
syncFile(temporary)
|
|
148
|
+
const checked = verifyBackup(temporary)
|
|
149
|
+
publish(temporary, target, options.force === true)
|
|
150
|
+
return { ...checked, path: target }
|
|
151
|
+
} catch (error) {
|
|
152
|
+
rmSync(temporary, { force: true })
|
|
153
|
+
throw error
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Verify a snapshot, then atomically restore its exact bytes to a stopped destination. */
|
|
158
|
+
export const restoreBackup = (
|
|
159
|
+
snapshotPath: string,
|
|
160
|
+
destination: string,
|
|
161
|
+
options: WriteOptions & { expectedSha256?: string } = {}
|
|
162
|
+
): Snapshot => {
|
|
163
|
+
const source = resolve(snapshotPath)
|
|
164
|
+
const target = resolve(destination)
|
|
165
|
+
if (samePath(source, target)) {
|
|
166
|
+
throw new Error('CAVE restore: snapshot and destination are the same file')
|
|
167
|
+
}
|
|
168
|
+
if (existsSync(target) && options.force !== true) {
|
|
169
|
+
throw new Error(`CAVE restore: ${target} already exists; pass force to replace it`)
|
|
170
|
+
}
|
|
171
|
+
for (const suffix of ['-wal', '-shm', '-journal']) {
|
|
172
|
+
if (existsSync(`${target}${suffix}`)) {
|
|
173
|
+
throw new Error(`CAVE restore: refusing while ${target}${suffix} exists; stop all users and remove stale sidecars`)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const checked = verifyBackup(source, options.expectedSha256)
|
|
177
|
+
const temporary = temporaryPath(target)
|
|
178
|
+
try {
|
|
179
|
+
copyFileSync(source, temporary)
|
|
180
|
+
syncFile(temporary)
|
|
181
|
+
const copied = verifyBackup(temporary, checked.sha256)
|
|
182
|
+
publish(temporary, target, options.force === true)
|
|
183
|
+
return { ...copied, path: target }
|
|
184
|
+
} catch (error) {
|
|
185
|
+
rmSync(temporary, { force: true })
|
|
186
|
+
throw error
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -14,8 +14,16 @@
|
|
|
14
14
|
|
|
15
15
|
export * as Resolve from './resolve.ts'
|
|
16
16
|
export * as Row from './row.ts'
|
|
17
|
+
export * as QuerySql from './query-sql.ts'
|
|
18
|
+
export * as Record from './record.ts'
|
|
19
|
+
export * as Provenance from './provenance.ts'
|
|
17
20
|
export * as Schema from './schema.ts'
|
|
21
|
+
export * as Sensitivity from './sensitivity.ts'
|
|
22
|
+
export { backup, restoreBackup, verifyBackup } from './backup.ts'
|
|
23
|
+
export type { Snapshot as BackupSnapshot, WriteOptions as BackupWriteOptions } from './backup.ts'
|
|
18
24
|
export { open } from './open.ts'
|
|
19
25
|
export type { Store } from './open.ts'
|
|
20
26
|
export { defaultDbPath } from './store.ts'
|
|
21
27
|
export type { AppendOptions, ForwardFact, IngestResult, ReverseFact, TraverseOptions } from './store.ts'
|
|
28
|
+
export type { Dimension as ProvenanceDimension, Input as ProvenanceInput, t as ProvenanceRecord } from './provenance.ts'
|
|
29
|
+
export type { Level as SensitivityLevel } from './sensitivity.ts'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Node.js builtin SQLite implementation of the CAVE adapter contract. */
|
|
2
|
+
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
4
|
+
import type { Adapter, Database } from './adapter.ts'
|
|
5
|
+
|
|
6
|
+
const asNodeDatabase = (db: Database): DatabaseSync => db as DatabaseSync
|
|
7
|
+
|
|
8
|
+
export const nodeSqliteAdapter: Adapter = {
|
|
9
|
+
name: 'node:sqlite',
|
|
10
|
+
capabilities: {
|
|
11
|
+
transactions: { immediate: true, savepoints: true },
|
|
12
|
+
fullText: 'fts5',
|
|
13
|
+
loadExtension: (db, path) => asNodeDatabase(db).loadExtension(path),
|
|
14
|
+
backup: {
|
|
15
|
+
location: db => asNodeDatabase(db).location(),
|
|
16
|
+
inTransaction: db => asNodeDatabase(db).isTransaction,
|
|
17
|
+
write: (db, destination) => {
|
|
18
|
+
db.prepare('VACUUM INTO ?').run(destination)
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
open: (path, options = {}) => new DatabaseSync(path, options),
|
|
23
|
+
}
|
package/src/open.ts
CHANGED
|
@@ -1,82 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import type { Store as BaseStore } from './store.ts'
|
|
1
|
+
import { nodeSqliteAdapter } from './node-adapter.ts'
|
|
2
|
+
import { openWith } from './runtime.ts'
|
|
3
|
+
import type { OpenOptions, Store } from './runtime.ts'
|
|
5
4
|
|
|
6
|
-
export type Store
|
|
7
|
-
/**
|
|
8
|
-
* Verb registry reconstructed from the configured base registry plus
|
|
9
|
-
* declaration rows visible at the transaction-time boundary.
|
|
10
|
-
*/
|
|
11
|
-
readonly registryAsOf: (asOf: string) => Canonical.Registry.t
|
|
12
|
-
}
|
|
5
|
+
export type { Store } from './runtime.ts'
|
|
13
6
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
type Declaration = {
|
|
19
|
-
readonly subject: string
|
|
20
|
-
readonly verb: string
|
|
21
|
-
readonly object: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const upperTxBound = (text: string): string => {
|
|
25
|
-
const hasTime = text.includes('T')
|
|
26
|
-
const start = Date.parse(hasTime ? text : `${text}T00:00:00Z`)
|
|
27
|
-
if (Number.isNaN(start)) {
|
|
28
|
-
throw new Error(`CAVE: cannot parse as-of boundary ${JSON.stringify(text)}`)
|
|
29
|
-
}
|
|
30
|
-
const end = start + (hasTime ? 1_000 : 86_400_000)
|
|
31
|
-
return Uuidv7.at(end, 0, new Uint8Array(8))
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const declarationsAsOf = (store: BaseStore, asOf: string): Declaration[] => {
|
|
35
|
-
const id = asOf.toLowerCase()
|
|
36
|
-
const [condition, boundary] = Uuidv7.is(id) ?
|
|
37
|
-
['tx <= ?', id] as const :
|
|
38
|
-
['tx < ?', upperTxBound(asOf)] as const
|
|
39
|
-
return store.db.prepare(`
|
|
40
|
-
SELECT subject, verb, object FROM cave_claim
|
|
41
|
-
WHERE ${condition}
|
|
42
|
-
AND negated = 0 AND object IS NOT NULL AND verb IN ('REVERSE', 'IS')
|
|
43
|
-
AND id NOT IN (SELECT child_id FROM cave_edge WHERE role IN ('WHEN', 'VIA', 'BECAUSE'))
|
|
44
|
-
ORDER BY tx
|
|
45
|
-
`).all(boundary) as Declaration[]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const applyDeclarations = (
|
|
49
|
-
baseRegistry: Canonical.Registry.t,
|
|
50
|
-
declarations: readonly Declaration[]
|
|
51
|
-
): Canonical.Registry.t => {
|
|
52
|
-
let registry = baseRegistry
|
|
53
|
-
for (const declaration of declarations) {
|
|
54
|
-
if (!Verb.isVerbToken(declaration.subject)) {
|
|
55
|
-
continue
|
|
56
|
-
}
|
|
57
|
-
if (declaration.verb === 'REVERSE' && Verb.isVerbToken(declaration.object)) {
|
|
58
|
-
registry = Canonical.Registry.declareReverse(
|
|
59
|
-
registry,
|
|
60
|
-
declaration.subject,
|
|
61
|
-
declaration.object
|
|
62
|
-
).registry
|
|
63
|
-
} else if (declaration.verb === 'IS' && declaration.object === 'verb') {
|
|
64
|
-
registry = Canonical.Registry.declareVerb(registry, declaration.subject)
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return registry
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Public store constructor. It retains the configured base registry so
|
|
72
|
-
* transaction-time query views can rebuild vocabulary at the same boundary
|
|
73
|
-
* as their claim rows.
|
|
74
|
-
*/
|
|
75
|
-
export const open = (path: string = ':memory:', options: OpenOptions = {}): Store => {
|
|
76
|
-
const baseRegistry = options.registry ?? Canonical.standardRegistry
|
|
77
|
-
const store = openStore(path, options)
|
|
78
|
-
return Object.assign(store, {
|
|
79
|
-
registryAsOf: (asOf: string): Canonical.Registry.t =>
|
|
80
|
-
applyDeclarations(baseRegistry, declarationsAsOf(store, asOf))
|
|
81
|
-
})
|
|
82
|
-
}
|
|
7
|
+
/** Open a CAVE store with the Node.js builtin SQLite adapter. */
|
|
8
|
+
export const open = (path: string = ':memory:', options: OpenOptions = {}): Store =>
|
|
9
|
+
openWith(nodeSqliteAdapter, path, options)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Explicit provenance dimensions derived alongside compact claim contexts. */
|
|
2
|
+
|
|
3
|
+
import type { Database } from './adapter.ts'
|
|
4
|
+
import { SourceSpan } from '@cavelang/core'
|
|
5
|
+
|
|
6
|
+
export type Dimension = 'actor' | 'source' | 'run' | 'domain'
|
|
7
|
+
|
|
8
|
+
export type t = {
|
|
9
|
+
readonly actors: readonly string[]
|
|
10
|
+
readonly sources: readonly string[]
|
|
11
|
+
readonly runs: readonly string[]
|
|
12
|
+
readonly domains: readonly string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Input = {
|
|
16
|
+
readonly actor?: string
|
|
17
|
+
readonly sources?: readonly string[]
|
|
18
|
+
readonly run?: string
|
|
19
|
+
readonly domains?: readonly string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Entry = {
|
|
23
|
+
readonly dimension: Dimension
|
|
24
|
+
readonly value: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const runPrefixes = ['connect/', 'rule/', 'action/', 'automation/'] as const
|
|
28
|
+
|
|
29
|
+
const actorSource = (source: string): boolean =>
|
|
30
|
+
source === 'cli' || source === 'sync' || source === 'ingest' || source.startsWith('ingest/') ||
|
|
31
|
+
source.startsWith('agent/') || source.startsWith('suggest/') ||
|
|
32
|
+
source.startsWith('cave-') || runPrefixes.some(prefix => source.startsWith(prefix))
|
|
33
|
+
|
|
34
|
+
const contextEntries = (contexts: readonly string[], authoredSources: boolean): Entry[] => {
|
|
35
|
+
const result: Entry[] = []
|
|
36
|
+
for (const context of contexts) {
|
|
37
|
+
const reference = SourceSpan.parse(context)
|
|
38
|
+
if (reference !== undefined) {
|
|
39
|
+
if (authoredSources) {
|
|
40
|
+
result.push({ dimension: 'source', value: reference.source })
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
if (runPrefixes.some(prefix => reference.source.startsWith(prefix))) {
|
|
44
|
+
result.push(
|
|
45
|
+
{ dimension: 'actor' as const, value: reference.source },
|
|
46
|
+
{ dimension: 'run' as const, value: reference.source }
|
|
47
|
+
)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
result.push(actorSource(reference.source) ?
|
|
51
|
+
{ dimension: 'actor', value: reference.source } :
|
|
52
|
+
{ dimension: 'source', value: reference.source })
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if (context.startsWith('scope:') && context.length > 'scope:'.length) {
|
|
56
|
+
result.push({ dimension: 'domain', value: context.slice('scope:'.length) })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const clean = (values: readonly (undefined | string)[]): string[] =>
|
|
63
|
+
[...new Set(values.filter((value): value is string => value !== undefined && value !== ''))]
|
|
64
|
+
|
|
65
|
+
export const entries = (contexts: readonly string[], input: Input = {}): Entry[] => {
|
|
66
|
+
// With an explicit actor, every src: in `contexts` was authored before
|
|
67
|
+
// compatibility stamping and therefore names evidence. During replay or
|
|
68
|
+
// migration, established actor/run prefixes are inferred conservatively.
|
|
69
|
+
const inferred = contextEntries(contexts, input.actor !== undefined)
|
|
70
|
+
return [
|
|
71
|
+
...clean([input.actor, ...inferred.filter(entry => entry.dimension === 'actor').map(entry => entry.value)])
|
|
72
|
+
.map(value => ({ dimension: 'actor' as const, value })),
|
|
73
|
+
...clean([...(input.sources ?? []), ...inferred.filter(entry => entry.dimension === 'source').map(entry => entry.value)])
|
|
74
|
+
.map(value => ({ dimension: 'source' as const, value })),
|
|
75
|
+
...clean([input.run, ...inferred.filter(entry => entry.dimension === 'run').map(entry => entry.value)])
|
|
76
|
+
.map(value => ({ dimension: 'run' as const, value })),
|
|
77
|
+
...clean([...(input.domains ?? []), ...inferred.filter(entry => entry.dimension === 'domain').map(entry => entry.value)])
|
|
78
|
+
.map(value => ({ dimension: 'domain' as const, value }))
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const fromEntries = (values: readonly Entry[]): t => ({
|
|
83
|
+
actors: values.filter(entry => entry.dimension === 'actor').map(entry => entry.value),
|
|
84
|
+
sources: values.filter(entry => entry.dimension === 'source').map(entry => entry.value),
|
|
85
|
+
runs: values.filter(entry => entry.dimension === 'run').map(entry => entry.value),
|
|
86
|
+
domains: values.filter(entry => entry.dimension === 'domain').map(entry => entry.value)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
/** Idempotently derives dimensions for rows written by older CAVE versions. */
|
|
90
|
+
export const backfill = (db: Database): void => {
|
|
91
|
+
const missing = db.prepare(`
|
|
92
|
+
SELECT c.id, ctx.context FROM cave_claim c
|
|
93
|
+
LEFT JOIN cave_context ctx ON ctx.claim_id = c.id
|
|
94
|
+
WHERE NOT EXISTS (SELECT 1 FROM cave_provenance p WHERE p.claim_id = c.id)
|
|
95
|
+
ORDER BY c.tx, ctx.rowid
|
|
96
|
+
`).all() as { id: string, context: null | string }[]
|
|
97
|
+
const grouped = new Map<string, string[]>()
|
|
98
|
+
for (const row of missing) {
|
|
99
|
+
grouped.set(row.id, [...grouped.get(row.id) ?? [], ...row.context === null ? [] : [row.context]])
|
|
100
|
+
}
|
|
101
|
+
const insert = db.prepare(`
|
|
102
|
+
INSERT OR IGNORE INTO cave_provenance (claim_id, dimension, value) VALUES (?, ?, ?)
|
|
103
|
+
`)
|
|
104
|
+
for (const [id, contexts] of grouped) {
|
|
105
|
+
for (const entry of entries(contexts)) {
|
|
106
|
+
insert.run(id, entry.dimension, entry.value)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/query-sql.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable SQL fragments for CAVE's shared read semantics.
|
|
3
|
+
*
|
|
4
|
+
* These functions return SQL only; callers own the outer SELECT, ordering,
|
|
5
|
+
* and additional predicates. Transaction boundaries contain only validated
|
|
6
|
+
* UUIDv7 text generated here, so their literal form remains safe inside
|
|
7
|
+
* nested CTEs where positional-parameter ordering would be fragile.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Time, Uuidv7 } from '@cavelang/core'
|
|
11
|
+
import * as Row from './row.ts'
|
|
12
|
+
|
|
13
|
+
export type TransactionBounds = {
|
|
14
|
+
readonly lo: string
|
|
15
|
+
readonly hi: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type AsOfBoundary = {
|
|
19
|
+
readonly operator: '<' | '<='
|
|
20
|
+
readonly tx: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** UUIDv7 interval `[lo, hi)` for a UTC period or one-second timestamp. */
|
|
24
|
+
export const transactionBounds = (text: string): TransactionBounds | undefined => {
|
|
25
|
+
const boundary = Time.parseBoundary(text)
|
|
26
|
+
if (boundary === undefined) return undefined
|
|
27
|
+
return {
|
|
28
|
+
lo: Uuidv7.at(boundary.start, 0, new Uint8Array(8)),
|
|
29
|
+
hi: Uuidv7.at(boundary.end, 0, new Uint8Array(8)),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Inclusive transaction boundary: an exact UUID includes that append; a
|
|
35
|
+
* period/timestamp includes its whole UTC period/second through an exclusive high
|
|
36
|
+
* bound. Returns `undefined` for text that is neither form.
|
|
37
|
+
*/
|
|
38
|
+
export const asOfBoundary = (text: string): AsOfBoundary | undefined => {
|
|
39
|
+
const id = text.toLowerCase()
|
|
40
|
+
if (Uuidv7.is(id)) return { operator: '<=', tx: id }
|
|
41
|
+
const bounds = transactionBounds(text)
|
|
42
|
+
return bounds === undefined ? undefined : { operator: '<', tx: bounds.hi }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A validated as-of predicate, optionally qualified with a table alias. */
|
|
46
|
+
export const asOfCondition = (boundary: AsOfBoundary, column = 'tx'): string =>
|
|
47
|
+
`${column} ${boundary.operator} '${boundary.tx}'`
|
|
48
|
+
|
|
49
|
+
/** Every claim, optionally bounded in transaction time. */
|
|
50
|
+
export const claims = (boundary?: AsOfBoundary): string =>
|
|
51
|
+
boundary === undefined ? 'cave_claim' :
|
|
52
|
+
`(SELECT * FROM cave_claim WHERE ${asOfCondition(boundary)})`
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Latest transaction per claim key. `source` selects the eligible
|
|
56
|
+
* `cave_claim` rows (for example `claims(asOfBoundary(...))`); the outer row
|
|
57
|
+
* comes from the base table so the source fragment appears only once.
|
|
58
|
+
*/
|
|
59
|
+
export const current = (source: string = claims()): string => `
|
|
60
|
+
SELECT c.* FROM cave_claim c
|
|
61
|
+
JOIN (
|
|
62
|
+
SELECT claim_key, MAX(tx) AS max_tx
|
|
63
|
+
FROM ${source} GROUP BY claim_key
|
|
64
|
+
) latest ON c.claim_key = latest.claim_key AND c.tx = latest.max_tx
|
|
65
|
+
`
|
|
66
|
+
|
|
67
|
+
/** Current, positive, entity-to-entity ALIAS edges in both directions. */
|
|
68
|
+
export const aliasEdges = (currentSql: string = current()): string => `alias_edge(a, b) AS (
|
|
69
|
+
SELECT c.subject, c.object FROM (${currentSql}) c
|
|
70
|
+
WHERE c.verb = 'ALIAS' AND c.negated = 0 AND c.conf > 0 AND c.object IS NOT NULL
|
|
71
|
+
AND ${Row.entityTermSql('c.subject')} AND ${Row.entityTermSql('c.object')}
|
|
72
|
+
UNION
|
|
73
|
+
SELECT c.object, c.subject FROM (${currentSql}) c
|
|
74
|
+
WHERE c.verb = 'ALIAS' AND c.negated = 0 AND c.conf > 0 AND c.object IS NOT NULL
|
|
75
|
+
AND ${Row.entityTermSql('c.subject')} AND ${Row.entityTermSql('c.object')}
|
|
76
|
+
)`
|
|
77
|
+
|
|
78
|
+
/** Full transitive alias closure as ordered pairs. Requires `WITH RECURSIVE`. */
|
|
79
|
+
export const aliasPairs = (currentSql: string = current()): string => `${aliasEdges(currentSql)},
|
|
80
|
+
alias_pair(a, b) AS (
|
|
81
|
+
SELECT a, b FROM alias_edge
|
|
82
|
+
UNION
|
|
83
|
+
SELECT p.a, e.b FROM alias_pair p JOIN alias_edge e ON e.a = p.b
|
|
84
|
+
)`
|
|
85
|
+
|
|
86
|
+
/** Seeded alias closure. Its first SELECT consumes one positional parameter. */
|
|
87
|
+
export const aliasSeed = (): string => `alias_closure(name) AS (
|
|
88
|
+
SELECT ?
|
|
89
|
+
UNION
|
|
90
|
+
SELECT e.b FROM alias_closure s JOIN alias_edge e ON e.a = s.name
|
|
91
|
+
)`
|
|
92
|
+
|
|
93
|
+
/** Ready-to-prefix seeded closure; the caller supplies the seed parameter. */
|
|
94
|
+
export const aliasClosure = (currentSql: string = current()): string =>
|
|
95
|
+
`WITH RECURSIVE ${aliasEdges(currentSql)}, ${aliasSeed()}\n`
|
|
96
|
+
|
|
97
|
+
/** SQL equality widened through an `alias_pair` CTE already in scope. */
|
|
98
|
+
export const aliasSame = (left: string, right: string): string =>
|
|
99
|
+
`(${left} = ${right} OR EXISTS (SELECT 1 FROM alias_pair p WHERE p.a = ${left} AND p.b = ${right}))`
|
package/src/record.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Versioned, storage-independent claim and transaction records. */
|
|
2
|
+
|
|
3
|
+
import { Key, Uuidv7 } from '@cavelang/core'
|
|
4
|
+
import type { Claim } from '@cavelang/core'
|
|
5
|
+
import { emitClaim } from '@cavelang/canonical'
|
|
6
|
+
import type * as Provenance from './provenance.ts'
|
|
7
|
+
import type * as Row from './row.ts'
|
|
8
|
+
|
|
9
|
+
export const format = 'cave.claim' as const
|
|
10
|
+
export const version = 1 as const
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Public JSON representation. It deliberately contains semantic domain
|
|
14
|
+
* values rather than `cave_claim` column names, so storage migrations do not
|
|
15
|
+
* silently revise serialized APIs.
|
|
16
|
+
*/
|
|
17
|
+
export type V1 = {
|
|
18
|
+
readonly format: typeof format
|
|
19
|
+
readonly version: typeof version
|
|
20
|
+
readonly id: string
|
|
21
|
+
readonly tx: string
|
|
22
|
+
readonly key: string
|
|
23
|
+
/** Canonical primary-direction CAVE text. */
|
|
24
|
+
readonly canonical: string
|
|
25
|
+
/** Semantic claim; `claim.raw` retains the authored spelling. */
|
|
26
|
+
readonly claim: Claim.t
|
|
27
|
+
readonly provenance: Provenance.t
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type t = V1
|
|
31
|
+
|
|
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
|
+
})
|
|
42
|
+
|
|
43
|
+
const object = (value: unknown): value is Record<string, unknown> =>
|
|
44
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
45
|
+
|
|
46
|
+
const strings = (value: unknown): value is readonly string[] =>
|
|
47
|
+
Array.isArray(value) && value.every(entry => typeof entry === 'string')
|
|
48
|
+
|
|
49
|
+
/** Decode a persisted v1 fixture, rejecting unknown future formats loudly. */
|
|
50
|
+
export const decode = (input: string | unknown): t => {
|
|
51
|
+
const value: unknown = typeof input === 'string' ? JSON.parse(input) : input
|
|
52
|
+
if (!object(value) || value['format'] !== format) {
|
|
53
|
+
throw new Error(`CAVE record: expected format ${JSON.stringify(format)}`)
|
|
54
|
+
}
|
|
55
|
+
if (value['version'] !== version) {
|
|
56
|
+
throw new Error(`CAVE record: unsupported ${format} version ${JSON.stringify(value['version'])}`)
|
|
57
|
+
}
|
|
58
|
+
const claim = value['claim']
|
|
59
|
+
const provenance = value['provenance']
|
|
60
|
+
if (typeof value['id'] !== 'string' || typeof value['tx'] !== 'string' ||
|
|
61
|
+
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'])) {
|
|
69
|
+
throw new Error(`CAVE record: malformed ${format}/v${version}`)
|
|
70
|
+
}
|
|
71
|
+
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
|
+
}
|
|
75
|
+
if (Key.of(decoded.claim) !== decoded.key || emitClaim(decoded.claim) !== decoded.canonical) {
|
|
76
|
+
throw new Error(`CAVE record: malformed ${format}/v${version} semantic identity`)
|
|
77
|
+
}
|
|
78
|
+
return decoded
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const encode = (record: t, space?: number): string =>
|
|
82
|
+
JSON.stringify(record, undefined, space)
|
package/src/resolve.ts
CHANGED
|
Binary file
|