@hraness/oh 0.2.3
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/LICENSE +21 -0
- package/README.md +598 -0
- package/dist/canonical.d.ts +32 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +3419 -0
- package/dist/contract.d.ts +33 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/graph.d.ts +67 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1988 -0
- package/dist/libsql.d.ts +57 -0
- package/dist/libsql.d.ts.map +1 -0
- package/dist/libsql.js +2662 -0
- package/dist/memory.d.ts +366 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +3650 -0
- package/dist/ontology.d.ts +242 -0
- package/dist/ontology.d.ts.map +1 -0
- package/dist/operation.d.ts +24 -0
- package/dist/operation.d.ts.map +1 -0
- package/dist/projection-public.d.ts +59 -0
- package/dist/projection-public.d.ts.map +1 -0
- package/dist/projection-public.js +1682 -0
- package/dist/projection-suss.d.ts +17 -0
- package/dist/projection-suss.d.ts.map +1 -0
- package/dist/projection-suss.js +1721 -0
- package/dist/projection.d.ts +315 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/schema.d.ts +45 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/sdk.d.ts +51 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +3072 -0
- package/dist/search.d.ts +34 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/semantic.d.ts +83 -0
- package/dist/semantic.d.ts.map +1 -0
- package/dist/semantic.js +706 -0
- package/dist/sqlite/driver.d.ts +6 -0
- package/dist/sqlite/driver.d.ts.map +1 -0
- package/dist/sqlite/index.d.ts +5 -0
- package/dist/sqlite/index.d.ts.map +1 -0
- package/dist/sqlite/index.js +2840 -0
- package/dist/sqlite/migrations.d.ts +10 -0
- package/dist/sqlite/migrations.d.ts.map +1 -0
- package/dist/sqlite/port.d.ts +40 -0
- package/dist/sqlite/port.d.ts.map +1 -0
- package/dist/sqlite/runtime.d.ts +23 -0
- package/dist/sqlite/runtime.d.ts.map +1 -0
- package/dist/sqlite/store.d.ts +85 -0
- package/dist/sqlite/store.d.ts.map +1 -0
- package/dist/store.d.ts +236 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +996 -0
- package/dist/sync.d.ts +57 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +1328 -0
- package/package.json +121 -0
- package/skills/oh/SKILL.md +206 -0
- package/skills/oh/agents/openai.yaml +4 -0
- package/spec/README.md +74 -0
- package/spec/manifest.json +41 -0
- package/spec/v1/canonical-json.md +59 -0
- package/spec/v1/contract.json +28 -0
- package/spec/v1/contract.schema.json +58 -0
- package/spec/v1/embedding-profile.json +11 -0
- package/spec/v1/embedding.md +56 -0
- package/spec/v1/graph.md +87 -0
- package/spec/v1/memory.md +193 -0
- package/spec/v1/migration.md +92 -0
- package/spec/v1/ontology.json +55 -0
- package/spec/v1/ontology.md +80 -0
- package/spec/v1/operation.schema.json +138 -0
- package/spec/v1/projection-identity.schema.json +58 -0
- package/spec/v1/projection-query.schema.json +60 -0
- package/spec/v1/projection-result.schema.json +452 -0
- package/spec/v1/projection-rule-pack.schema.json +182 -0
- package/spec/v1/projection.md +165 -0
- package/spec/v1/record.schema.json +95 -0
- package/spec/v1/schema-evolution.md +51 -0
- package/spec/v1/schema-revision.schema.json +178 -0
- package/spec/v1/storage.md +88 -0
- package/spec/v1/store.md +131 -0
- package/spec/v1/sync-bundle.schema.json +51 -0
- package/spec/v1/sync.md +67 -0
- package/src/canonical.test.ts +46 -0
- package/src/canonical.ts +203 -0
- package/src/cli.test.ts +103 -0
- package/src/cli.ts +308 -0
- package/src/contract.ts +87 -0
- package/src/contracts.test.ts +147 -0
- package/src/graph.ts +248 -0
- package/src/index.ts +8 -0
- package/src/libsql.test.ts +657 -0
- package/src/libsql.ts +1687 -0
- package/src/memory.test.ts +783 -0
- package/src/memory.ts +1684 -0
- package/src/ontology.ts +573 -0
- package/src/operation.ts +80 -0
- package/src/projection-public.ts +53 -0
- package/src/projection-suss.ts +129 -0
- package/src/projection.test.ts +418 -0
- package/src/projection.ts +1457 -0
- package/src/schema.ts +156 -0
- package/src/sdk.ts +96 -0
- package/src/search.ts +66 -0
- package/src/semantic.test.ts +480 -0
- package/src/semantic.ts +333 -0
- package/src/sqlite/driver.ts +47 -0
- package/src/sqlite/index.ts +4 -0
- package/src/sqlite/migrations.test.ts +44 -0
- package/src/sqlite/migrations.ts +178 -0
- package/src/sqlite/port.test.ts +127 -0
- package/src/sqlite/port.ts +120 -0
- package/src/sqlite/runtime.test.ts +68 -0
- package/src/sqlite/runtime.ts +53 -0
- package/src/sqlite/store.test.ts +295 -0
- package/src/sqlite/store.ts +988 -0
- package/src/store.test.ts +121 -0
- package/src/store.ts +701 -0
- package/src/sync.test.ts +117 -0
- package/src/sync.ts +227 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SQLite storage V1
|
|
2
|
+
|
|
3
|
+
SQLite schema version `2` is the local authority for an Oh space. The default
|
|
4
|
+
CLI database is `.oh/oh.sqlite`; callers may select another path or use an
|
|
5
|
+
in-memory database.
|
|
6
|
+
|
|
7
|
+
## Connection policy
|
|
8
|
+
|
|
9
|
+
The Bun SQLite driver opens in strict mode and applies:
|
|
10
|
+
|
|
11
|
+
```sql
|
|
12
|
+
PRAGMA foreign_keys = ON;
|
|
13
|
+
PRAGMA journal_mode = WAL;
|
|
14
|
+
PRAGMA synchronous = NORMAL;
|
|
15
|
+
PRAGMA busy_timeout = 5000;
|
|
16
|
+
PRAGMA trusted_schema = OFF;
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Each migration and committed operation runs inside `BEGIN IMMEDIATE`. Failure
|
|
20
|
+
rolls back the transaction and preserves the original error.
|
|
21
|
+
|
|
22
|
+
## Tables
|
|
23
|
+
|
|
24
|
+
| Table | Role |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `oh_migrations` | Applied migration version, immutable name, SQL digest, and timestamp. |
|
|
27
|
+
| `oh_contracts` | Exact contract manifest and digest accepted by the store. |
|
|
28
|
+
| `oh_spaces` | Current compare-and-swap head for each logical space. |
|
|
29
|
+
| `oh_operations` | Append-only canonical operation chain. |
|
|
30
|
+
| `oh_operation_records` | Ordered record changes for each operation. |
|
|
31
|
+
| `oh_records` | Current canonical record materialization. |
|
|
32
|
+
| `oh_dependencies` | Current explicit dependency edges. |
|
|
33
|
+
| `oh_sync_outbox` | Local operations eligible for sync. |
|
|
34
|
+
| `oh_sync_state` | Last settled state for a named remote. |
|
|
35
|
+
| `oh_search_documents` | Derived keyword text bound to a record digest. |
|
|
36
|
+
| `oh_search_fts` | Derived FTS5 index. |
|
|
37
|
+
| `oh_space_bindings` | Host-selected realm, lifecycle profile, capabilities, and application-profile digest for a supported store port. |
|
|
38
|
+
| `oh_space_purges` | Minimal receipt that permanently reserves the identifier of a purged working space. |
|
|
39
|
+
|
|
40
|
+
The first migration is named `0001_oh_core`; its released bytes remain
|
|
41
|
+
unchanged. Schema version 2 adds `0002_store_realms`. An implementation MUST
|
|
42
|
+
store and check the exact SHA-256 digest of applied migration SQL. It MUST
|
|
43
|
+
refuse to run when the same migration version or name has different bytes.
|
|
44
|
+
|
|
45
|
+
## Realm profiles and purge
|
|
46
|
+
|
|
47
|
+
A promise-based store port MAY bind one space to one host-selected realm and
|
|
48
|
+
profile. The binding includes the exact Oh contract digest, an optional
|
|
49
|
+
application-profile digest, and declared capabilities. A supported runtime
|
|
50
|
+
MUST reject a later attempt to open the same space under different binding
|
|
51
|
+
bytes. A working profile disables operation replication and enables only
|
|
52
|
+
host-controlled whole-space purge. A canonical profile cannot be purged by
|
|
53
|
+
that API.
|
|
54
|
+
|
|
55
|
+
Purge deletes the space head, complete operation history, current records,
|
|
56
|
+
dependency and operation materializations, sync state, and derived keyword
|
|
57
|
+
rows in one immediate transaction. It leaves only a content-free receipt with
|
|
58
|
+
the prior head, binding digest, purge instant, and receipt digest. The purged
|
|
59
|
+
space identifier cannot be reopened in the same database. A host that deletes
|
|
60
|
+
an entire database file MUST retain any required deletion evidence in its own
|
|
61
|
+
control plane.
|
|
62
|
+
|
|
63
|
+
Realm and profile binding is additive store control metadata. V1 operation
|
|
64
|
+
digest preimages do not contain the binding. It therefore protects supported
|
|
65
|
+
opens and operations but is not a portable cryptographic claim about a V1
|
|
66
|
+
history. Such a claim requires a new wire contract rather than a change to V1
|
|
67
|
+
operation bytes.
|
|
68
|
+
|
|
69
|
+
## Authority and derivation
|
|
70
|
+
|
|
71
|
+
`oh_operations`, `oh_spaces`, and the canonical current records define state.
|
|
72
|
+
The dependency and operation-record tables are checked materializations. Search
|
|
73
|
+
documents, FTS rows, and the separate QMD cache are derived and rebuildable.
|
|
74
|
+
|
|
75
|
+
Deleting an embedding or FTS index MUST NOT delete authored records or
|
|
76
|
+
operations. A search result MUST be rejoined to a current record and digest
|
|
77
|
+
before it is returned as current.
|
|
78
|
+
|
|
79
|
+
## Replay verification
|
|
80
|
+
|
|
81
|
+
`oh verify` runs SQLite `integrity_check`, parses every canonical operation,
|
|
82
|
+
replays the operation chain from an empty graph, recomputes record-set and graph
|
|
83
|
+
revision digests, checks dependencies, compares the materialized records, and
|
|
84
|
+
requires the reconstructed head to equal the stored head.
|
|
85
|
+
|
|
86
|
+
Backup and restore procedures SHOULD preserve the database and WAL atomically.
|
|
87
|
+
An application SHOULD run replay verification after an untrusted transfer or
|
|
88
|
+
restore before treating the space as authoritative.
|
package/spec/v1/store.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Store ports, profiles, and direct libSQL authority
|
|
2
|
+
|
|
3
|
+
The graph and operation contract remains V1. This document defines additive
|
|
4
|
+
host APIs that preserve those bytes across local SQLite and direct libSQL
|
|
5
|
+
authorities.
|
|
6
|
+
|
|
7
|
+
## Promise-based store port
|
|
8
|
+
|
|
9
|
+
`@hraness/oh/store` has no dependency on `bun:sqlite`. Its methods return
|
|
10
|
+
promises and expose:
|
|
11
|
+
|
|
12
|
+
- the current exact head;
|
|
13
|
+
- a current or historical snapshot at an exact sequence and operation digest;
|
|
14
|
+
- a bounded contiguous change page through a pinned head;
|
|
15
|
+
- compare-and-swap commit;
|
|
16
|
+
- dependency-closure export;
|
|
17
|
+
- replay and materialization verification; and
|
|
18
|
+
- close.
|
|
19
|
+
|
|
20
|
+
A historical read MUST fail if its sequence is absent or identifies a
|
|
21
|
+
different operation digest. A change page MUST name its source cursor, pinned
|
|
22
|
+
through-head, returned cursor, and whether more operations remain.
|
|
23
|
+
|
|
24
|
+
## Semantic bundle ingress
|
|
25
|
+
|
|
26
|
+
Model-facing code SHOULD use `OhSemanticBundleIngressV1` instead of generic
|
|
27
|
+
record puts. The ingress seals its codec registry, requires a registered codec
|
|
28
|
+
for every put kind, parses all values, creates canonical record envelopes, and
|
|
29
|
+
submits all puts and tombstones as one compare-and-swap operation. Missing
|
|
30
|
+
codecs, invalid values, duplicate keys, stale heads, and incomplete dependency
|
|
31
|
+
closures fail before the authority head moves.
|
|
32
|
+
|
|
33
|
+
## Profiles and host control
|
|
34
|
+
|
|
35
|
+
A binding combines one exact contract, application profile, lifecycle profile,
|
|
36
|
+
realm, and space. The built-in canonical profile permits operation replication
|
|
37
|
+
and forbids whole-space purge. The built-in working profile forbids operation
|
|
38
|
+
replication and permits purge.
|
|
39
|
+
|
|
40
|
+
Creating an authority returns separate `store` and `host` objects. The ordinary
|
|
41
|
+
store never has a purge method. Trusted control-plane code retains the host
|
|
42
|
+
object and does not pass it to an agent tool or model. This is an API and
|
|
43
|
+
custody boundary, not protection from code that already holds raw database
|
|
44
|
+
credentials or direct filesystem access.
|
|
45
|
+
|
|
46
|
+
## Dependency-closure capsules
|
|
47
|
+
|
|
48
|
+
A closure export pins the source binding and exact head, sorts unique roots,
|
|
49
|
+
and includes exactly the records reachable through declared dependencies. The
|
|
50
|
+
capsule digest binds all of those fields. Verification rejects a missing
|
|
51
|
+
dependency, an extra unrelated record, a changed record digest, reordered
|
|
52
|
+
roots or records, and a false capsule digest. V1 bounds a capsule to 1,024
|
|
53
|
+
roots, 8,192 records, and 67,108,864 canonical UTF-8 bytes.
|
|
54
|
+
|
|
55
|
+
A closure is content evidence for a later reviewed adoption. It does not copy
|
|
56
|
+
source authority, review state, credentials, or operation history into a
|
|
57
|
+
destination.
|
|
58
|
+
|
|
59
|
+
## Direct libSQL authority
|
|
60
|
+
|
|
61
|
+
`@hraness/oh/libsql` accepts the `execute` and transactional `batch` shape of
|
|
62
|
+
`@libsql/client`. It is Node 24 and serverless compatible and does not import
|
|
63
|
+
`bun:sqlite`. Unlike the V1 sync transport, it treats libSQL as the current
|
|
64
|
+
record and operation authority.
|
|
65
|
+
|
|
66
|
+
`bootstrapOhLibSqlAuthorityV1` is the only API that creates schema objects.
|
|
67
|
+
Run it in a deployment or migration step with a short-lived schema credential.
|
|
68
|
+
`createOhLibSqlStoreAuthorityV1` is the runtime open: it only verifies the
|
|
69
|
+
installed schema and contract before reading or creating a bound data space,
|
|
70
|
+
so a runtime token does not need schema-change permission.
|
|
71
|
+
|
|
72
|
+
`openExistingOhLibSqlStoreAuthorityV1` is the least-privilege open for a
|
|
73
|
+
separately held reader or purge worker. It verifies the same exact schema,
|
|
74
|
+
contract, space head, and binding using reads only. It rejects a missing,
|
|
75
|
+
purged, or differently bound space and never inserts, updates, or deletes data
|
|
76
|
+
during open. A provider credential can therefore omit space and binding
|
|
77
|
+
creation while retaining only the data actions required by its later task.
|
|
78
|
+
|
|
79
|
+
`purgeOhLibSqlWorkingSpaceV1` is the one-shot lifecycle-worker boundary. It
|
|
80
|
+
purges an existing exact working binding or atomically writes an empty-head
|
|
81
|
+
purge receipt when creation never completed. Creation and absent-space fencing
|
|
82
|
+
race in the same authority transaction: whichever wins, the result converges
|
|
83
|
+
to one complete purge receipt, and a delayed creator cannot resurrect the
|
|
84
|
+
space. The credential still needs no permission to create spaces or bindings.
|
|
85
|
+
|
|
86
|
+
Runtime open verifies the exact installed table, index, and trigger set, not
|
|
87
|
+
only a schema marker. Every operation, binding, and purge receipt read parses
|
|
88
|
+
its canonical JSON and cross-checks each duplicated SQL column. Current reads
|
|
89
|
+
also prove contiguous operation coverage through the exact terminal head,
|
|
90
|
+
record provenance puts, record digests, and dependency materialization.
|
|
91
|
+
|
|
92
|
+
Its private implementation tables use the `oh_authority_` prefix:
|
|
93
|
+
|
|
94
|
+
| Table | Role |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `oh_authority_schemas` | Exact adapter schema name and digest. |
|
|
97
|
+
| `oh_authority_contracts` | Exact Oh contract manifest. |
|
|
98
|
+
| `oh_authority_spaces` | Current compare-and-swap heads. |
|
|
99
|
+
| `oh_authority_operations` | Canonical append-only operations. |
|
|
100
|
+
| `oh_authority_operation_records` | Ordered changes per operation. |
|
|
101
|
+
| `oh_authority_records` | Current record materialization. |
|
|
102
|
+
| `oh_authority_dependencies` | Current dependency edges. |
|
|
103
|
+
| `oh_authority_bindings` | Realm and profile control metadata. |
|
|
104
|
+
| `oh_authority_purges` | Minimal whole-space purge receipts. |
|
|
105
|
+
| `oh_authority_commit_guards` | Empty constraint table used to abort a stale transactional batch. |
|
|
106
|
+
|
|
107
|
+
A normal remote commit takes three atomic provider round trips: an idempotency,
|
|
108
|
+
head, and purge preflight; one exact current-materialization read; and one
|
|
109
|
+
guarded write batch. The final write guard aborts the complete transaction when
|
|
110
|
+
compare-and-swap did not settle at the declared operation. Its write-batch
|
|
111
|
+
readback must reproduce both the canonical operation and persisted head before
|
|
112
|
+
the adapter returns success.
|
|
113
|
+
|
|
114
|
+
Provider responses are bounded as part of the API. V1 accepts at most 64
|
|
115
|
+
changes, 512 dependencies, and 512 KiB of canonical operation JSON per commit.
|
|
116
|
+
A feed returns at most seven operations plus one checked sentinel and refuses a
|
|
117
|
+
page whose conservative transport estimate exceeds 9,000,000 bytes. Historical
|
|
118
|
+
replay is limited to 16,384 operations, 4 MiB of canonical operation JSON, and
|
|
119
|
+
the same response estimate. Current snapshot and full-verification result sets
|
|
120
|
+
are independently transport-estimated and SQL-gated before rows are returned.
|
|
121
|
+
Sizing, rows, and the pinned head are read in the same transaction, so a
|
|
122
|
+
concurrent append cannot grow an unchecked response between preflight and read.
|
|
123
|
+
|
|
124
|
+
Remote purge similarly inserts a receipt only for the expected working head,
|
|
125
|
+
deletes every payload and materialization row under that receipt in the same
|
|
126
|
+
write batch, and aborts if either the receipt or deletion is incomplete. A
|
|
127
|
+
later open returns the stored purge receipt instead of recreating the space.
|
|
128
|
+
Operation-record deletion resolves ownership through the canonical operation;
|
|
129
|
+
the purge postcondition also rejects any global orphan or cross-space owner
|
|
130
|
+
mismatch. Purge receipts are immutable and intentionally retain only binding,
|
|
131
|
+
prior-head, and purge-event evidence.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$defs": {
|
|
3
|
+
"safeCode": {
|
|
4
|
+
"maxLength": 128,
|
|
5
|
+
"pattern": "^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$",
|
|
6
|
+
"type": "string"
|
|
7
|
+
},
|
|
8
|
+
"sha256": {
|
|
9
|
+
"pattern": "^[a-f0-9]{64}$",
|
|
10
|
+
"type": "string"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"$id": "https://oh.computer/spec/v1/sync-bundle.schema.json",
|
|
14
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
15
|
+
"$comment": "Runtime conformance additionally requires one contiguous operation chain for the selected space and a recomputed bundleSha256.",
|
|
16
|
+
"additionalProperties": false,
|
|
17
|
+
"properties": {
|
|
18
|
+
"bundleSha256": {
|
|
19
|
+
"$ref": "#/$defs/sha256"
|
|
20
|
+
},
|
|
21
|
+
"contractSha256": {
|
|
22
|
+
"const": "e53ae573c2af417082be9f554d0f6f3e317f054daf745181f462608e3f622594"
|
|
23
|
+
},
|
|
24
|
+
"operations": {
|
|
25
|
+
"items": {
|
|
26
|
+
"$ref": "./operation.schema.json"
|
|
27
|
+
},
|
|
28
|
+
"maxItems": 1000,
|
|
29
|
+
"type": "array"
|
|
30
|
+
},
|
|
31
|
+
"protocol": {
|
|
32
|
+
"const": "oh.sync.v1"
|
|
33
|
+
},
|
|
34
|
+
"spaceId": {
|
|
35
|
+
"$ref": "#/$defs/safeCode"
|
|
36
|
+
},
|
|
37
|
+
"v": {
|
|
38
|
+
"const": 1
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"required": [
|
|
42
|
+
"bundleSha256",
|
|
43
|
+
"contractSha256",
|
|
44
|
+
"operations",
|
|
45
|
+
"protocol",
|
|
46
|
+
"spaceId",
|
|
47
|
+
"v"
|
|
48
|
+
],
|
|
49
|
+
"title": "Oh sync bundle V1",
|
|
50
|
+
"type": "object"
|
|
51
|
+
}
|
package/spec/v1/sync.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Operation sync V1
|
|
2
|
+
|
|
3
|
+
The protocol identifier is `oh.sync.v1`. Sync exchanges canonical operations,
|
|
4
|
+
not current-row snapshots or semantic indexes.
|
|
5
|
+
|
|
6
|
+
## Handshake
|
|
7
|
+
|
|
8
|
+
Before reading or writing a remote, a transport MUST compare the complete V1
|
|
9
|
+
contract manifest. The current contract digest is
|
|
10
|
+
`e53ae573c2af417082be9f554d0f6f3e317f054daf745181f462608e3f622594`.
|
|
11
|
+
A different digest fails before operation exchange.
|
|
12
|
+
|
|
13
|
+
A transport implements four bounded methods:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
interface OhOperationSyncTransportV1 {
|
|
17
|
+
handshake(manifest: OhContractManifestV1): Promise<void>;
|
|
18
|
+
head(spaceId: string): Promise<OhSyncHeadV1>;
|
|
19
|
+
pull(spaceId: string, afterSequence: number, limit: number): Promise<OhSyncBundleV1>;
|
|
20
|
+
push(bundle: OhSyncBundleV1): Promise<OhSyncHeadV1>;
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Bundle
|
|
25
|
+
|
|
26
|
+
A bundle binds `oh.sync.v1`, the contract digest, one space ID, at most 1,000
|
|
27
|
+
ordered operations, and its own digest. Operations MUST form one contiguous
|
|
28
|
+
chain. An empty bundle is valid.
|
|
29
|
+
|
|
30
|
+
Parsing recomputes every operation digest and the bundle digest. Import also
|
|
31
|
+
requires the bundle space to equal the selected local space and each operation
|
|
32
|
+
to extend the exact local head.
|
|
33
|
+
|
|
34
|
+
## Settlement
|
|
35
|
+
|
|
36
|
+
The synchronizer defaults to batches of 100 and at most 100 rounds. Each round
|
|
37
|
+
compares heads:
|
|
38
|
+
|
|
39
|
+
- Equal sequences and equal digests are settled.
|
|
40
|
+
- A lower local sequence pulls a chain that begins at the exact local head.
|
|
41
|
+
- A higher local sequence pushes a chain that begins at the exact remote head.
|
|
42
|
+
- Equal sequences with different digests, or any non-extending chain, are a
|
|
43
|
+
conflict.
|
|
44
|
+
|
|
45
|
+
The synchronizer never performs last-write-wins merging. Divergent append-only
|
|
46
|
+
histories remain intact for an explicit domain merge. Remote acknowledgments
|
|
47
|
+
must equal the final pushed operation.
|
|
48
|
+
|
|
49
|
+
## libSQL and Turso seam
|
|
50
|
+
|
|
51
|
+
The included adapter accepts a client with `execute` and transactional `batch`
|
|
52
|
+
methods compatible with `@libsql/client`. It creates only:
|
|
53
|
+
|
|
54
|
+
- `oh_sync_contracts`, containing exact contract manifests; and
|
|
55
|
+
- `oh_sync_operations`, containing the ordered canonical operation log.
|
|
56
|
+
|
|
57
|
+
The application supplies the client, credentials, endpoint, access policy,
|
|
58
|
+
retry policy, and backups. The adapter parameterizes values and verifies
|
|
59
|
+
canonical operation JSON on pull. Semantic documents and vectors never cross
|
|
60
|
+
this seam.
|
|
61
|
+
|
|
62
|
+
## Offline transfer
|
|
63
|
+
|
|
64
|
+
`oh sync export --after <sequence> --limit <count>` emits one canonical bundle.
|
|
65
|
+
`oh sync import --file <path>` verifies and applies a bundle idempotently. A
|
|
66
|
+
transfer process SHOULD preserve the exact bytes and SHOULD run `oh verify`
|
|
67
|
+
after the final import.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { canonicalJson, canonicalSha256, parseCanonicalJson, sha256Hex } from "./canonical";
|
|
4
|
+
|
|
5
|
+
describe("canonical JSON", () => {
|
|
6
|
+
test("sorts object keys recursively and hashes exact bytes", () => {
|
|
7
|
+
const left = { z: [{ b: 2, a: 1 }], a: "é" };
|
|
8
|
+
const right = { a: "é", z: [{ a: 1, b: 2 }] };
|
|
9
|
+
expect(canonicalJson(left)).toBe('{"a":"é","z":[{"a":1,"b":2}]}');
|
|
10
|
+
expect(canonicalJson(right)).toBe(canonicalJson(left));
|
|
11
|
+
expect(canonicalSha256(right)).toBe(canonicalSha256(left));
|
|
12
|
+
expect(canonicalSha256(left)).toBe(sha256Hex(canonicalJson(left)));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("rejects ambiguous and non-JSON values", () => {
|
|
16
|
+
expect(() => canonicalJson(-0)).toThrow("negative zero");
|
|
17
|
+
expect(() => canonicalJson(Number.NaN)).toThrow("finite");
|
|
18
|
+
expect(() => canonicalJson({ value: undefined })).toThrow("cannot encode");
|
|
19
|
+
expect(() => canonicalJson("\ud800")).toThrow("unpaired surrogate");
|
|
20
|
+
const cyclic: { self?: unknown } = {};
|
|
21
|
+
cyclic.self = cyclic;
|
|
22
|
+
expect(() => canonicalJson(cyclic)).toThrow("cycle");
|
|
23
|
+
expect(() => canonicalJson(Array(1))).toThrow("holes");
|
|
24
|
+
const symbolObject = { value: 1, [Symbol("hidden")]: 2 };
|
|
25
|
+
expect(() => canonicalJson(symbolObject)).toThrow("symbol property");
|
|
26
|
+
const accessor = Object.defineProperty({}, "value", { enumerable: true, get: () => 1 });
|
|
27
|
+
expect(() => canonicalJson(accessor)).toThrow("data property");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("accepts only byte-canonical JSON", () => {
|
|
31
|
+
expect(parseCanonicalJson('{"a":1,"b":2}')).toEqual({ a: 1, b: 2 });
|
|
32
|
+
expect(() => parseCanonicalJson('{ "a": 1 }')).toThrow("not canonical");
|
|
33
|
+
expect(() => parseCanonicalJson('{"b":2,"a":1}')).toThrow("not canonical");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("is invariant to insertion order across generated objects", () => {
|
|
37
|
+
for (let seed = 1; seed <= 250; seed += 1) {
|
|
38
|
+
const keys = Array.from({ length: 12 }, (_, index) => `k${(seed * 17 + index * 31) % 97}`);
|
|
39
|
+
const first: Record<string, number> = {};
|
|
40
|
+
const second: Record<string, number> = {};
|
|
41
|
+
keys.forEach((key, index) => { first[key] = index; });
|
|
42
|
+
[...keys].reverse().forEach((key) => { second[key] = first[key] as number; });
|
|
43
|
+
expect(canonicalJson(first)).toBe(canonicalJson(second));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
package/src/canonical.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type JsonPrimitive = boolean | null | number | string;
|
|
4
|
+
export type JsonValue = JsonPrimitive | readonly JsonValue[] | JsonObject;
|
|
5
|
+
export type JsonObject = Readonly<{ [key: string]: JsonValue }>;
|
|
6
|
+
|
|
7
|
+
declare const sha256HexBrand: unique symbol;
|
|
8
|
+
export type Sha256Hex = string & { readonly [sha256HexBrand]: "Sha256Hex" };
|
|
9
|
+
|
|
10
|
+
export class OhValidationError extends Error {
|
|
11
|
+
readonly code: string;
|
|
12
|
+
readonly path: string;
|
|
13
|
+
|
|
14
|
+
constructor(code: string, path: string, message: string) {
|
|
15
|
+
super(`${path}: ${message}`);
|
|
16
|
+
this.name = "OhValidationError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.path = path;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
24
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
25
|
+
return prototype === Object.prototype || prototype === null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
29
|
+
const actual = Object.keys(value);
|
|
30
|
+
return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertUnicodeScalarString(value: string, path: string): void {
|
|
34
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
35
|
+
const code = value.charCodeAt(index);
|
|
36
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
37
|
+
const next = value.charCodeAt(index + 1);
|
|
38
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
|
|
39
|
+
throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate");
|
|
40
|
+
}
|
|
41
|
+
index += 1;
|
|
42
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
43
|
+
throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function encodeCanonical(value: unknown, path: string, ancestors: Set<object>): string {
|
|
49
|
+
if (value === null || typeof value === "boolean") return JSON.stringify(value);
|
|
50
|
+
if (typeof value === "string") {
|
|
51
|
+
assertUnicodeScalarString(value, path);
|
|
52
|
+
return JSON.stringify(value);
|
|
53
|
+
}
|
|
54
|
+
if (typeof value === "number") {
|
|
55
|
+
if (!Number.isFinite(value)) {
|
|
56
|
+
throw new OhValidationError("non-json-number", path, "must be finite");
|
|
57
|
+
}
|
|
58
|
+
if (Object.is(value, -0)) {
|
|
59
|
+
throw new OhValidationError("noncanonical-number", path, "negative zero is not canonical");
|
|
60
|
+
}
|
|
61
|
+
return JSON.stringify(value);
|
|
62
|
+
}
|
|
63
|
+
if (typeof value !== "object" || value === null) {
|
|
64
|
+
throw new OhValidationError("non-json-value", path, `cannot encode ${typeof value}`);
|
|
65
|
+
}
|
|
66
|
+
if (ancestors.has(value)) {
|
|
67
|
+
throw new OhValidationError("cycle", path, "contains a cycle");
|
|
68
|
+
}
|
|
69
|
+
ancestors.add(value);
|
|
70
|
+
try {
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
const encoded: string[] = [];
|
|
73
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
74
|
+
if (!Object.hasOwn(value, index)) {
|
|
75
|
+
throw new OhValidationError("sparse-array", `${path}[${index}]`, "must not contain holes");
|
|
76
|
+
}
|
|
77
|
+
encoded.push(encodeCanonical(value[index], `${path}[${index}]`, ancestors));
|
|
78
|
+
}
|
|
79
|
+
const extraKeys = Reflect.ownKeys(value).filter((key) => key !== "length"
|
|
80
|
+
&& (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length));
|
|
81
|
+
if (extraKeys.length > 0) {
|
|
82
|
+
throw new OhValidationError("non-json-property", path, "array has non-index properties");
|
|
83
|
+
}
|
|
84
|
+
return `[${encoded.join(",")}]`;
|
|
85
|
+
}
|
|
86
|
+
if (!isPlainRecord(value)) {
|
|
87
|
+
throw new OhValidationError("non-plain-object", path, "must be a plain object");
|
|
88
|
+
}
|
|
89
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
90
|
+
if (ownKeys.some((key) => typeof key !== "string")) {
|
|
91
|
+
throw new OhValidationError("non-json-property", path, "object has a symbol property");
|
|
92
|
+
}
|
|
93
|
+
const keys = ownKeys as string[];
|
|
94
|
+
for (const key of keys) {
|
|
95
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
96
|
+
if (descriptor === undefined || !descriptor.enumerable || descriptor.get !== undefined || descriptor.set !== undefined) {
|
|
97
|
+
throw new OhValidationError("non-json-property", `${path}.${key}`, "must be an enumerable data property");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
keys.sort();
|
|
101
|
+
const entries = keys.map((key) => {
|
|
102
|
+
assertUnicodeScalarString(key, `${path}.<key>`);
|
|
103
|
+
return `${JSON.stringify(key)}:${encodeCanonical(value[key], `${path}.${key}`, ancestors)}`;
|
|
104
|
+
});
|
|
105
|
+
return `{${entries.join(",")}}`;
|
|
106
|
+
} finally {
|
|
107
|
+
ancestors.delete(value);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** RFC 8785-style canonical JSON for the JSON subset accepted by Oh. */
|
|
112
|
+
export function canonicalJson(value: unknown): string {
|
|
113
|
+
return encodeCanonical(value, "$", new Set());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function parseCanonicalJson(text: string, maximumBytes = 16 * 1024 * 1024): JsonValue {
|
|
117
|
+
if (utf8ByteLength(text) > maximumBytes) {
|
|
118
|
+
throw new OhValidationError("limit-exceeded", "$", "canonical JSON exceeds its byte limit");
|
|
119
|
+
}
|
|
120
|
+
let value: unknown;
|
|
121
|
+
try {
|
|
122
|
+
value = JSON.parse(text);
|
|
123
|
+
} catch {
|
|
124
|
+
throw new OhValidationError("invalid-json", "$", "is not valid JSON");
|
|
125
|
+
}
|
|
126
|
+
if (canonicalJson(value) !== text) {
|
|
127
|
+
throw new OhValidationError("noncanonical-json", "$", "keys or values are not canonical");
|
|
128
|
+
}
|
|
129
|
+
return value as JsonValue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function utf8ByteLength(value: string): number {
|
|
133
|
+
return Buffer.byteLength(value, "utf8");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function sha256Hex(value: string | Uint8Array): Sha256Hex {
|
|
137
|
+
return createHash("sha256").update(value).digest("hex") as Sha256Hex;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function canonicalSha256(value: unknown): Sha256Hex {
|
|
141
|
+
return sha256Hex(canonicalJson(value));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function parseSha256Hex(value: unknown): Sha256Hex | null {
|
|
145
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value)
|
|
146
|
+
? value as Sha256Hex
|
|
147
|
+
: null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parseCanonicalInstantV1(value: unknown): string | null {
|
|
151
|
+
if (typeof value !== "string"
|
|
152
|
+
|| !/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/u.test(value)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const timestamp = Date.parse(value);
|
|
156
|
+
return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value ? value : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function canonicalNow(): string {
|
|
160
|
+
return new Date().toISOString();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function opaqueId(prefix: string): string {
|
|
164
|
+
if (!/^[a-z][a-z0-9_]{1,15}$/u.test(prefix)) {
|
|
165
|
+
throw new OhValidationError("invalid-prefix", "prefix", "must be a short lowercase code");
|
|
166
|
+
}
|
|
167
|
+
return `${prefix}${randomBytes(12).toString("hex")}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function safeCode(value: unknown, maximumLength = 128): string | null {
|
|
171
|
+
return typeof value === "string" && value.length <= maximumLength
|
|
172
|
+
&& /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value)
|
|
173
|
+
? value
|
|
174
|
+
: null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function boundedText(value: unknown, maximumBytes = 64 * 1024): string | null {
|
|
178
|
+
if (typeof value !== "string" || value.length === 0 || value.normalize("NFC") !== value
|
|
179
|
+
|| utf8ByteLength(value) > maximumBytes) return null;
|
|
180
|
+
try { assertUnicodeScalarString(value, "$text"); } catch { return null; }
|
|
181
|
+
for (const character of value) {
|
|
182
|
+
const code = character.codePointAt(0) ?? 0;
|
|
183
|
+
if (code <= 8 || (code >= 11 && code <= 12) || (code >= 14 && code <= 31)
|
|
184
|
+
|| (code >= 127 && code <= 159)) return null;
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function orderedUnique<T>(values: readonly T[], key: (value: T) => string): boolean {
|
|
190
|
+
return values.every((value, index) => index === 0 || key(values[index - 1] as T) < key(value));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function sortUnique<T>(values: readonly T[], key: (value: T) => string): readonly T[] {
|
|
194
|
+
const sorted = [...values].sort((left, right) => {
|
|
195
|
+
const leftKey = key(left);
|
|
196
|
+
const rightKey = key(right);
|
|
197
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
198
|
+
});
|
|
199
|
+
if (!orderedUnique(sorted, key)) {
|
|
200
|
+
throw new OhValidationError("duplicate", "$", "contains duplicate canonical values");
|
|
201
|
+
}
|
|
202
|
+
return sorted;
|
|
203
|
+
}
|