DB
The program that hosts the field. The substrate is delivered to consumers through this program; SQLite is the substrate's persistent body underneath.
A single Rust crate, compiled into the host binary. Owns one SQLite
database file per project at .ol/db. No in-memory cache
that can drift from disk — SQLite is the single source of truth.
The substrate spec defines what the field IS. This document defines two contracts:
- Consumer ↔︎ db. What the engine, the host, and any program reaching the substrate sees.
- db ↔︎ SQLite. What the db expresses in SQL and the discipline that holds.
Both answer to pilot/substrate.md. Where they
disagree, the substrate spec is right.
Consumer contract
Methods on the Db handle. Synchronous in surface (SQLite
calls block); change stream is async.
Lifecycle
Db::open(project_path: &Path) -> Result<Db, OpenError>Initializes the SQLite connection (creates the file with migrations
if fresh, opens with journal_mode = WAL if existing), seeds
the minimum the db needs, returns the handle. Closes via
Drop.
The db's own bootstrap is small: one row in branches
(the bootstrap branch, main) and one initial commit in
commits. The substrate's archetypes for branches and
commits, and the scope-anchors branches_root and
commits_root, are projected by the read
layer with hardcoded shapes — not stored as chunks. Field content
(engine root scope, ui root scope, agent root scope, archetype seeds,
etc.) is not the db's concern; the host's bootstrap routine writes those
via db.commit() after Db::open returns.
Reads
Two operations. Everything readable from the field goes through them.
impl Db {
fn scope(&self, scopes: &[ChunkId], opts: ScopeOpts)
-> Result<ScopeResult, ReadError>;
fn get(&self, chunk_id: ChunkId, opts: ReadOpts)
-> Result<Option<ChunkItem>, ReadError>;
}scope returns the intersection of the named scopes —
chunks placed on every one of them. get returns a single
chunk by id, or None if not present in current state.
ScopeOpts:
ScopeOpts
branch: BranchName default "main"
at: Option<CommitId> time travel
match_: Option<String> FTS5 filter applied within the intersection
limit: Option<usize> pagination
offset: Option<usize>
include: Includes what to populate
scopes may be empty. Empty scope means the whole field —
every chunk qualifies for the intersection (vacuous truth). Combined
with match_, it is field-wide search; combined with
Includes::shape(), it is the browser's starting view (all
dims sorted by count); combined with intersection_chunks
plus limit, it enumerates the field. Pagination is the
guardrail against unbounded fetches, not a runtime restriction.
When scopes is empty, vacuous truth applies uniformly:
in_scope = in_scope_instance = in_scope_relates = total.
Every chunk is trivially "placed instance on every queried scope" and
"placed relates on every queried scope" when no scopes are queried (the
empty conjunction is true). Consumers should treat the instance/relates
split as degenerate (uninformative) under empty scope — it is reported
for consistency with the non-empty case, not as useful attribution.
Result
ScopeResult
head commit sampled
total chunks in branch
in_scope chunks at intersection
in_scope_instance ...via instance on every dim in scope
in_scope_relates ...via relates on every dim in scope
chunks: [ChunkItem] intersection chunks (opt-in)
dimensions: [Dim] scopes you can add (opt-in)
ChunkItem
id always
name? spec? body? placements? chunk self-data (opt-in)
Dim
id, name
count chunks at intersection placed here
instance
relates
edges?: [Edge] scopes you can reach from this dim,
beyond current adjacency (opt-in)
Edge
id, name
count chunks on this dim also placed on the edge dim
instance
relates
Placement
scope_id, type_, seq?
Spec
ordered, accepts, required, unique, propagate
Folk reading:
- "What's at my intersection?" →
chunks - "What can I add to narrow further?" →
dimensions - "What can I reach from this dim, beyond current adjacency?" →
dim.edges
Why dimensions and edges differ: dimensions are scopes intersection chunks already touch — adding any keeps the intersection non-empty (narrowing). Edges are scopes a dim's chunks (including chunks NOT at the current intersection) touch beyond the current adjacency — reachable only by stepping out of the current scope.
Sort: dimensions and Dim.edges both
descending by count.
Includes
Includes default: every flag false
chunk_name chunk_spec chunk_body chunk_placements per ChunkItem
intersection_chunks populate `chunks`
dimensions populate `dimensions`
edges also populate `Dim.edges`
rank snippet with match_
Minimum return when nothing is opted in: head, four
counts, empty chunks, empty dimensions.
Convenience constructors:
Includes::shape() = { dimensions }
Includes::content() = { intersection_chunks, chunk_name, chunk_body, chunk_placements }
Includes::all() = every flag
Branches and commits as virtual chunks
The substrate's discipline is that everything is chunks and
placements. Branches and commits are projected by the read layer as
virtual chunks — they appear in scope and get
like any other content:
db.scope(&[branches_root], opts)— every branch as a chunk; body carries{ head: commit_id }.db.scope(&[branches_root, branch_id], opts)— a single branch.db.scope(&[commits_root, branch_id], opts)— commits in the branch's ancestry, ordered.
branches_root and commits_root are
well-known ids recognized by the read layer. They are not stored — they
are projection anchors with hardcoded specs (the branch and
commit archetypes).
Virtual chunks are read-only via scope/get.
Writes targeting them are rejected (WriteToVirtualChunk).
Their state is owned by db-level operations: commit
(advances a branch's head), create_branch /
delete_branch (manipulate the branch graph).
There is no list_branches, current_head, or
history operation — they are just scope reads against the
virtual anchors.
Write
One operation produces commits.
impl Db {
fn commit(&self, declaration: &Declaration, opts: CommitOpts)
-> Result<Commit, WriteError>;
}Declaration
chunks: [ChunkDeclaration]
placements: [PlacementSpec] bare placements (no chunk content change)
message: Option<String>
CommitOpts
branch: BranchName which branch this commit lands on
dispatch_id: Option<String> engine metadata, propagated to the commit chunk
The whole declaration is one transaction. All writes succeed and a commit is recorded, or all fail and nothing is written.
The result is the Commit itself — a chunk-shaped
artifact:
Commit
id, parent_id?, timestamp, message?, dispatch_id?
chunks_modified: [ChunkId]
placements_modified: [(ChunkId, ChunkId)] (chunk_id, scope_id) entered or left
chunks_modified and placements_modified are
the deltas — for caller convenience and for filtering on the change
stream.
Branch operations
impl Db {
fn create_branch(&self, name: &str, from: CommitId) -> Result<Branch, WriteError>;
fn delete_branch(&self, name: &str) -> Result<(), WriteError>;
}create_branch makes a new branch pointer at an existing
commit. delete_branch removes the pointer; commits remain
(lossless). Both emit on the change stream — branch graph mutations
surface alongside commits.
The change stream
impl Db {
fn subscribe_scope(&self, scopes: &[ChunkId], opts: SubscribeOpts)
-> impl Stream<Item = Commit>;
}A single subscription primitive. Yields commits that touch the named
scopes (any of them). Backed by an internal broadcast channel pushed
from Rust right after tx.commit() returns Ok (see
Reactivity wiring); state and event are tightly coupled — by
the time the event arrives, the SQL commit is durable and visible to any
reader.
Subscribe at any scope to listen there:
subscribe_scope(&[commits_root])— every new commit.subscribe_scope(&[branches_root])— branch graph mutations.subscribe_scope(&[my_session])— changes touching the session's content.
Backpressure: each subscriber has a bounded receiver. On overflow,
oldest events drop and a Lagged marker is emitted.
Subscriptions are tied to the handle's Db lifetime;
dropping the stream unsubscribes.
Errors
ValidationError { scope_id, kind } spec violation; kind = Ordered | Accepts | Required | Unique | AmbiguousType
NameCollision { scope_id, name } name uniqueness rule
NotFound { kind, id } chunk, branch, or commit not present
MalformedDeclaration(reason) declaration self-inconsistent
WriteToVirtualChunk { id } declaration targets a projected chunk
IoError(SqliteError) underlying SQLite error
Atomicity
A declaration is one transaction. Inside:
- Insert version rows for everything in the declaration.
- Apply current-state transitions (FTS triggers fire).
- Run validation against the post-write current state.
- If validation passes: insert the commit row, advance the branch HEAD, COMMIT, push to the change stream.
- If validation fails: ROLLBACK. Nothing recorded; nothing emitted.
Writes within a declaration are visible to validation through ordinary SELECTs (the post-write state lives in current-state tables inside the transaction), but invisible to other transactions until COMMIT. The substrate's two-pass write-then-validate is delivered by SQLite transaction semantics directly.
A commit row appears only when validation passes. The change stream emits only successful commits.
SQLite contract
Physical schema
Two layers. Version tables are append-only history (the source of truth for time travel). Current-state tables are materialized views maintained on each commit (the read path).
CREATE TABLE commits (
id TEXT PRIMARY KEY, -- sortable ULID-shaped id
parent_id TEXT REFERENCES commits(id),
timestamp TEXT NOT NULL, -- ISO-8601 UTC
message TEXT,
dispatch_id TEXT
);
CREATE TABLE branches (
name TEXT PRIMARY KEY,
head TEXT NOT NULL REFERENCES commits(id)
);
CREATE TABLE chunk_versions (
chunk_id TEXT NOT NULL,
commit_id TEXT NOT NULL REFERENCES commits(id),
name TEXT,
spec TEXT NOT NULL DEFAULT '{}', -- JSON
body TEXT NOT NULL DEFAULT '{}', -- JSON
removed INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (chunk_id, commit_id)
);
CREATE TABLE placement_versions (
chunk_id TEXT NOT NULL,
scope_id TEXT NOT NULL,
commit_id TEXT NOT NULL REFERENCES commits(id),
type TEXT NOT NULL CHECK (type IN ('instance', 'relates')),
seq INTEGER,
active INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (chunk_id, scope_id, commit_id)
);
CREATE TABLE current_chunks (
chunk_id TEXT NOT NULL,
branch TEXT NOT NULL REFERENCES branches(name),
name TEXT,
spec TEXT NOT NULL DEFAULT '{}',
body TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (chunk_id, branch)
);
CREATE TABLE current_placements (
chunk_id TEXT NOT NULL,
scope_id TEXT NOT NULL,
branch TEXT NOT NULL REFERENCES branches(name),
type TEXT NOT NULL,
seq INTEGER,
PRIMARY KEY (chunk_id, scope_id, branch)
);
CREATE VIRTUAL TABLE chunk_fts USING fts5(
name,
body,
content='current_chunks',
content_rowid='rowid',
tokenize='unicode61'
);Indexes
CREATE INDEX idx_current_placements_scope ON current_placements(scope_id, branch, type);
CREATE INDEX idx_current_placements_chunk ON current_placements(chunk_id, branch);
CREATE INDEX idx_chunk_versions_chunk ON chunk_versions(chunk_id, commit_id);
CREATE INDEX idx_placement_versions_chunk ON placement_versions(chunk_id, scope_id, commit_id);
CREATE INDEX idx_commits_parent ON commits(parent_id);FTS hookup
Triggers on current_chunks keep the FTS index
synchronized within the commit transaction:
CREATE TRIGGER current_chunks_ai AFTER INSERT ON current_chunks BEGIN
INSERT INTO chunk_fts(rowid, name, body) VALUES (new.rowid, new.name, new.body);
END;
CREATE TRIGGER current_chunks_ad AFTER DELETE ON current_chunks BEGIN
INSERT INTO chunk_fts(chunk_fts, rowid, name, body)
VALUES ('delete', old.rowid, old.name, old.body);
END;
CREATE TRIGGER current_chunks_au AFTER UPDATE ON current_chunks BEGIN
INSERT INTO chunk_fts(chunk_fts, rowid, name, body)
VALUES ('delete', old.rowid, old.name, old.body);
INSERT INTO chunk_fts(rowid, name, body) VALUES (new.rowid, new.name, new.body);
END;The FTS index covers all branches' current state; branch filtering is a JOIN at query time.
Tokenization. body is stored and
indexed as JSON text. The unicode61 tokenizer splits on
word boundaries — punctuation including {, },
", :, , is treated as separators
— so a query like match_: "world" matches
body = {"greeting": "hello world"}. The flip side: tokens
from JSON keys are not distinguished from values, so
match_: "greeting" would also match. The pilot accepts this
as a "search over chunk text content" semantic, not a structured query —
programs that need keyed search compose substrate queries from scopes
and dimensions, not FTS.
The commit algorithm
commit(declaration, opts):
reject if any chunk in the declaration targets a virtual chunk
(branches_root, commits_root, branch archetype, commit archetype) → WriteToVirtualChunk
BEGIN IMMEDIATE TRANSACTION
let commit_id = generate_commit_id()
let parent = head_of(opts.branch)
let timestamp = now_utc()
INSERT INTO commits (id, parent_id, timestamp, message, dispatch_id)
VALUES (commit_id, parent, timestamp, declaration.message, opts.dispatch_id)
for each chunk in declaration.chunks:
resolve id (declared or generated)
INSERT INTO chunk_versions (chunk_id, commit_id, name, spec, body, removed)
apply current-state transition for opts.branch
for each placement (chunk-bound and bare):
INSERT INTO placement_versions (chunk_id, scope_id, commit_id, type, seq, active)
apply current-state transition for opts.branch
validate in Rust against post-write current state on this branch:
for each chunk touched, compose effective contract and check rules
any failure => ROLLBACK and return
UPDATE branches SET head = commit_id WHERE name = opts.branch
COMMIT
(after tx.commit() returns Ok, push Commit to broadcast channel)
return Commit
Validation is in Rust. SQL stores; Rust enforces. Validating in SQL
would require recursive CTEs over the propagating-archetype graph and
lock the rule into SQL; Rust gives clearer code and easier evolution.
Rules read through ordinary SELECTs against the open transaction's
connection, not pre-fetched into pure structs — the post-write state
already lives in current_chunks /
current_placements inside the transaction.
Current-state transitions
For each chunk_versions row at branch B:
| chunk_versions row | current_chunks rule (branch B) |
|---|---|
removed = 0 |
UPSERT row with new (name, spec, body) |
removed = 1 |
DELETE current_chunks row; DELETE all current_placements rows where chunk is the chunk OR the scope (branch B only) |
For each placement_versions row at branch B:
| placement_versions row | current_placements rule (branch B) |
|---|---|
active = 1 |
UPSERT row with (type, seq); auto-assign seq when scope is
ordered: true and seq omitted (see below) |
active = 0 |
DELETE row for (chunk_id, scope_id, branch B) |
Removal is per-branch.
Seq auto-assignment. When ordered: true
and seq is omitted, the assignment is
max(seq) + 1 over current_placements for that
(scope_id, branch), evaluated as each placement is applied
(not in batch). Within a single declaration that places multiple chunks
on the same ordered scope without seq, the assignments run sequentially:
the second sees the first's just-applied row, gets max + 2,
etc. Across concurrent commits, BEGIN IMMEDIATE serializes
writes, so one commit's auto-assigned seqs are visible to the next
before its max lookup runs.
Query patterns
Intersection (the chunks)
SELECT cc.*
FROM current_chunks cc
JOIN current_placements cp
ON cp.chunk_id = cc.chunk_id AND cp.branch = cc.branch
WHERE cc.branch = :branch
AND cp.scope_id IN (:scope_ids)
AND cp.type = 'instance'
GROUP BY cc.chunk_id
HAVING COUNT(DISTINCT cp.scope_id) = :n_scopes;With match_, intersect against FTS:
SELECT cc.*, fts.rank
FROM chunk_fts fts
JOIN current_chunks cc ON cc.rowid = fts.rowid
JOIN current_placements cp
ON cp.chunk_id = cc.chunk_id AND cp.branch = cc.branch
WHERE chunk_fts MATCH :query
AND cc.branch = :branch
AND cp.scope_id IN (:scope_ids)
AND cp.type = 'instance'
GROUP BY cc.chunk_id
HAVING COUNT(DISTINCT cp.scope_id) = :n_scopes
ORDER BY fts.rank;Empty scope. When scopes is empty,
in_scope is "every chunk on this branch" — the placement
join is dropped:
SELECT cc.*
FROM current_chunks cc
WHERE cc.branch = :branch
LIMIT :limit OFFSET :offset;With match_ added, intersect against FTS as above but
again without the placement join.
For ordered scopes (non-empty case), ORDER BY cp.seq
with LIMIT/OFFSET. Pagination is position-based on the
ordered result set, not seq-value-based: LIMIT 10 OFFSET 20
returns the chunks at positions 21–30 in the seq order, regardless of
how sparse seq values are.
Dimensions
For each scope the intersection chunks are placed on, with counts split by placement type:
WITH in_scope AS (
SELECT cp.chunk_id
FROM current_placements cp
WHERE cp.branch = :branch
AND cp.scope_id IN (:scope_ids)
AND cp.type = 'instance'
GROUP BY cp.chunk_id
HAVING COUNT(DISTINCT cp.scope_id) = :n_scopes
)
SELECT
cp.scope_id,
COUNT(*) FILTER (WHERE cp.type = 'instance') AS instance_count,
COUNT(*) FILTER (WHERE cp.type = 'relates') AS relates_count,
COUNT(*) AS total
FROM current_placements cp
JOIN in_scope ON in_scope.chunk_id = cp.chunk_id
WHERE cp.branch = :branch
GROUP BY cp.scope_id
ORDER BY total DESC;(Dimensions include the scopes in the input — they qualify trivially. The consumer filters them out only if they want the "what to add" view excluding the input.)
When scopes is empty, the in_scope CTE
collapses to "every chunk on this branch" — every dim in the field
appears in dimensions, sorted by count. Edges become empty
in this case: with empty input, every dim is already adjacent, so there
is nothing "beyond."
Edges (for each dim, what it reaches beyond)
-- for each adjacent dim X, find dims Y that any chunk on X also touches,
-- where Y is not in the current scope and not already adjacent
SELECT
cm1.scope_id AS from_dim,
cm2.scope_id AS to_dim,
COUNT(*) FILTER (WHERE cm2.type = 'instance') AS instance_count,
COUNT(*) FILTER (WHERE cm2.type = 'relates') AS relates_count,
COUNT(*) AS total
FROM current_placements cm1
JOIN current_placements cm2 ON cm1.chunk_id = cm2.chunk_id AND cm2.branch = cm1.branch
WHERE cm1.branch = :branch
AND cm1.scope_id IN (:dimension_ids) -- adjacent dims from previous query
AND cm2.scope_id NOT IN (:scope_ids)
AND cm2.scope_id NOT IN (:dimension_ids)
AND cm1.scope_id != cm2.scope_id
GROUP BY cm1.scope_id, cm2.scope_id
ORDER BY total DESC;Virtual chunks (branches and commits)
When scope is called with branches_root or
commits_root, the read layer projects from the underlying
tables instead of joining current_chunks:
-- branches projection
SELECT name AS chunk_id, name, '{}' AS spec,
json_object('head', head) AS body
FROM branches;
-- commits in a branch's ancestry, ordered by depth
WITH RECURSIVE ancestry(id, depth) AS (
SELECT head, 0 FROM branches WHERE name = :branch
UNION ALL
SELECT c.parent_id, a.depth + 1
FROM commits c JOIN ancestry a ON c.id = a.id
WHERE c.parent_id IS NOT NULL
)
SELECT c.id AS chunk_id, NULL AS name, '{}' AS spec,
json_object(
'timestamp', c.timestamp,
'message', c.message,
'dispatch_id', c.dispatch_id
) AS body,
a.depth AS seq
FROM commits c JOIN ancestry a ON c.id = a.id
ORDER BY seq;Mixing virtual and real scopes in one scope call is
rejected in the pilot — keeps the projection clean.
Time travel
When at: Some(commit_id) is set, the current-state path
is bypassed; the read walks version tables:
WITH RECURSIVE ancestry(id, depth) AS (
SELECT :target, 0
UNION ALL
SELECT c.parent_id, a.depth + 1
FROM commits c JOIN ancestry a ON c.id = a.id
WHERE c.parent_id IS NOT NULL
),
chunk_state AS (
SELECT cv.*,
ROW_NUMBER() OVER (
PARTITION BY cv.chunk_id
ORDER BY (SELECT depth FROM ancestry WHERE id = cv.commit_id) ASC
) AS rn
FROM chunk_versions cv
WHERE cv.commit_id IN (SELECT id FROM ancestry)
)
SELECT * FROM chunk_state WHERE rn = 1 AND removed = 0;(Sketch — exact query refines under benchmark.)
Reactivity wiring
The Db handle holds a
tokio::sync::broadcast::Sender<Commit>. Each
successful write op (commit, create_branch,
delete_branch) pushes the resulting Commit
onto the channel immediately after tx.commit() returns Ok.
Subscribers (subscribe_scope) hold receivers and filter on
placements_modified/chunks_modified.
By the time the push runs, the SQL commit is durable and visible to any subsequent reader — atomic from any observer's perspective. Rolled-back transactions never reach the push, so subscribers see only durable commits.
The channel is bounded; on overflow the oldest event drops and a
Lagged marker reaches the subscriber so they can re-read
from a known commit if needed. The push itself is non-blocking — slow
consumers do not block the writer.
Transaction discipline
All writes use BEGIN IMMEDIATE to acquire the SQLite
write lock up front — no deadlock-by-upgrade. Reads use the default
deferred mode and benefit from WAL's reader-doesn't-block-writer
behavior. Open settings: journal_mode = WAL,
synchronous = NORMAL.
Concurrency
SQLite in WAL mode gives single-writer, many-reader. The db inherits this; nothing is invented on top.
One writer at a time. Concurrent commit
calls serialize at the SQLite level. Default busy timeout is 5 seconds;
on timeout the call fails and the caller decides whether to retry.
Readers do not block writers; writers do not block readers. A read started during a write reads from a snapshot taken at the read's start; the in-progress write is invisible until it commits.
Per-call read consistency. Each read runs in its own implicit transaction — consistent within one call, may shift between consecutive calls.
Cross-call read consistency. Open. The engine's most
demanding read pattern fits in a single multi-scope scope
call; explicit snapshot handles may not be needed for the pilot.
Reactivity does not block writes. The change-stream
channel is bounded and non-blocking on push; slow consumers drop the
oldest with a Lagged marker.
Code architecture
Module layout
pilot/db/
src/
lib.rs — public re-exports
types.rs — ChunkId, CommitId, ChunkItem, Spec, Commit, Includes,
ScopeOpts, ScopeResult, Dim, Edge, Placement,
Declaration, ChunkDeclaration, PlacementSpec,
ReadOpts, CommitOpts, BranchName, Branch, SubscribeOpts
errors.rs — per-op error enums (OpenError, ReadError, WriteError, ...) via thiserror
schema.rs — embedded DDL via include_str! + rusqlite_migration list
schema.sql — DDL: tables, indexes, FTS triggers
id.rs — ULID-shaped id generation (`ulid` crate)
db.rs — Db { conn: Mutex<Connection>, sender: broadcast::Sender<Commit> }
Db::open, Drop
validate.rs — Rule enum + check_commit; effective-contract composition
virtual_chunks.rs — branches_root / commits_root projection (used by ops::scope, ops::get)
bootstrap.rs — initial seed on fresh open (main branch + initial commit)
ops/ — public surface; one module per Db method
mod.rs — re-exports
get.rs — Db::get
commit.rs — Db::commit; transitions inline
branches.rs — Db::create_branch, Db::delete_branch
subscribe.rs — Db::subscribe_scope (BroadcastStream + scope filter)
scope/ — folded because of size: four distinct query paths
mod.rs — Db::scope orchestrator; opts/result plumbing
intersection.rs — chunks query (with/without FTS, with/without empty scope, hydration)
dimensions.rs — dimensions CTE
edges.rs — edges-beyond-adjacency
time_travel.rs — `at: Some(commit)` ancestry walk
tests/ — integration tests; oracle-checked vs the TS suite
Cargo.toml
Each ops/*.rs owns its method end-to-end via
impl Db { pub fn ... }. Rust spreads impl Db
across files — that keeps ops/ flat where it can be flat.
scope/ is folded because the public method fans into four
distinct query paths.
Pattern repeated in every feature file
// 1. SQL as `const`s at the top — declarative, scannable.
const INTERSECTION: &str = "...";
// 2. The public method on Db. Reads top-to-bottom; narrates the flow.
impl Db {
pub fn scope(&self, scopes: &[ChunkId], opts: ScopeOpts)
-> Result<ScopeResult, ReadError> { ... }
}
// 3. Private free functions for shape: param prep, row mapping.
fn row_to_chunk_item(row: &Row) -> Result<ChunkItem> { ... }git diff between any two feature files reads as the same
shape with different verbs. Coherence through pattern, not folder.
Key mechanics
Connection ownership.
Db { conn: Mutex<Connection>, sender: broadcast::Sender<Commit> }.
Every op locks before touching the connection. SQLite is single-writer
anyway; the mutex is uncontested in practice and gives
Db: Send + Sync for free. Subscribers hold their own
broadcast::Receiver; they do not retain
&Db.
Transactions. No custom RAII helper.
conn.transaction_with_behavior(TransactionBehavior::Immediate)?
returns rusqlite's Transaction — Drop = ROLLBACK; explicit
tx.commit() advances. Used directly inside
ops::commit and ops::branches.
Reactivity push. After tx.commit()
returns Ok, the op pushes the resulting Commit onto
db.sender. Three call sites: ops::commit,
ops::branches::create_branch,
ops::branches::delete_branch. Two lines each; repetition
over a wrapper. By the time the push runs, the SQL commit is durable and
visible to any subsequent reader.
Validation. Rule enum with one variant
per rule (Ordered, Accepts,
Required, Unique, NameUnique);
match dispatches inside
check_commit(conn, branch, touched). Adding a rule = adding
a variant. Reads run through ordinary SELECTs against the open
transaction's connection — no pre-fetch into pure structs.
Errors. thiserror. Per-op enums
(OpenError, ReadError,
WriteError) with shared variants (e.g.
IoError(rusqlite::Error)) duplicated across them —
dumb-but-clear over a single mega-enum.
IDs as newtypes. ChunkId(String),
CommitId(String), BranchName(String) with
From<&str> and Display.
Sync surface, async reactivity. scope,
get, commit, create_branch,
delete_branch are sync (SQLite is sync).
subscribe_scope returns a Stream — async, the
natural shape for change feeds (via
tokio_stream::wrappers::BroadcastStream).
Settled choices
- Direct struct construction for
Declaration, with helper free-functions where useful. No builder. rusqlite_migrationwith the full schema as the v1 migration.- JSON for body and spec. Body is
serde_json::Value. Spec is a typed struct with#[serde(default)]. - Bootstrap idempotence. A
metatable with a single bootstrap-version row;open()checks before seeding.
What is open
- Cross-call read snapshots — explicit handles for multi-read consistency.
- Branch-meta commits — whether
create_branch/delete_branchshould write commits on a meta-branch for uniform traceability. - Mixing virtual and real scopes in one
scopecall. Currently rejected. - FTS branch-scoping — currently FTS holds all branches; branch filter at query time.
- Bootstrap IDs — resolved at the substrate level (lookup-by-name); carries through.
- Time-travel query optimization — recursive ancestry walk is correct but unmeasured.