@cavelang/store 0.1.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 +91 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +17 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/row.d.ts +69 -0
- package/dist/src/row.d.ts.map +1 -0
- package/dist/src/row.js +80 -0
- package/dist/src/row.js.map +1 -0
- package/dist/src/schema.d.ts +9 -0
- package/dist/src/schema.d.ts.map +1 -0
- package/dist/src/schema.js +76 -0
- package/dist/src/schema.js.map +1 -0
- package/dist/src/store.d.ts +124 -0
- package/dist/src/store.d.ts.map +1 -0
- package/dist/src/store.js +266 -0
- package/dist/src/store.js.map +1 -0
- package/package.json +43 -0
- package/src/index.ts +18 -0
- package/src/row.ts +136 -0
- package/src/schema.ts +79 -0
- package/src/store.ts +362 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CAVE store (spec §13) — append-only claim persistence on the Node.js
|
|
3
|
+
* builtin `node:sqlite`.
|
|
4
|
+
*
|
|
5
|
+
* - one row per fact, canonical direction; inverses are query-time views
|
|
6
|
+
* over existing indexes, never materialized rows (spec §13.3);
|
|
7
|
+
* - current belief = latest tx per claim key (spec §9.1, §13.5);
|
|
8
|
+
* - the verb registry is rebuilt from stored in-band declaration claims on
|
|
9
|
+
* open, so a reopened database keeps its inverse vocabulary;
|
|
10
|
+
* - full-text search over subjects, objects, values, comments and raw
|
|
11
|
+
* lines via FTS5.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
15
|
+
import { Claim, Key, Uuidv7, Verb } from '@cavelang/core'
|
|
16
|
+
import * as Canonical from '@cavelang/canonical'
|
|
17
|
+
import * as Row from './row.ts'
|
|
18
|
+
import * as Schema from './schema.ts'
|
|
19
|
+
|
|
20
|
+
const currentSql = `
|
|
21
|
+
SELECT c.* FROM cave_claim c
|
|
22
|
+
JOIN (
|
|
23
|
+
SELECT claim_key, MAX(tx) AS max_tx
|
|
24
|
+
FROM cave_claim GROUP BY claim_key
|
|
25
|
+
) latest ON c.claim_key = latest.claim_key AND c.tx = latest.max_tx
|
|
26
|
+
`
|
|
27
|
+
|
|
28
|
+
export type IngestResult = {
|
|
29
|
+
/** ids of inserted claim rows, in document order. */
|
|
30
|
+
readonly ids: readonly string[]
|
|
31
|
+
readonly edges: number
|
|
32
|
+
readonly problems: readonly Canonical.Problem[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ForwardFact = {
|
|
36
|
+
/** Canonical (primary) verb. */
|
|
37
|
+
readonly verb: string
|
|
38
|
+
readonly target: string
|
|
39
|
+
readonly row: Row.t
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ReverseFact = {
|
|
43
|
+
/** Canonical (primary) verb of the stored row. */
|
|
44
|
+
readonly verb: string
|
|
45
|
+
/** Inverse relation name, `undefined` when none is declared (spec §5.5). */
|
|
46
|
+
readonly rel?: string
|
|
47
|
+
readonly source: string
|
|
48
|
+
readonly row: Row.t
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type TraverseOptions = {
|
|
52
|
+
/** Include `VERB NOT` rows (default `false`). */
|
|
53
|
+
readonly negated?: boolean
|
|
54
|
+
/** Include rows whose current belief is `@ 0%` (default `false`). */
|
|
55
|
+
readonly retracted?: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type Store = ReturnType<typeof open>
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Opens (creating if necessary) a CAVE store. The registry defaults to the
|
|
62
|
+
* standard §5.5 prelude pairs and is extended by any declaration claims
|
|
63
|
+
* already stored; pass `Canonical.Registry.empty` for a declaration-free
|
|
64
|
+
* start.
|
|
65
|
+
*/
|
|
66
|
+
export const open = (path: string = ':memory:', options: { registry?: Canonical.Registry.t } = {}) => {
|
|
67
|
+
const db = new DatabaseSync(path)
|
|
68
|
+
db.exec('PRAGMA foreign_keys = ON')
|
|
69
|
+
Schema.init(db)
|
|
70
|
+
|
|
71
|
+
let registry = options.registry ?? Canonical.standardRegistry
|
|
72
|
+
|
|
73
|
+
const insertClaim = db.prepare(`
|
|
74
|
+
INSERT INTO cave_claim (
|
|
75
|
+
id, tx, subject, verb, negated, object, attribute,
|
|
76
|
+
value_text, value_num, value_unit, value_approx,
|
|
77
|
+
delta_text, delta_num, delta_unit, sigma_level,
|
|
78
|
+
conf, importance, comment, raw_line, claim_key
|
|
79
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
80
|
+
`)
|
|
81
|
+
const insertContext = db.prepare('INSERT INTO cave_context (claim_id, context) VALUES (?, ?)')
|
|
82
|
+
const insertTag = db.prepare('INSERT INTO cave_tag (claim_id, key, value) VALUES (?, ?, ?)')
|
|
83
|
+
const insertEdge = db.prepare('INSERT INTO cave_edge (parent_id, role, child_id) VALUES (?, ?, ?)')
|
|
84
|
+
const insertFts = db.prepare(`
|
|
85
|
+
INSERT INTO cave_fts (claim_id, subject, verb, object, attribute, value_text, comment, raw_line)
|
|
86
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
87
|
+
`)
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Rebuilds in-band declarations from stored claims (ordered by tx),
|
|
91
|
+
* mirroring `@cavelang/canonical`'s `applyDeclarations` predicate exactly —
|
|
92
|
+
* the registry after reopen must equal the registry at close. Qualifier
|
|
93
|
+
* condition rows (children of WHEN/VIA/BECAUSE edges) never declared
|
|
94
|
+
* in-session, so they are excluded here too; `X IS verb` needs a
|
|
95
|
+
* verb-shaped plain-entity subject.
|
|
96
|
+
*/
|
|
97
|
+
const rebuildRegistry = (): void => {
|
|
98
|
+
const declarations = db.prepare(`
|
|
99
|
+
SELECT subject, verb, object FROM cave_claim
|
|
100
|
+
WHERE negated = 0 AND object IS NOT NULL AND verb IN ('REVERSE', 'IS')
|
|
101
|
+
AND id NOT IN (SELECT child_id FROM cave_edge WHERE role IN ('WHEN', 'VIA', 'BECAUSE'))
|
|
102
|
+
ORDER BY tx
|
|
103
|
+
`).all() as { subject: string, verb: string, object: string }[]
|
|
104
|
+
for (const declaration of declarations) {
|
|
105
|
+
if (!Verb.isVerbToken(declaration.subject)) {
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (declaration.verb === 'REVERSE' && Verb.isVerbToken(declaration.object)) {
|
|
109
|
+
registry = Canonical.Registry.declareReverse(registry, declaration.subject, declaration.object).registry
|
|
110
|
+
} else if (declaration.verb === 'IS' && declaration.object === 'verb') {
|
|
111
|
+
registry = Canonical.Registry.declareVerb(registry, declaration.subject)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
rebuildRegistry()
|
|
116
|
+
|
|
117
|
+
const transaction = <T>(body: () => T): T => {
|
|
118
|
+
db.exec('BEGIN')
|
|
119
|
+
try {
|
|
120
|
+
const result = body()
|
|
121
|
+
db.exec('COMMIT')
|
|
122
|
+
return result
|
|
123
|
+
} catch (error) {
|
|
124
|
+
db.exec('ROLLBACK')
|
|
125
|
+
throw error
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Appends a canonicalization result — one row per claim, per-row tx. */
|
|
130
|
+
const insertResult = (result: Canonical.Result): IngestResult =>
|
|
131
|
+
transaction(() => {
|
|
132
|
+
const ids: string[] = []
|
|
133
|
+
for (const entry of result.claims) {
|
|
134
|
+
const claim = entry.claim
|
|
135
|
+
const id = Uuidv7.next()
|
|
136
|
+
const columns = Row.toColumns(claim)
|
|
137
|
+
const rawLine = columns.rawLine === '' ? Canonical.emitClaim(claim) : columns.rawLine
|
|
138
|
+
insertClaim.run(
|
|
139
|
+
id, id,
|
|
140
|
+
columns.subject, columns.verb, columns.negated, columns.object, columns.attribute,
|
|
141
|
+
columns.valueText, columns.valueNum, columns.valueUnit, columns.valueApprox,
|
|
142
|
+
columns.deltaText, columns.deltaNum, columns.deltaUnit, columns.sigmaLevel,
|
|
143
|
+
columns.conf, columns.importance, columns.comment,
|
|
144
|
+
rawLine,
|
|
145
|
+
Key.of(claim)
|
|
146
|
+
)
|
|
147
|
+
for (const context of claim.contexts) {
|
|
148
|
+
insertContext.run(id, context)
|
|
149
|
+
}
|
|
150
|
+
for (const tag of claim.tags) {
|
|
151
|
+
insertTag.run(id, tag.key, tag.value ?? null)
|
|
152
|
+
}
|
|
153
|
+
insertFts.run(
|
|
154
|
+
id, columns.subject, columns.verb, columns.object, columns.attribute,
|
|
155
|
+
columns.valueText, columns.comment, rawLine
|
|
156
|
+
)
|
|
157
|
+
ids.push(id)
|
|
158
|
+
}
|
|
159
|
+
for (const edge of result.edges) {
|
|
160
|
+
insertEdge.run(ids[edge.parent]!, edge.role, ids[edge.child]!)
|
|
161
|
+
}
|
|
162
|
+
registry = result.registry
|
|
163
|
+
return { ids, edges: result.edges.length, problems: result.problems }
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const rows = (sql: string, ...params: (string | number)[]): Row.t[] =>
|
|
167
|
+
db.prepare(sql).all(...params) as unknown as Row.t[]
|
|
168
|
+
|
|
169
|
+
const contextsOf = (id: string): string[] =>
|
|
170
|
+
(db.prepare('SELECT context FROM cave_context WHERE claim_id = ?').all(id) as { context: string }[])
|
|
171
|
+
.map(row => row.context)
|
|
172
|
+
|
|
173
|
+
const tagsOf = (id: string): { key: string, value: null | string }[] =>
|
|
174
|
+
db.prepare('SELECT key, value FROM cave_tag WHERE claim_id = ?').all(id) as { key: string, value: null | string }[]
|
|
175
|
+
|
|
176
|
+
const toClaim = (row: Row.t): Claim.t =>
|
|
177
|
+
Row.toClaim(row, contextsOf(row.id), tagsOf(row.id))
|
|
178
|
+
|
|
179
|
+
const traversalFilter = (options: TraverseOptions): string =>
|
|
180
|
+
(options.negated === true ? '' : ' AND negated = 0') +
|
|
181
|
+
(options.retracted === true ? '' : ' AND conf > 0')
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
/** Raw database handle — used by `@cavelang/query`; treat as read-only. */
|
|
185
|
+
db,
|
|
186
|
+
|
|
187
|
+
/** Current verb registry (input registry + stored + ingested declarations). */
|
|
188
|
+
registry: (): Canonical.Registry.t => registry,
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parses, canonicalizes and appends CAVE text. Lenient by default —
|
|
192
|
+
* problems are returned, valid lines still land (spec §1.6); pass
|
|
193
|
+
* `strict` to throw instead.
|
|
194
|
+
*/
|
|
195
|
+
ingest(text: string, options_: { strict?: boolean } = {}): IngestResult {
|
|
196
|
+
const result = Canonical.canonicalizeText(text, registry)
|
|
197
|
+
if (options_.strict === true && result.problems.length > 0) {
|
|
198
|
+
const detail = result.problems.map(problem => ` line ${problem.line}: ${problem.message}`).join('\n')
|
|
199
|
+
throw new Error(`CAVE ingest failed with ${result.problems.length} problem(s):\n${detail}`)
|
|
200
|
+
}
|
|
201
|
+
return insertResult(result)
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/** Appends an already-canonicalized result. */
|
|
205
|
+
insertResult,
|
|
206
|
+
|
|
207
|
+
/** Latest row per claim key (spec §13.5), oldest first. */
|
|
208
|
+
currentBeliefs(options_: { minConf?: number } = {}): Row.t[] {
|
|
209
|
+
return options_.minConf === undefined ?
|
|
210
|
+
rows(`${currentSql} ORDER BY c.tx`) :
|
|
211
|
+
rows(`${currentSql} WHERE c.conf >= ? ORDER BY c.tx`, options_.minConf)
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
/** Current belief for one claim key, `undefined` if the fact is unknown. */
|
|
215
|
+
currentBelief(claimKey: string): undefined | Row.t {
|
|
216
|
+
const row = db.prepare(
|
|
217
|
+
'SELECT * FROM cave_claim WHERE claim_key = ? ORDER BY tx DESC LIMIT 1'
|
|
218
|
+
).get(claimKey) as undefined | Row.t
|
|
219
|
+
return row
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
/** Full belief series of one claim key, oldest first (spec §9.1). */
|
|
223
|
+
history(claimKey: string): Row.t[] {
|
|
224
|
+
return rows('SELECT * FROM cave_claim WHERE claim_key = ? ORDER BY tx', claimKey)
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
/** All rows about an entity, both directions, newest first (spec §13.5). */
|
|
228
|
+
claimsAbout(entity: string): Row.t[] {
|
|
229
|
+
return rows('SELECT * FROM cave_claim WHERE subject = ? OR object = ? ORDER BY tx DESC', entity, entity)
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
/** Forward reads: current relational facts with `entity` as subject (spec §13.3). */
|
|
233
|
+
forward(entity: string, options_: TraverseOptions = {}): ForwardFact[] {
|
|
234
|
+
return rows(
|
|
235
|
+
`SELECT * FROM (${currentSql}) WHERE subject = ? AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`,
|
|
236
|
+
entity
|
|
237
|
+
).map(row => ({ verb: row.verb, target: row.object!, row }))
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Inverse reads (spec §13.3): current relational facts
|
|
242
|
+
* with `entity` as object, relation named via the registry's inverse
|
|
243
|
+
* when one is declared.
|
|
244
|
+
*/
|
|
245
|
+
reverse(entity: string, options_: TraverseOptions = {}): ReverseFact[] {
|
|
246
|
+
return rows(
|
|
247
|
+
`SELECT * FROM (${currentSql}) WHERE object = ? AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`,
|
|
248
|
+
entity
|
|
249
|
+
).map(row => {
|
|
250
|
+
const rel = Canonical.Registry.inverseOf(registry, row.verb)
|
|
251
|
+
return { verb: row.verb, ...rel === undefined ? {} : { rel }, source: row.subject, row }
|
|
252
|
+
})
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
/** Flat tag (`value` omitted → `value IS NULL`) or scoped tag rows (spec §13.5). */
|
|
256
|
+
byTag(key: string, value?: string): Row.t[] {
|
|
257
|
+
return value === undefined ?
|
|
258
|
+
rows(`
|
|
259
|
+
SELECT c.* FROM cave_claim c JOIN cave_tag t ON c.id = t.claim_id
|
|
260
|
+
WHERE t.key = ? AND t.value IS NULL ORDER BY c.tx`, key) :
|
|
261
|
+
rows(`
|
|
262
|
+
SELECT c.* FROM cave_claim c JOIN cave_tag t ON c.id = t.claim_id
|
|
263
|
+
WHERE t.key = ? AND t.value = ? ORDER BY c.tx`, key, value)
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
/** Rows carrying a context (spec §13.5), newest first. */
|
|
267
|
+
byContext(context: string): Row.t[] {
|
|
268
|
+
return rows(`
|
|
269
|
+
SELECT c.* FROM cave_claim c JOIN cave_context ctx ON c.id = ctx.claim_id
|
|
270
|
+
WHERE ctx.context = ? ORDER BY c.tx DESC`, context)
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
/** Members of a topic — forward `CONTAINS` traversal (spec §11.2). */
|
|
274
|
+
topicMembers(topic: string, options_: TraverseOptions = {}): string[] {
|
|
275
|
+
return rows(
|
|
276
|
+
`SELECT * FROM (${currentSql}) WHERE subject = ? AND verb = 'CONTAINS' AND object IS NOT NULL${traversalFilter(options_)} ORDER BY tx`,
|
|
277
|
+
topic
|
|
278
|
+
).map(row => row.object!)
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
/** Topics containing an entity — the inverse `CONTAINS` read (spec §11.2). */
|
|
282
|
+
topicsOf(entity: string, options_: TraverseOptions = {}): string[] {
|
|
283
|
+
return rows(
|
|
284
|
+
`SELECT * FROM (${currentSql}) WHERE object = ? AND verb = 'CONTAINS'${traversalFilter(options_)} ORDER BY tx`,
|
|
285
|
+
entity
|
|
286
|
+
).map(row => row.subject)
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Full-text search, newest first. The query is treated as a literal
|
|
291
|
+
* phrase by default (safe for terms like `token-expiry`, which FTS5
|
|
292
|
+
* would otherwise parse as a column filter); pass `raw` to use full
|
|
293
|
+
* FTS5 MATCH syntax.
|
|
294
|
+
*/
|
|
295
|
+
search(query: string, options_: { raw?: boolean } = {}): Row.t[] {
|
|
296
|
+
const match = options_.raw === true ? query : `"${query.replaceAll('"', '""')}"`
|
|
297
|
+
return rows(`
|
|
298
|
+
SELECT c.* FROM cave_claim c JOIN cave_fts f ON c.id = f.claim_id
|
|
299
|
+
WHERE cave_fts MATCH ? ORDER BY c.tx DESC`, match)
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
/** Qualifier/grouping edges of a claim row (spec §13.2). */
|
|
303
|
+
edgesOf(parentId: string): { role: string, child: Row.t }[] {
|
|
304
|
+
return (db.prepare(`
|
|
305
|
+
SELECT e.role AS role, c.* FROM cave_edge e JOIN cave_claim c ON c.id = e.child_id
|
|
306
|
+
WHERE e.parent_id = ?`).all(parentId) as unknown as (Row.t & { role: string })[]
|
|
307
|
+
).map(({ role, ...child }) => ({ role, child: child as Row.t }))
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
/** Reconstructs the full canonical claim of a row (side tables included). */
|
|
311
|
+
toClaim,
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Emits the store as canonical CAVE text — all rows in transaction
|
|
315
|
+
* order, or only current beliefs with `current`.
|
|
316
|
+
*
|
|
317
|
+
* In current-only export an edge endpoint may be a superseded row;
|
|
318
|
+
* dropping such edges would silently un-condition current claims and
|
|
319
|
+
* promote orphaned WHEN conditions to top-level facts. Instead each
|
|
320
|
+
* endpoint resolves to the *current row of its claim key*, and the
|
|
321
|
+
* resulting edges are deduplicated.
|
|
322
|
+
*/
|
|
323
|
+
exportText(options_: { current?: boolean } = {}): string {
|
|
324
|
+
const current = options_.current === true
|
|
325
|
+
const claimRows = current ?
|
|
326
|
+
rows(`${currentSql} ORDER BY c.tx`) :
|
|
327
|
+
rows('SELECT * FROM cave_claim ORDER BY tx')
|
|
328
|
+
const indexById = new Map(claimRows.map((row, index) => [row.id, index]))
|
|
329
|
+
let resolve = (id: string): undefined | number => indexById.get(id)
|
|
330
|
+
if (current) {
|
|
331
|
+
const indexByKey = new Map(claimRows.map((row, index) => [row.claim_key, index]))
|
|
332
|
+
const keyById = new Map(
|
|
333
|
+
(db.prepare('SELECT id, claim_key FROM cave_claim').all() as { id: string, claim_key: string }[])
|
|
334
|
+
.map(row => [row.id, row.claim_key])
|
|
335
|
+
)
|
|
336
|
+
resolve = id => indexById.get(id) ?? indexByKey.get(keyById.get(id) ?? '')
|
|
337
|
+
}
|
|
338
|
+
const claims = claimRows.map(row => ({ claim: toClaim(row), line: 0 }))
|
|
339
|
+
const edgeRows = db.prepare('SELECT parent_id, role, child_id FROM cave_edge').all() as
|
|
340
|
+
{ parent_id: string, role: Canonical.EdgeRole, child_id: string }[]
|
|
341
|
+
const seen = new Set<string>()
|
|
342
|
+
const edges = edgeRows.flatMap(edge => {
|
|
343
|
+
const parent = resolve(edge.parent_id)
|
|
344
|
+
const child = resolve(edge.child_id)
|
|
345
|
+
if (parent === undefined || child === undefined || parent === child) {
|
|
346
|
+
return []
|
|
347
|
+
}
|
|
348
|
+
const dedupe = `${parent}|${edge.role}|${child}`
|
|
349
|
+
if (seen.has(dedupe)) {
|
|
350
|
+
return []
|
|
351
|
+
}
|
|
352
|
+
seen.add(dedupe)
|
|
353
|
+
return [{ parent, role: edge.role, child }]
|
|
354
|
+
})
|
|
355
|
+
return Canonical.emit({ claims, edges })
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
close(): void {
|
|
359
|
+
db.close()
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|