@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.
Files changed (79) hide show
  1. package/Authors.md +5 -0
  2. package/License.md +6 -0
  3. package/README.md +121 -14
  4. package/dist/src/adapter-entry.d.ts +9 -0
  5. package/dist/src/adapter-entry.d.ts.map +1 -0
  6. package/dist/src/adapter-entry.js +7 -0
  7. package/dist/src/adapter-entry.js.map +1 -0
  8. package/dist/src/adapter.d.ts +51 -0
  9. package/dist/src/adapter.d.ts.map +1 -0
  10. package/dist/src/adapter.js +3 -0
  11. package/dist/src/adapter.js.map +1 -0
  12. package/dist/src/backup.d.ts +23 -0
  13. package/dist/src/backup.d.ts.map +1 -0
  14. package/dist/src/backup.js +164 -0
  15. package/dist/src/backup.js.map +1 -0
  16. package/dist/src/index.d.ts +8 -0
  17. package/dist/src/index.d.ts.map +1 -1
  18. package/dist/src/index.js +5 -0
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/node-adapter-entry.d.ts +3 -0
  21. package/dist/src/node-adapter-entry.d.ts.map +1 -0
  22. package/dist/src/node-adapter-entry.js +3 -0
  23. package/dist/src/node-adapter-entry.js.map +1 -0
  24. package/dist/src/node-adapter.d.ts +4 -0
  25. package/dist/src/node-adapter.d.ts.map +1 -0
  26. package/dist/src/node-adapter.js +20 -0
  27. package/dist/src/node-adapter.js.map +1 -0
  28. package/dist/src/open.d.ts +3 -18
  29. package/dist/src/open.d.ts.map +1 -1
  30. package/dist/src/open.js +4 -52
  31. package/dist/src/open.js.map +1 -1
  32. package/dist/src/provenance.d.ts +24 -0
  33. package/dist/src/provenance.d.ts.map +1 -0
  34. package/dist/src/provenance.js +75 -0
  35. package/dist/src/provenance.js.map +1 -0
  36. package/dist/src/query-sql.d.ts +45 -0
  37. package/dist/src/query-sql.d.ts.map +1 -0
  38. package/dist/src/query-sql.js +77 -0
  39. package/dist/src/query-sql.js.map +1 -0
  40. package/dist/src/record.d.ts +29 -0
  41. package/dist/src/record.d.ts.map +1 -0
  42. package/dist/src/record.js +50 -0
  43. package/dist/src/record.js.map +1 -0
  44. package/dist/src/resolve.d.ts +2 -2
  45. package/dist/src/resolve.d.ts.map +1 -1
  46. package/dist/src/resolve.js +0 -0
  47. package/dist/src/resolve.js.map +1 -1
  48. package/dist/src/runtime.d.ts +16 -0
  49. package/dist/src/runtime.d.ts.map +1 -0
  50. package/dist/src/runtime.js +43 -0
  51. package/dist/src/runtime.js.map +1 -0
  52. package/dist/src/schema.d.ts +10 -6
  53. package/dist/src/schema.d.ts.map +1 -1
  54. package/dist/src/schema.js +111 -9
  55. package/dist/src/schema.js.map +1 -1
  56. package/dist/src/sensitivity.d.ts +24 -0
  57. package/dist/src/sensitivity.d.ts.map +1 -0
  58. package/dist/src/sensitivity.js +34 -0
  59. package/dist/src/sensitivity.js.map +1 -0
  60. package/dist/src/store.d.ts +37 -13
  61. package/dist/src/store.d.ts.map +1 -1
  62. package/dist/src/store.js +107 -72
  63. package/dist/src/store.js.map +1 -1
  64. package/package.json +25 -6
  65. package/src/adapter-entry.ts +18 -0
  66. package/src/adapter.ts +57 -0
  67. package/src/backup.ts +188 -0
  68. package/src/index.ts +8 -0
  69. package/src/node-adapter-entry.ts +3 -0
  70. package/src/node-adapter.ts +23 -0
  71. package/src/open.ts +7 -80
  72. package/src/provenance.ts +109 -0
  73. package/src/query-sql.ts +99 -0
  74. package/src/record.ts +82 -0
  75. package/src/resolve.ts +0 -0
  76. package/src/runtime.ts +78 -0
  77. package/src/schema.ts +135 -10
  78. package/src/sensitivity.ts +42 -0
  79. 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,3 @@
1
+ /** Node.js builtin implementation for explicit adapter composition. */
2
+
3
+ export { nodeSqliteAdapter } from './node-adapter.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 { Uuidv7, Verb } from '@cavelang/core'
2
- import * as Canonical from '@cavelang/canonical'
3
- import { open as openStore } from './store.ts'
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 = BaseStore & {
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
- type OpenOptions = {
15
- readonly registry?: Canonical.Registry.t
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
+ }
@@ -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