@hraness/oh 0.3.1 → 0.4.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 +194 -127
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +811 -97
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/graph.d.ts.map +1 -1
- package/dist/index.js +676 -61
- package/dist/libsql-semantic-v2.d.ts +160 -0
- package/dist/libsql-semantic-v2.d.ts.map +1 -0
- package/dist/libsql.d.ts.map +1 -1
- package/dist/libsql.js +162 -35
- package/dist/memory-page.js +2 -2
- package/dist/memory.d.ts +87 -6
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +1106 -148
- package/dist/operation.d.ts +3 -1
- package/dist/operation.d.ts.map +1 -1
- package/dist/projection-public.js +2 -2
- package/dist/projection-suss.js +2 -2
- package/dist/sdk.js +780 -88
- package/dist/semantic-cloud.d.ts +1 -0
- package/dist/semantic-cloud.d.ts.map +1 -1
- package/dist/semantic-cloud.js +1545 -2
- package/dist/semantic.js +2 -2
- package/dist/sqlite/index.js +1251 -306
- package/dist/sqlite/port.d.ts +31 -3
- package/dist/sqlite/port.d.ts.map +1 -1
- package/dist/sqlite/store.d.ts +18 -2
- package/dist/sqlite/store.d.ts.map +1 -1
- package/dist/store.d.ts +3 -12
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +154 -32
- package/dist/sync.d.ts +7 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +668 -35
- package/package.json +5 -1
- package/skills/oh/SKILL.md +42 -16
- package/spec/README.md +10 -3
- package/spec/manifest.json +5 -1
- package/spec/v1/libsql-semantic-cache-schema-v1.sql +114 -0
- package/spec/v1/libsql-semantic-digest-fixture-v1.json +13 -0
- package/spec/v1/memory.md +134 -16
- package/spec/v1/storage.md +8 -5
- package/spec/v1/store.md +20 -0
- package/spec/v1/sync.md +77 -8
- package/spec/v2/libsql-semantic-cache-schema-v2.sql +175 -0
- package/spec/v2/libsql-semantic-digest-fixture-v2.json +14 -0
- package/spec/v2/manifest.json +11 -0
- package/spec/v2/semantic-cloud.md +106 -0
- package/src/cli.test.ts +53 -1
- package/src/cli.ts +34 -8
- package/src/errors.test.ts +87 -0
- package/src/errors.ts +185 -0
- package/src/graph.ts +2 -2
- package/src/libsql-semantic-v2.test.ts +1114 -0
- package/src/libsql-semantic-v2.ts +1891 -0
- package/src/libsql-semantic.test.ts +50 -1
- package/src/libsql.test.ts +36 -0
- package/src/libsql.ts +26 -5
- package/src/memory.test.ts +1488 -18
- package/src/memory.ts +1199 -122
- package/src/operation.ts +13 -3
- package/src/semantic-cloud.ts +1 -0
- package/src/sqlite/port.test.ts +209 -0
- package/src/sqlite/port.ts +118 -4
- package/src/sqlite/store.test.ts +106 -1
- package/src/sqlite/store.ts +168 -30
- package/src/store.test.ts +12 -0
- package/src/store.ts +30 -20
- package/src/sync.test.ts +570 -2
- package/src/sync.ts +586 -36
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS oh_semantic_schemas (
|
|
2
|
+
version INTEGER PRIMARY KEY,
|
|
3
|
+
name TEXT NOT NULL UNIQUE,
|
|
4
|
+
schema_sha256 TEXT NOT NULL,
|
|
5
|
+
applied_at TEXT NOT NULL
|
|
6
|
+
) STRICT;
|
|
7
|
+
|
|
8
|
+
CREATE TABLE IF NOT EXISTS oh_semantic_isolations (
|
|
9
|
+
isolation_sha256 TEXT PRIMARY KEY,
|
|
10
|
+
authority_id TEXT NOT NULL,
|
|
11
|
+
created_at TEXT NOT NULL
|
|
12
|
+
) STRICT;
|
|
13
|
+
|
|
14
|
+
CREATE TABLE IF NOT EXISTS oh_semantic_vectors (
|
|
15
|
+
isolation_sha256 TEXT NOT NULL,
|
|
16
|
+
profile_sha256 TEXT NOT NULL,
|
|
17
|
+
renderer_sha256 TEXT NOT NULL,
|
|
18
|
+
input_sha256 TEXT NOT NULL,
|
|
19
|
+
vector_sha256 TEXT NOT NULL,
|
|
20
|
+
vector BLOB NOT NULL,
|
|
21
|
+
created_at TEXT NOT NULL,
|
|
22
|
+
PRIMARY KEY(isolation_sha256, profile_sha256, renderer_sha256, input_sha256)
|
|
23
|
+
) STRICT;
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS oh_semantic_generations (
|
|
26
|
+
authority_id TEXT NOT NULL,
|
|
27
|
+
generation INTEGER NOT NULL CHECK(generation >= 0),
|
|
28
|
+
authority_sha256 TEXT NOT NULL,
|
|
29
|
+
isolation_sha256 TEXT NOT NULL,
|
|
30
|
+
profile_sha256 TEXT NOT NULL,
|
|
31
|
+
renderer_sha256 TEXT NOT NULL,
|
|
32
|
+
membership_sha256 TEXT NOT NULL,
|
|
33
|
+
generation_sha256 TEXT NOT NULL UNIQUE,
|
|
34
|
+
document_count INTEGER NOT NULL CHECK(document_count >= 0),
|
|
35
|
+
chunk_count INTEGER NOT NULL CHECK(chunk_count >= 0),
|
|
36
|
+
created_at TEXT NOT NULL,
|
|
37
|
+
PRIMARY KEY(authority_id, generation)
|
|
38
|
+
) STRICT;
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS oh_semantic_memberships (
|
|
41
|
+
authority_id TEXT NOT NULL,
|
|
42
|
+
generation INTEGER NOT NULL CHECK(generation >= 0),
|
|
43
|
+
generation_sha256 TEXT NOT NULL,
|
|
44
|
+
isolation_sha256 TEXT NOT NULL,
|
|
45
|
+
record_key TEXT NOT NULL,
|
|
46
|
+
record_sha256 TEXT NOT NULL,
|
|
47
|
+
ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
|
|
48
|
+
input_sha256 TEXT NOT NULL,
|
|
49
|
+
PRIMARY KEY(authority_id, generation, record_key, ordinal)
|
|
50
|
+
) STRICT;
|
|
51
|
+
|
|
52
|
+
CREATE TABLE IF NOT EXISTS oh_semantic_heads (
|
|
53
|
+
authority_id TEXT PRIMARY KEY,
|
|
54
|
+
generation INTEGER NOT NULL CHECK(generation >= 0),
|
|
55
|
+
authority_sha256 TEXT NOT NULL,
|
|
56
|
+
isolation_sha256 TEXT NOT NULL,
|
|
57
|
+
profile_sha256 TEXT NOT NULL,
|
|
58
|
+
renderer_sha256 TEXT NOT NULL,
|
|
59
|
+
membership_sha256 TEXT NOT NULL,
|
|
60
|
+
generation_sha256 TEXT NOT NULL,
|
|
61
|
+
published_at TEXT NOT NULL
|
|
62
|
+
) STRICT;
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS oh_semantic_purges (
|
|
65
|
+
authority_id TEXT PRIMARY KEY,
|
|
66
|
+
isolation_sha256 TEXT NOT NULL,
|
|
67
|
+
profile_sha256 TEXT NOT NULL,
|
|
68
|
+
published_generation INTEGER CHECK(published_generation IS NULL OR published_generation >= 0),
|
|
69
|
+
published_generation_sha256 TEXT,
|
|
70
|
+
purged_at TEXT NOT NULL,
|
|
71
|
+
purge_marker_sha256 TEXT NOT NULL,
|
|
72
|
+
generation_count INTEGER NOT NULL CHECK(generation_count >= 0),
|
|
73
|
+
membership_count INTEGER NOT NULL CHECK(membership_count >= 0),
|
|
74
|
+
orphan_vector_count INTEGER NOT NULL CHECK(orphan_vector_count >= 0),
|
|
75
|
+
isolation_scope_count INTEGER NOT NULL CHECK(isolation_scope_count >= 0),
|
|
76
|
+
counts_recorded INTEGER NOT NULL CHECK(counts_recorded IN (0, 1))
|
|
77
|
+
) STRICT;
|
|
78
|
+
|
|
79
|
+
CREATE INDEX IF NOT EXISTS oh_semantic_isolations_authority
|
|
80
|
+
ON oh_semantic_isolations(authority_id, isolation_sha256);
|
|
81
|
+
|
|
82
|
+
CREATE INDEX IF NOT EXISTS oh_semantic_memberships_generation
|
|
83
|
+
ON oh_semantic_memberships(authority_id, generation, record_key, ordinal);
|
|
84
|
+
|
|
85
|
+
CREATE INDEX IF NOT EXISTS oh_semantic_memberships_input
|
|
86
|
+
ON oh_semantic_memberships(isolation_sha256, input_sha256);
|
|
87
|
+
|
|
88
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_isolations_no_update
|
|
89
|
+
BEFORE UPDATE ON oh_semantic_isolations
|
|
90
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic isolations are immutable'); END;
|
|
91
|
+
|
|
92
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_isolations_no_delete
|
|
93
|
+
BEFORE DELETE ON oh_semantic_isolations
|
|
94
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic isolations are immutable'); END;
|
|
95
|
+
|
|
96
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_isolations_purge_guard
|
|
97
|
+
BEFORE INSERT ON oh_semantic_isolations
|
|
98
|
+
WHEN EXISTS (SELECT 1 FROM oh_semantic_purges WHERE authority_id = NEW.authority_id)
|
|
99
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic authority was purged'); END;
|
|
100
|
+
|
|
101
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_vectors_no_update
|
|
102
|
+
BEFORE UPDATE ON oh_semantic_vectors
|
|
103
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic vectors are immutable'); END;
|
|
104
|
+
|
|
105
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_vectors_isolation_guard
|
|
106
|
+
BEFORE INSERT ON oh_semantic_vectors
|
|
107
|
+
WHEN NOT EXISTS (SELECT 1 FROM oh_semantic_isolations
|
|
108
|
+
WHERE isolation_sha256 = NEW.isolation_sha256)
|
|
109
|
+
OR EXISTS (SELECT 1 FROM oh_semantic_purges AS purge
|
|
110
|
+
JOIN oh_semantic_isolations AS isolation
|
|
111
|
+
ON isolation.authority_id = purge.authority_id
|
|
112
|
+
WHERE isolation.isolation_sha256 = NEW.isolation_sha256)
|
|
113
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic vector isolation is unavailable'); END;
|
|
114
|
+
|
|
115
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_generations_no_update
|
|
116
|
+
BEFORE UPDATE ON oh_semantic_generations
|
|
117
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic generations are immutable'); END;
|
|
118
|
+
|
|
119
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_memberships_no_update
|
|
120
|
+
BEFORE UPDATE ON oh_semantic_memberships
|
|
121
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic memberships are immutable'); END;
|
|
122
|
+
|
|
123
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_generations_purge_guard
|
|
124
|
+
BEFORE INSERT ON oh_semantic_generations
|
|
125
|
+
WHEN EXISTS (SELECT 1 FROM oh_semantic_purges WHERE authority_id = NEW.authority_id)
|
|
126
|
+
OR NOT EXISTS (SELECT 1 FROM oh_semantic_isolations
|
|
127
|
+
WHERE isolation_sha256 = NEW.isolation_sha256 AND authority_id = NEW.authority_id)
|
|
128
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic authority or isolation is unavailable'); END;
|
|
129
|
+
|
|
130
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_memberships_purge_guard
|
|
131
|
+
BEFORE INSERT ON oh_semantic_memberships
|
|
132
|
+
WHEN EXISTS (SELECT 1 FROM oh_semantic_purges WHERE authority_id = NEW.authority_id)
|
|
133
|
+
OR NOT EXISTS (SELECT 1 FROM oh_semantic_generations
|
|
134
|
+
WHERE authority_id = NEW.authority_id AND generation = NEW.generation
|
|
135
|
+
AND generation_sha256 = NEW.generation_sha256
|
|
136
|
+
AND isolation_sha256 = NEW.isolation_sha256)
|
|
137
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic authority or isolation is unavailable'); END;
|
|
138
|
+
|
|
139
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_memberships_published_guard
|
|
140
|
+
BEFORE INSERT ON oh_semantic_memberships
|
|
141
|
+
WHEN EXISTS (SELECT 1 FROM oh_semantic_heads
|
|
142
|
+
WHERE authority_id = NEW.authority_id AND generation = NEW.generation)
|
|
143
|
+
AND NOT EXISTS (SELECT 1 FROM oh_semantic_memberships
|
|
144
|
+
WHERE authority_id = NEW.authority_id AND generation = NEW.generation
|
|
145
|
+
AND generation_sha256 = NEW.generation_sha256
|
|
146
|
+
AND isolation_sha256 = NEW.isolation_sha256
|
|
147
|
+
AND record_key = NEW.record_key AND record_sha256 = NEW.record_sha256
|
|
148
|
+
AND ordinal = NEW.ordinal AND input_sha256 = NEW.input_sha256)
|
|
149
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic generation is published'); END;
|
|
150
|
+
|
|
151
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_heads_insert_purge_guard
|
|
152
|
+
BEFORE INSERT ON oh_semantic_heads
|
|
153
|
+
WHEN EXISTS (SELECT 1 FROM oh_semantic_purges WHERE authority_id = NEW.authority_id)
|
|
154
|
+
OR NOT EXISTS (SELECT 1 FROM oh_semantic_generations
|
|
155
|
+
WHERE authority_id = NEW.authority_id AND generation = NEW.generation
|
|
156
|
+
AND generation_sha256 = NEW.generation_sha256
|
|
157
|
+
AND isolation_sha256 = NEW.isolation_sha256)
|
|
158
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic authority or isolation is unavailable'); END;
|
|
159
|
+
|
|
160
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_heads_update_purge_guard
|
|
161
|
+
BEFORE UPDATE ON oh_semantic_heads
|
|
162
|
+
WHEN EXISTS (SELECT 1 FROM oh_semantic_purges WHERE authority_id = NEW.authority_id)
|
|
163
|
+
OR NOT EXISTS (SELECT 1 FROM oh_semantic_generations
|
|
164
|
+
WHERE authority_id = NEW.authority_id AND generation = NEW.generation
|
|
165
|
+
AND generation_sha256 = NEW.generation_sha256
|
|
166
|
+
AND isolation_sha256 = NEW.isolation_sha256)
|
|
167
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic authority or isolation is unavailable'); END;
|
|
168
|
+
|
|
169
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_purges_no_update
|
|
170
|
+
BEFORE UPDATE ON oh_semantic_purges
|
|
171
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic purge markers are immutable'); END;
|
|
172
|
+
|
|
173
|
+
CREATE TRIGGER IF NOT EXISTS oh_semantic_purges_no_delete
|
|
174
|
+
BEFORE DELETE ON oh_semantic_purges
|
|
175
|
+
BEGIN SELECT RAISE(ABORT, 'Oh semantic purge markers are immutable'); END;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"authorityId": "fixture:semantic-authority",
|
|
3
|
+
"authoritySha256": "d1eac8dd93ce6e04cb1316c670008250b98ec32cb0555f21d10b782ddffe03a7",
|
|
4
|
+
"content": "fixture body",
|
|
5
|
+
"generation": 7,
|
|
6
|
+
"generationSha256": "5d848fcd482ca72781af87da7b2ff3f68cd5b9ef571495bc734a5a2582c719d1",
|
|
7
|
+
"inputSha256": "b2309e89dd3f903934c7b5f4a4c8c51d5323d84a66928a2f017dd871492ef294",
|
|
8
|
+
"isolationSha256": "217df103dc50da7d3ba2883d9c1cbad01014b2b9eb20e4f94f737291c583d9a5",
|
|
9
|
+
"membershipSha256": "73ec3fcf8af17d99e2ae84878329c834e044bb47ff08ecd0dca6a596504b26b8",
|
|
10
|
+
"recordKey": "memory:fixture",
|
|
11
|
+
"recordSha256": "920d936e47e8f3cc5847017da5975ef73f8d84c04cb113880fb10575eeb614f9",
|
|
12
|
+
"title": "fixture title",
|
|
13
|
+
"v": 2
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cacheSchema": "./libsql-semantic-cache-schema-v2.sql",
|
|
3
|
+
"cacheSchemaSha256": "679edfc3cb02dc768843976093f90d5879d53e84b797ac60d77956a634facae9",
|
|
4
|
+
"digestFixture": "./libsql-semantic-digest-fixture-v2.json",
|
|
5
|
+
"id": "oh.semantic-cloud.v2",
|
|
6
|
+
"legacyCacheSchema": "../v1/libsql-semantic-cache-schema-v1.sql",
|
|
7
|
+
"profile": "../v1/cloudflare-embedding-profile.json",
|
|
8
|
+
"renderer": "../v1/cloudflare-embedding-renderer.json",
|
|
9
|
+
"specification": "./semantic-cloud.md",
|
|
10
|
+
"v": 2
|
|
11
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Hosted semantic cache V2
|
|
2
|
+
|
|
3
|
+
Hosted semantic-cache V2 is the isolated successor to the immutable
|
|
4
|
+
[semantic-cache V1 contract](../v1/semantic-cloud.md). It keeps the same fixed
|
|
5
|
+
Cloudflare EmbeddingGemma profile and renderer, but changes only the rebuildable
|
|
6
|
+
libSQL cache protocol. It does not change an Oh graph, record, operation, local
|
|
7
|
+
embedding, or provider contract.
|
|
8
|
+
|
|
9
|
+
Applications use `bootstrapOhLibSqlSemanticCacheV2` and
|
|
10
|
+
`openOhLibSqlSemanticCacheV2`. The parallel
|
|
11
|
+
`bootstrapOhLibSqlSemanticCacheV1`, `openOhLibSqlSemanticCacheV1`, V1 types,
|
|
12
|
+
V1 result values, and V1 digest preimages remain the released V1 behavior. V1
|
|
13
|
+
and V2 use the same physical table names and therefore cannot be open in one
|
|
14
|
+
database simultaneously. A V1 database can be upgraded to V2; a V2 database
|
|
15
|
+
cannot be opened as V1 or downgraded.
|
|
16
|
+
|
|
17
|
+
The host MUST quiesce V1 stage, publish, head, and search traffic before
|
|
18
|
+
upgrade. An already-open V1 object is not a V2 runtime capability. Bootstrap
|
|
19
|
+
does, however, arbitrate a racing V1 purge: a purge committed before transition
|
|
20
|
+
custody is copied, while a purge that reaches the changed schema cannot report
|
|
21
|
+
success.
|
|
22
|
+
|
|
23
|
+
## Isolation and identity
|
|
24
|
+
|
|
25
|
+
Every V2 authority reference and every stage, publish, head, search, receipt,
|
|
26
|
+
and purge operation binds an `isolationSha256`. It is a validated opaque
|
|
27
|
+
SHA-256 supplied by the host. Private hosts SHOULD derive it from their private
|
|
28
|
+
authority handle, cache epoch, and exact cache profile. When omitted, Oh derives:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{"authorityId":"<authority>","kind":"oh.semantic-authority-isolation.v2","v":2}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
and hashes the canonical JSON. One isolation belongs immutably to one authority,
|
|
35
|
+
while one authority may reserve multiple isolation epochs. The digest never
|
|
36
|
+
enters renderer or provider text.
|
|
37
|
+
|
|
38
|
+
Vector primary identity is
|
|
39
|
+
`(isolation_sha256, profile_sha256, renderer_sha256, input_sha256)`.
|
|
40
|
+
Generations, memberships, and published heads retain the isolation explicitly.
|
|
41
|
+
The membership digest hashes a canonical
|
|
42
|
+
`oh.semantic-membership.v2` envelope; the generation digest hashes an
|
|
43
|
+
`oh.semantic-generation.v2` envelope. Consequently identical rendered text in
|
|
44
|
+
different authorities or epochs is embedded and stored independently. Isolation
|
|
45
|
+
prevents reuse and deletion coupling; it is not encryption and a database holder
|
|
46
|
+
can still observe equal raw input digests or vector bytes.
|
|
47
|
+
|
|
48
|
+
All public V2 values use `v: 2`. Provider and renderer values remain V1 because
|
|
49
|
+
those fixed contracts did not change.
|
|
50
|
+
|
|
51
|
+
## Published reads and purge custody
|
|
52
|
+
|
|
53
|
+
`publishedHead` returns `null` for an isolation mismatch. Search checks the
|
|
54
|
+
same isolation before sending a query to the provider and again while reading
|
|
55
|
+
memberships. Publish requires the staged generation's exact isolation.
|
|
56
|
+
|
|
57
|
+
`purgeAuthority` accepts the current published isolation, writes the permanent
|
|
58
|
+
authority tombstone, deletes every generation and membership for the authority,
|
|
59
|
+
and deletes every vector in every isolation reserved by it. A mismatched current
|
|
60
|
+
isolation is a conflict and authorizes no deletion. The receipt records the
|
|
61
|
+
first execution's counts and returns them unchanged on replay. It contains
|
|
62
|
+
`purgeMarkerSha256`, the canonical
|
|
63
|
+
`oh.semantic-purge-receipt.v2` `purgeReceiptSha256`, and zero
|
|
64
|
+
`residualGenerations`, `residualMemberships`, and
|
|
65
|
+
`residualScopedVectors`. `purgeReceipt` replays the same content-free proof
|
|
66
|
+
without issuing deletes.
|
|
67
|
+
|
|
68
|
+
## V1-to-V2 transition
|
|
69
|
+
|
|
70
|
+
Bootstrap recognizes only the exact published V1 inventory or the exact V2
|
|
71
|
+
inventory. An upgrade first starts one atomic write transaction that:
|
|
72
|
+
|
|
73
|
+
1. creates `oh_semantic_v1_purge_transition`;
|
|
74
|
+
2. copies every V1 authority tombstone into it with one database-side
|
|
75
|
+
`INSERT ... SELECT`;
|
|
76
|
+
3. only after that copy, drops the unsafe globally deduplicated V1 derived rows;
|
|
77
|
+
and
|
|
78
|
+
4. creates the V2 schema without a V2 schema marker.
|
|
79
|
+
|
|
80
|
+
The copy has no lifetime tombstone cap and returns no unbounded row set to the
|
|
81
|
+
host. A concurrent V1 purge either commits before this transaction and is copied,
|
|
82
|
+
or runs afterward against a non-V1 schema and cannot report success. Concurrent
|
|
83
|
+
V2 bootstraps converge on the one transition table.
|
|
84
|
+
|
|
85
|
+
While the transition table exists, `openOhLibSqlSemanticCacheV2` fails closed.
|
|
86
|
+
Bootstrap reads transition rows in fixed pages. Each page atomically creates the
|
|
87
|
+
V2 isolation and receipt for every legacy tombstone and deletes a transition row
|
|
88
|
+
only when the complete computed V2 custody record exists. A crash before a page
|
|
89
|
+
commit changes nothing; a crash after commit can replay safely. Live V1 heads,
|
|
90
|
+
generations, memberships, and vectors are deliberately invalidated because their
|
|
91
|
+
global vector identity cannot be assigned a private owner after the fact.
|
|
92
|
+
|
|
93
|
+
Finalization inserts the exact V2 schema marker only when the transition table
|
|
94
|
+
is empty, then drops that table in the same transaction. Until that commit,
|
|
95
|
+
neither a partially projected database nor a V2 runtime is accepted. Legacy
|
|
96
|
+
receipts use `countsRecorded: false`; new V2 purges use
|
|
97
|
+
`countsRecorded: true`.
|
|
98
|
+
|
|
99
|
+
The exact standalone schemas are
|
|
100
|
+
[revision 1](../v1/libsql-semantic-cache-schema-v1.sql) and
|
|
101
|
+
[revision 2](libsql-semantic-cache-schema-v2.sql). Fixed
|
|
102
|
+
[V1](../v1/libsql-semantic-digest-fixture-v1.json) and
|
|
103
|
+
[V2](libsql-semantic-digest-fixture-v2.json) fixtures make the incompatible
|
|
104
|
+
digest domains explicit. Hosted failure remains a
|
|
105
|
+
missing derived retrieval lane and MUST NOT weaken exact graph or Datalog
|
|
106
|
+
operations.
|
package/src/cli.test.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
|
|
7
|
+
import { canonicalJson, canonicalSha256 } from "./canonical";
|
|
8
|
+
import { createKnowledgeGraphRecordV1 } from "./graph";
|
|
9
|
+
import { createOhOperationV1 } from "./operation";
|
|
10
|
+
import { OhSqliteStore } from "./sqlite/store";
|
|
11
|
+
import { createOhSyncBundleV1, OH_SYNC_BUNDLE_MAX_BYTES_V1 } from "./sync";
|
|
12
|
+
|
|
7
13
|
const roots: string[] = [];
|
|
8
14
|
const CLI_PATH = join(import.meta.dir, "cli.ts");
|
|
9
15
|
const REPOSITORY_ROOT = join(import.meta.dir, "..");
|
|
@@ -100,4 +106,50 @@ describe("oh CLI", () => {
|
|
|
100
106
|
expect((await run(["contract", "--db", database, "--space", "contract.test"], root)).code).toBe(0);
|
|
101
107
|
expect(existsSync(database)).toBe(false);
|
|
102
108
|
});
|
|
109
|
+
|
|
110
|
+
test("imports a sync bundle atomically when a later operation is invalid", async () => {
|
|
111
|
+
const root = await mkdtemp(join(tmpdir(), "oh-cli-atomic-import-test-"));
|
|
112
|
+
roots.push(root);
|
|
113
|
+
const database = join(root, "target.sqlite");
|
|
114
|
+
const bundlePath = join(root, "hostile-bundle.json");
|
|
115
|
+
const source = new OhSqliteStore({ path: ":memory:" });
|
|
116
|
+
const entity = (key: string, name: string) => createKnowledgeGraphRecordV1({
|
|
117
|
+
dependencies: [], key, kind: "entity", v: 1, value: { name },
|
|
118
|
+
});
|
|
119
|
+
source.commit({ actorId: "agent.test", changes: [{ kind: "put",
|
|
120
|
+
record: entity("entity:first", "First"), v: 1 }], expectedHead: source.head(),
|
|
121
|
+
operationId: "op_first" });
|
|
122
|
+
source.commit({ actorId: "agent.test", changes: [{ kind: "put",
|
|
123
|
+
record: entity("entity:second", "Second"), v: 1 }], expectedHead: source.head(),
|
|
124
|
+
operationId: "op_second" });
|
|
125
|
+
const [first, second] = source.exportOperations();
|
|
126
|
+
if (first === undefined || second === undefined) throw new Error("Expected two operations.");
|
|
127
|
+
const { operationSha256: _operationSha256, ...payload } = second;
|
|
128
|
+
const hostileSecond = createOhOperationV1({ ...payload,
|
|
129
|
+
graphRevisionSha256: canonicalSha256("hostile graph revision"),
|
|
130
|
+
recordsSha256: canonicalSha256("hostile records") });
|
|
131
|
+
await writeFile(bundlePath,
|
|
132
|
+
canonicalJson(createOhSyncBundleV1(source.spaceId, [first, hostileSecond])), "utf8");
|
|
133
|
+
source.close();
|
|
134
|
+
|
|
135
|
+
const imported = await run(["sync", "import", "--db", database, "--file", bundlePath]);
|
|
136
|
+
expect(imported.code).toBe(1);
|
|
137
|
+
expect(imported.stderr).toContain("does not reproduce");
|
|
138
|
+
const verified = await run(["verify", "--db", database]);
|
|
139
|
+
expect(verified.code).toBe(0);
|
|
140
|
+
expect(JSON.parse(verified.stdout)).toMatchObject({ operations: 0, records: 0 });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("rejects an oversized sparse sync bundle before opening the database", async () => {
|
|
144
|
+
const root = await mkdtemp(join(tmpdir(), "oh-cli-bounded-import-test-"));
|
|
145
|
+
roots.push(root);
|
|
146
|
+
const database = join(root, "target.sqlite");
|
|
147
|
+
const bundlePath = join(root, "oversized-bundle.json");
|
|
148
|
+
await writeFile(bundlePath, "", "utf8");
|
|
149
|
+
await truncate(bundlePath, OH_SYNC_BUNDLE_MAX_BYTES_V1 + 2);
|
|
150
|
+
const imported = await run(["sync", "import", "--db", database, "--file", bundlePath]);
|
|
151
|
+
expect(imported.code).toBe(1);
|
|
152
|
+
expect(imported.stderr).toContain("regular file of at most");
|
|
153
|
+
expect(existsSync(database)).toBe(false);
|
|
154
|
+
});
|
|
103
155
|
});
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
3
3
|
|
|
4
4
|
import { canonicalJson, opaqueId, safeCode, type JsonValue } from "./canonical";
|
|
5
5
|
import { OH_CONTRACT_MANIFEST_V1 } from "./contract";
|
|
@@ -7,9 +7,9 @@ import { OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1, createKnowledgeGraphRecordV1,
|
|
|
7
7
|
type KnowledgeGraphRecordKindV1, type KnowledgeGraphRecordV1 } from "./graph";
|
|
8
8
|
import { Oh } from "./sdk";
|
|
9
9
|
import { OH_SQLITE_SCHEMA_VERSION } from "./sqlite/migrations";
|
|
10
|
-
import { createOhSyncBundleV1, parseOhSyncBundleV1 } from "./sync";
|
|
10
|
+
import { createOhSyncBundleV1, OH_SYNC_BUNDLE_MAX_BYTES_V1, parseOhSyncBundleV1 } from "./sync";
|
|
11
11
|
|
|
12
|
-
export const OH_PACKAGE_VERSION = "0.
|
|
12
|
+
export const OH_PACKAGE_VERSION = "0.4.0" as const;
|
|
13
13
|
|
|
14
14
|
type ParsedArguments = { options: Map<string, string[]>; positionals: string[] };
|
|
15
15
|
type ValidatedInvocation = Readonly<{
|
|
@@ -174,7 +174,7 @@ async function validateInvocation(command: string, parsed: ParsedArguments): Pro
|
|
|
174
174
|
assertAllowedOptions(parsed, [...GLOBAL_OPTIONS, "file"]);
|
|
175
175
|
const file = one(parsed, "file");
|
|
176
176
|
if (file === undefined) throw new TypeError("sync import needs --file.");
|
|
177
|
-
syncBundle = parseOhSyncBundleV1(
|
|
177
|
+
syncBundle = parseOhSyncBundleV1(await readSyncBundleFile(file));
|
|
178
178
|
if (syncBundle === null) throw new TypeError("Invalid sync bundle.");
|
|
179
179
|
} else {
|
|
180
180
|
throw new TypeError("sync needs export or import.");
|
|
@@ -191,6 +191,21 @@ async function validateInvocation(command: string, parsed: ParsedArguments): Pro
|
|
|
191
191
|
|
|
192
192
|
function print(value: unknown): void { process.stdout.write(`${canonicalJson(value)}\n`); }
|
|
193
193
|
|
|
194
|
+
async function readSyncBundleFile(path: string): Promise<unknown> {
|
|
195
|
+
// `sync export` writes one terminal LF after the bounded canonical bundle.
|
|
196
|
+
const maximumFileBytes = OH_SYNC_BUNDLE_MAX_BYTES_V1 + 1;
|
|
197
|
+
const metadata = await lstat(path);
|
|
198
|
+
if (!metadata.isFile() || !Number.isSafeInteger(metadata.size)
|
|
199
|
+
|| metadata.size > maximumFileBytes) {
|
|
200
|
+
throw new RangeError(`Sync bundle file must be a regular file of at most ${maximumFileBytes} bytes.`);
|
|
201
|
+
}
|
|
202
|
+
const contents = await readFile(path);
|
|
203
|
+
if (contents.byteLength > maximumFileBytes) {
|
|
204
|
+
throw new RangeError(`Sync bundle file must be at most ${maximumFileBytes} bytes.`);
|
|
205
|
+
}
|
|
206
|
+
return JSON.parse(contents.toString("utf8")) as unknown;
|
|
207
|
+
}
|
|
208
|
+
|
|
194
209
|
const HELP = `oh ${OH_PACKAGE_VERSION}
|
|
195
210
|
|
|
196
211
|
Usage:
|
|
@@ -283,14 +298,25 @@ export async function runOhCli(arguments_: readonly string[]): Promise<number> {
|
|
|
283
298
|
if (action === "export") {
|
|
284
299
|
const after = integer(one(parsed, "after"), "after") ?? 0;
|
|
285
300
|
const limit = integer(one(parsed, "limit"), "limit") ?? 1000;
|
|
286
|
-
print(createOhSyncBundleV1(oh.store.spaceId, oh.store.exportOperations(after, limit)
|
|
301
|
+
print(createOhSyncBundleV1(oh.store.spaceId, oh.store.exportOperations(after, limit), {
|
|
302
|
+
largestFittingPrefix: true,
|
|
303
|
+
})); return 0;
|
|
287
304
|
}
|
|
288
305
|
if (action === "import") {
|
|
289
306
|
const bundle = validated.syncBundle;
|
|
290
307
|
if (bundle === null) throw new TypeError("Invalid prepared sync import command.");
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
308
|
+
const first = bundle.operations[0];
|
|
309
|
+
if (first === undefined) {
|
|
310
|
+
print({ head: oh.head(), imported: 0, v: 1 }); return 0;
|
|
311
|
+
}
|
|
312
|
+
const imported = oh.store.importOperations({
|
|
313
|
+
expectedHead: {
|
|
314
|
+
operationSha256: first.parentOperationSha256,
|
|
315
|
+
sequence: first.sequence - 1,
|
|
316
|
+
},
|
|
317
|
+
operations: bundle.operations,
|
|
318
|
+
});
|
|
319
|
+
print({ head: imported.head, imported: imported.imported, v: 1 }); return 0;
|
|
294
320
|
}
|
|
295
321
|
throw new TypeError("sync needs export or import.");
|
|
296
322
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isOhConflictError,
|
|
5
|
+
isOhDependencyError,
|
|
6
|
+
isOhIntegrityError,
|
|
7
|
+
isOhOperationSizeError,
|
|
8
|
+
isOhProfileError,
|
|
9
|
+
OhConflictError,
|
|
10
|
+
OhDependencyError,
|
|
11
|
+
OhIntegrityError,
|
|
12
|
+
OhOperationSizeError,
|
|
13
|
+
OhProfileError,
|
|
14
|
+
} from "./errors";
|
|
15
|
+
|
|
16
|
+
const CORE_ERRORS = [
|
|
17
|
+
{
|
|
18
|
+
brand: "@hraness/oh/OhConflictError/v1",
|
|
19
|
+
ErrorClass: OhConflictError,
|
|
20
|
+
guard: isOhConflictError,
|
|
21
|
+
name: "OhConflictError",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
brand: "@hraness/oh/OhIntegrityError/v1",
|
|
25
|
+
ErrorClass: OhIntegrityError,
|
|
26
|
+
guard: isOhIntegrityError,
|
|
27
|
+
name: "OhIntegrityError",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
brand: "@hraness/oh/OhDependencyError/v1",
|
|
31
|
+
ErrorClass: OhDependencyError,
|
|
32
|
+
guard: isOhDependencyError,
|
|
33
|
+
name: "OhDependencyError",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
brand: "@hraness/oh/OhProfileError/v1",
|
|
37
|
+
ErrorClass: OhProfileError,
|
|
38
|
+
guard: isOhProfileError,
|
|
39
|
+
name: "OhProfileError",
|
|
40
|
+
},
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
describe("Oh public error identity", () => {
|
|
44
|
+
for (const { brand, ErrorClass, guard, name } of CORE_ERRORS) {
|
|
45
|
+
test(`brands ${name} without changing its public constructor`, () => {
|
|
46
|
+
const error = new ErrorClass("preserved message");
|
|
47
|
+
expect(Error.isError(error)).toBe(true);
|
|
48
|
+
expect(error).toBeInstanceOf(Error);
|
|
49
|
+
expect(error).toBeInstanceOf(ErrorClass);
|
|
50
|
+
expect(guard(error)).toBe(true);
|
|
51
|
+
expect(error).toMatchObject({ message: "preserved message", name });
|
|
52
|
+
expect(Object.keys(error)).toEqual(["name"]);
|
|
53
|
+
|
|
54
|
+
const copied = Object.create(Error.prototype) as Record<PropertyKey, unknown>;
|
|
55
|
+
Object.defineProperty(copied, Symbol.for(brand), {
|
|
56
|
+
configurable: false,
|
|
57
|
+
value: true,
|
|
58
|
+
writable: false,
|
|
59
|
+
});
|
|
60
|
+
expect(Error.isError(copied)).toBe(false);
|
|
61
|
+
expect(guard(copied)).toBe(false);
|
|
62
|
+
expect(copied instanceof ErrorClass).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("preserves native subclass identity while exposing the branded base", () => {
|
|
67
|
+
class NarrowConflictError extends OhConflictError {}
|
|
68
|
+
|
|
69
|
+
const base = new OhConflictError("base");
|
|
70
|
+
const narrow = new NarrowConflictError("narrow");
|
|
71
|
+
expect(base).not.toBeInstanceOf(NarrowConflictError);
|
|
72
|
+
expect(narrow).toBeInstanceOf(NarrowConflictError);
|
|
73
|
+
expect(narrow).toBeInstanceOf(OhConflictError);
|
|
74
|
+
expect(isOhConflictError(narrow)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("does not make every operation-size error an instance of a subclass", () => {
|
|
78
|
+
class NarrowSizeError extends OhOperationSizeError {}
|
|
79
|
+
|
|
80
|
+
const base = new OhOperationSizeError(2, 1);
|
|
81
|
+
const narrow = new NarrowSizeError(2, 1);
|
|
82
|
+
expect(base).not.toBeInstanceOf(NarrowSizeError);
|
|
83
|
+
expect(narrow).toBeInstanceOf(NarrowSizeError);
|
|
84
|
+
expect(narrow).toBeInstanceOf(OhOperationSizeError);
|
|
85
|
+
expect(isOhOperationSizeError(narrow)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|