Phase 1: v1 prototype #1

Merged
eblume merged 91 commits from feature/v1-prototype into main 2026-06-03 20:48:23 -07:00
14 changed files with 1010 additions and 17 deletions
Showing only changes of commit bbac338f76 - Show all commits

Scaffold cargo workspace + heph-core foundation
Some checks failed
Build / validate (pull_request) Failing after 3s

Kick off Phase 1 (v1 prototype) per tech-spec §11.1. Sets up the Cargo
workspace and the first TDD slice of heph-core:

- Migration runner + §4.5 SQLite schema (nodes, tasks, links, aliases,
  users, oplog, sync_state, conflicts), versioned via PRAGMA user_version.
- Clock-injected `Clock` trait (no ambient wall-clock reads; §2).
- `Store` trait + `LocalStore` SQLite backend with node create/get,
  bootstrapping the single local user (oidc_sub NULL, §13).
- Node model (kinds: doc/task/project/tag/journal).

Repo housekeeping: fill AGENTS.md Project Structure (last template TODO),
ignore /target, add self-bootstrapping .forgejo/scripts/build that runs
cargo fmt/clippy/test in CI (§9), changelog fragment.

Tests green: 4 unit tests (migration version, local-user idempotency,
create/get round-trip, missing-node None).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Erich Blume 2026-05-31 18:52:15 -07:00

30
.forgejo/scripts/build Executable file
View file

@ -0,0 +1,30 @@
#!/usr/bin/env bash
#
# Project build hook (tech-spec §9): run the Rust workspace checks in CI.
#
# Invoked by .forgejo/workflows/build.yaml after the generic `prek` pass.
# Bootstraps a minimal Rust toolchain via rustup when the runner image lacks
# one, so the suite runs without a bespoke CI image. The headless nvim e2e
# suite is added here once heph.nvim exists.
set -euo pipefail
if ! command -v cargo >/dev/null 2>&1; then
echo "cargo not found; bootstrapping Rust toolchain via rustup..."
export RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}"
export CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal
# shellcheck disable=SC1091
. "$CARGO_HOME/env"
rustup component add clippy rustfmt
fi
echo "== cargo fmt --check =="
cargo fmt --all --check
echo "== cargo clippy =="
cargo clippy --all-targets --all-features -- -D warnings
echo "== cargo test =="
cargo test --all

3
.gitignore vendored
View file

@ -6,6 +6,9 @@ __pycache__/
*.pyo
.venv/
# Rust
/target/
# Linter caches
.ruff_cache/

View file

@ -6,18 +6,7 @@ Guidance for Claude Code working in this repository. See also [[ai-assistance-gu
**hephaestus** — Personal context management system: wiki-style knowledge base and task management.
## First-Time Setup
This repository was instantiated from the `project-template` Forgejo template. Setup status:
- [x] `baseUrl` in `docs/quartz.config.ts` set to `localhost` (update once docs are hosted)
- [x] Dagger module renamed to `.dagger/src/hephaestus_ci/` (`HephaestusCi` class)
- [ ] Fill in the Project Structure section below once the Rust workspace is scaffolded
- [x] Fill in license info in `README.md` (All Rights Reserved / private)
Delete this section once the remaining items are resolved.
> **This is now a generated repo, not the template source.** C1/C2 changes use feature branches + PRs (`tea pr create`); noteworthy changes get changelog fragments in `docs/changelog.d/`.
> **This is a generated repo, not the template source.** C1/C2 changes use feature branches + PRs (`tea pr create`); noteworthy changes get changelog fragments in `docs/changelog.d/`.
## Rules
@ -52,13 +41,23 @@ See [[agent-change-process]] for the full methodology.
## Project Structure
A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo tooling. The build follows the tech-spec §11.1 slice order; crates are added to the workspace as their slice begins, so not every crate below exists yet.
```
./docs/ # Diataxis docs, Quartz config, and release content
./docs/changelog.d/ # keep only .gitkeep in the template; generated repos add towncrier fragments here
./Cargo.toml # workspace manifest (shared deps + members)
./crates/heph-core/ # core lib: data model, Store trait + SQLite store, extraction,
# recurrence, "what is next?" ranking, op-log/HLC/CRDT sync
./crates/hephd/ # daemon (planned): local/server/client modes; JSON-RPC over unix socket
./crates/heph/ # CLI (planned): export, scripting, `heph conflicts`
./heph.nvim/ # Neovim plugin (planned): primary surface; replaces obsidian.nvim
./docs/ # Diataxis docs (incl. [[design]] + [[tech-spec]]), Quartz config, release content
./docs/changelog.d/ # towncrier fragments for noteworthy changes
./.dagger/ # Dagger module (src/hephaestus_ci/) backing docs builds and releases
./.forgejo/workflows/ # generic build and release workflows for generated repos
./.forgejo/scripts/ # optional per-project build/release hooks consumed by the workflows
./.forgejo/workflows/ # build + release workflows
./.forgejo/scripts/ # per-project build/release hooks (build runs cargo test once present)
./mise-tasks/ # repo automation via `mise run`
```
Other code paths will be listed via ai-docs. When you encounter wiki-links (`[[like-this]]`) it is referring to docs/ cards.
**Development is TDD** (tech-spec §2, §9): failing test first, implement to green, commit on green. `heph-core` is clock-injected — no ambient wall-clock reads; time is always passed in. Canonical spec is [[tech-spec]]; rationale is [[design]].
Other doc paths are listed via `mise run ai-docs`. Wiki-links (`[[like-this]]`) refer to `docs/` cards.

420
Cargo.lock generated Normal file
View file

@ -0,0 +1,420 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown",
]
[[package]]
name = "heph-core"
version = "0.0.0"
dependencies = [
"rusqlite",
"thiserror",
"ulid",
]
[[package]]
name = "js-sys"
version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "ulid"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
dependencies = [
"rand",
"web-time",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

20
Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[workspace]
resolver = "2"
members = ["crates/heph-core"]
[workspace.package]
edition = "2021"
version = "0.0.0"
license = "LicenseRef-Proprietary"
publish = false
authors = ["Erich Blume <blume.erich@gmail.com>"]
rust-version = "1.85"
[workspace.dependencies]
rusqlite = { version = "0.32", features = ["bundled"] }
ulid = "1"
thiserror = "2"
anyhow = "1"
[profile.release]
lto = "thin"

View file

@ -0,0 +1,14 @@
[package]
name = "heph-core"
description = "Hephaestus core: data model, storage backend, query engine, extraction, recurrence, and sync."
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true
authors.workspace = true
rust-version.workspace = true
[dependencies]
rusqlite.workspace = true
ulid.workspace = true
thiserror.workspace = true

View file

@ -0,0 +1,22 @@
//! Clock injection.
//!
//! `heph-core` never reads the ambient wall clock (tech-spec §2): the current
//! time is always supplied through a [`Clock`]. This keeps ranking and
//! recurrence deterministic and testable. The daemon wires in a real
//! system clock; tests wire in a [`FixedClock`].
/// Source of the current time, in epoch milliseconds.
pub trait Clock: Send + Sync {
/// Current time as epoch milliseconds.
fn now_ms(&self) -> i64;
}
/// A clock pinned to a fixed instant — for deterministic tests.
#[derive(Debug, Clone, Copy)]
pub struct FixedClock(pub i64);
impl Clock for FixedClock {
fn now_ms(&self) -> i64 {
self.0
}
}

View file

@ -0,0 +1,24 @@
//! Error type for `heph-core`.
/// Errors surfaced by the core library.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A SQLite-level failure.
#[error("sqlite: {0}")]
Sqlite(#[from] rusqlite::Error),
/// The DB file is already locked by another `local`/`server` process.
#[error("store is already locked by another process: {0}")]
Locked(String),
/// A referenced node does not exist.
#[error("node not found: {0}")]
NodeNotFound(String),
/// A value in the database did not match the expected shape.
#[error("data integrity: {0}")]
Integrity(String),
}
/// Convenience result alias.
pub type Result<T> = std::result::Result<T, Error>;

View file

@ -0,0 +1,21 @@
//! `heph-core` — the Hephaestus core library.
//!
//! Data model, the [`Store`](store::Store) abstraction + local SQLite store,
//! query engine, markdown extraction, recurrence, and (later) the sync engine.
//! See `docs/reference/tech-spec.md` for the canonical specification.
//!
//! The library is synchronous and side-effect-light. All time enters through an
//! injected [`Clock`](clock::Clock) (tech-spec §2) so ranking and recurrence are
//! deterministic.
pub mod clock;
pub mod error;
pub mod model;
pub mod sqlite;
pub mod store;
pub use clock::{Clock, FixedClock};
pub use error::{Error, Result};
pub use model::{NewNode, Node, NodeKind};
pub use sqlite::LocalStore;
pub use store::Store;

View file

@ -0,0 +1,91 @@
//! Core data model: nodes and their kinds (tech-spec §4).
//!
//! Every first-class entity is a [`Node`]. Tasks, links, recurrence, and the
//! derived context-item index build on top of this base in later slices.
use crate::error::{Error, Result};
/// Discriminator for the kind of thing a node is (tech-spec §4.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeKind {
/// Rich context document (knowledge base, work-logs). Body = markdown.
Doc,
/// Thin task. Carries no body; context arrives via links.
Task,
/// Grouping/context for tasks.
Project,
/// A label.
Tag,
/// A daily note, titled by ISO date.
Journal,
}
impl NodeKind {
/// The wire/storage string for this kind.
pub fn as_str(self) -> &'static str {
match self {
NodeKind::Doc => "doc",
NodeKind::Task => "task",
NodeKind::Project => "project",
NodeKind::Tag => "tag",
NodeKind::Journal => "journal",
}
}
/// Parse a storage string back into a [`NodeKind`].
pub fn parse(s: &str) -> Result<NodeKind> {
Ok(match s {
"doc" => NodeKind::Doc,
"task" => NodeKind::Task,
"project" => NodeKind::Project,
"tag" => NodeKind::Tag,
"journal" => NodeKind::Journal,
other => return Err(Error::Integrity(format!("unknown node kind: {other}"))),
})
}
}
/// A persisted node (a row of the `nodes` table).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Node {
/// Stable, sync-safe id (ULID for content nodes; deterministic for journal/tag).
pub id: String,
/// Owning user id (per-user isolation).
pub owner_id: String,
/// What kind of node this is.
pub kind: NodeKind,
/// Human-facing title.
pub title: String,
/// Markdown body (nullable — tasks have none).
pub body: Option<String>,
/// Creation time, epoch ms.
pub created_at: i64,
/// Last-modified time, epoch ms.
pub modified_at: i64,
/// Hybrid logical clock of the last write (sync ordering; placeholder until §12).
pub hlc: String,
/// Whether the node is tombstoned (soft-deleted).
pub tombstoned: bool,
}
/// Input for creating a node.
#[derive(Debug, Clone)]
pub struct NewNode {
/// What kind of node to create.
pub kind: NodeKind,
/// Human-facing title.
pub title: String,
/// Optional markdown body.
pub body: Option<String>,
}
impl NewNode {
/// A document node with a title and body.
pub fn doc(title: impl Into<String>, body: impl Into<String>) -> NewNode {
NewNode {
kind: NodeKind::Doc,
title: title.into(),
body: Some(body.into()),
}
}
}

View file

@ -0,0 +1,112 @@
//! Schema migrations, applied in order against SQLite's `user_version`.
//!
//! Each migration is `(version, sql)`. On open we read `PRAGMA user_version`
//! and apply every migration whose version is greater, bumping the pragma as we
//! go. Migrations are additive across build slices (tech-spec §4.5 is a
//! starting point; later slices add tables such as `nodes_fts`).
use crate::error::Result;
use rusqlite::Connection;
/// The ordered list of migrations. Never reorder or mutate a shipped entry —
/// only append.
const MIGRATIONS: &[(i64, &str)] = &[(1, MIGRATION_0001)];
/// v1 — the base node graph, identity, and sync scaffolding (tech-spec §4.5).
const MIGRATION_0001: &str = r#"
CREATE TABLE users (
id TEXT PRIMARY KEY,
oidc_sub TEXT UNIQUE,
name TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE nodes (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
kind TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
body_crdt BLOB,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
hlc TEXT NOT NULL,
tombstoned INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_nodes_owner_kind ON nodes(owner_id, kind);
CREATE TABLE tasks (
node_id TEXT PRIMARY KEY REFERENCES nodes(id),
attention TEXT,
do_date INTEGER,
late_on INTEGER,
state TEXT NOT NULL,
recurrence TEXT
);
CREATE TABLE links (
id TEXT PRIMARY KEY,
src_id TEXT NOT NULL REFERENCES nodes(id),
dst_id TEXT NOT NULL REFERENCES nodes(id),
type TEXT NOT NULL,
created_at INTEGER NOT NULL,
tombstoned INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_links_src ON links(src_id, type);
CREATE INDEX idx_links_dst ON links(dst_id, type);
CREATE TABLE aliases (
node_id TEXT REFERENCES nodes(id),
alias TEXT
);
CREATE INDEX idx_aliases_alias ON aliases(alias);
CREATE TABLE oplog (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
hlc TEXT NOT NULL,
origin TEXT NOT NULL,
op_type TEXT NOT NULL,
target_id TEXT NOT NULL,
payload TEXT NOT NULL,
applied INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE sync_state (
peer TEXT PRIMARY KEY,
last_pushed_hlc TEXT,
last_pulled_hlc TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE conflicts (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
node_id TEXT NOT NULL REFERENCES nodes(id),
field TEXT NOT NULL,
local_val TEXT,
remote_val TEXT,
local_hlc TEXT,
remote_hlc TEXT,
status TEXT NOT NULL,
created_at INTEGER NOT NULL
);
"#;
/// Apply all pending migrations to `conn`.
pub fn migrate(conn: &Connection) -> Result<()> {
let current: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
for &(version, sql) in MIGRATIONS {
if version > current {
conn.execute_batch(sql)?;
// user_version doesn't accept bound params; the value is a trusted const.
conn.execute_batch(&format!("PRAGMA user_version = {version}"))?;
}
}
Ok(())
}
/// The schema version this build migrates up to (the latest migration number).
pub fn latest_version() -> i64 {
MIGRATIONS.last().map(|&(v, _)| v).unwrap_or(0)
}

View file

@ -0,0 +1,213 @@
//! SQLite-backed [`Store`] implementation.
//!
//! `LocalStore` opens a SQLite file directly. The exclusive-lock handoff of
//! tech-spec §3.1 is layered on by `hephd` when it owns the file; the store
//! itself stays a thin, synchronous SQLite wrapper so it is trivially testable
//! against an in-memory database.
mod migrations;
pub use migrations::latest_version;
use std::path::Path;
use rusqlite::{Connection, OptionalExtension, Row};
use ulid::Ulid;
use crate::clock::Clock;
use crate::error::Result;
use crate::model::{NewNode, Node, NodeKind};
use crate::store::Store;
/// A SQLite file (or in-memory database) opened directly as a backend.
pub struct LocalStore {
conn: Connection,
owner_id: String,
clock: Box<dyn Clock>,
}
impl LocalStore {
/// Open (creating if needed) a SQLite database at `path`.
pub fn open(path: impl AsRef<Path>, clock: Box<dyn Clock>) -> Result<Self> {
let conn = Connection::open(path)?;
Self::init(conn, clock)
}
/// Open a throwaway in-memory database — for tests.
pub fn open_in_memory(clock: Box<dyn Clock>) -> Result<Self> {
let conn = Connection::open_in_memory()?;
Self::init(conn, clock)
}
fn init(conn: Connection, clock: Box<dyn Clock>) -> Result<Self> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
migrations::migrate(&conn)?;
let owner_id = ensure_local_user(&conn, clock.as_ref())?;
Ok(LocalStore {
conn,
owner_id,
clock,
})
}
/// The id of the user whose data this store reads/writes.
///
/// For a local-only instance this is the single generated local user
/// (`oidc_sub = NULL`, tech-spec §13).
pub fn owner_id(&self) -> &str {
&self.owner_id
}
/// Placeholder HLC string until the real hybrid logical clock lands (§12).
///
/// Zero-padded epoch ms keeps it lexically sortable in the meantime.
fn next_hlc(&self, now_ms: i64) -> String {
format!("{now_ms:016}")
}
}
/// Ensure a single local user exists, returning its id.
fn ensure_local_user(conn: &Connection, clock: &dyn Clock) -> Result<String> {
if let Some(id) = conn
.query_row(
"SELECT id FROM users ORDER BY created_at LIMIT 1",
[],
|r| r.get::<_, String>(0),
)
.optional()?
{
return Ok(id);
}
let id = Ulid::new().to_string();
conn.execute(
"INSERT INTO users (id, oidc_sub, name, created_at) VALUES (?1, NULL, 'local', ?2)",
(&id, clock.now_ms()),
)?;
Ok(id)
}
/// Map a `nodes` row to a [`Node`]. Column order must match the SELECT.
fn row_to_node(row: &Row) -> rusqlite::Result<Node> {
Ok(Node {
id: row.get("id")?,
owner_id: row.get("owner_id")?,
kind: NodeKind::parse(&row.get::<_, String>("kind")?)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
title: row.get("title")?,
body: row.get("body")?,
created_at: row.get("created_at")?,
modified_at: row.get("modified_at")?,
hlc: row.get("hlc")?,
tombstoned: row.get::<_, i64>("tombstoned")? != 0,
})
}
const NODE_COLUMNS: &str =
"id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned";
impl Store for LocalStore {
fn create_node(&mut self, input: NewNode) -> Result<Node> {
let now = self.clock.now_ms();
let node = Node {
id: Ulid::new().to_string(),
owner_id: self.owner_id.clone(),
kind: input.kind,
title: input.title,
body: input.body,
created_at: now,
modified_at: now,
hlc: self.next_hlc(now),
tombstoned: false,
};
self.conn.execute(
"INSERT INTO nodes (id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0)",
(
&node.id,
&node.owner_id,
node.kind.as_str(),
&node.title,
&node.body,
node.created_at,
node.modified_at,
&node.hlc,
),
)?;
Ok(node)
}
fn get_node(&self, id: &str) -> Result<Option<Node>> {
let node = self
.conn
.query_row(
&format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id = ?1"),
[id],
row_to_node,
)
.optional()?;
Ok(node)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FixedClock;
fn store_at(now_ms: i64) -> LocalStore {
LocalStore::open_in_memory(Box::new(FixedClock(now_ms))).expect("open in-memory store")
}
#[test]
fn migrations_bring_schema_to_latest() {
let store = store_at(0);
let v: i64 = store
.conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, latest_version());
}
#[test]
fn opening_twice_is_idempotent_for_the_local_user() {
// Re-running init against the same connection-equivalent must not create
// a second local user; ensure_local_user is the guard.
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
migrations::migrate(&conn).unwrap();
let a = ensure_local_user(&conn, &FixedClock(1)).unwrap();
let b = ensure_local_user(&conn, &FixedClock(2)).unwrap();
assert_eq!(a, b);
}
#[test]
fn create_then_get_round_trips() {
let mut store = store_at(1_700_000_000_000);
let created = store
.create_node(NewNode::doc(
"Roof leak log",
"# Roof\n\nCalled contractor.",
))
.unwrap();
assert_eq!(created.kind, NodeKind::Doc);
assert_eq!(created.title, "Roof leak log");
assert_eq!(
created.body.as_deref(),
Some("# Roof\n\nCalled contractor.")
);
assert_eq!(created.created_at, 1_700_000_000_000);
assert_eq!(created.modified_at, 1_700_000_000_000);
assert!(!created.tombstoned);
assert_eq!(created.owner_id, store.owner_id());
let fetched = store.get_node(&created.id).unwrap();
assert_eq!(fetched.as_ref(), Some(&created));
}
#[test]
fn get_missing_node_is_none() {
let store = store_at(0);
assert_eq!(store.get_node("01ARYZ6S41NONEXISTENT00000").unwrap(), None);
}
}

View file

@ -0,0 +1,23 @@
//! The storage abstraction (tech-spec §3.1).
//!
//! A runtime points at *something that stores nodes*; whether that is a local
//! SQLite file ([`crate::sqlite::LocalStore`]) or a remote server (a future
//! `RemoteStore`) is configuration. This trait is the seam.
use crate::error::Result;
use crate::model::{NewNode, Node};
/// A backend that can store and retrieve nodes.
///
/// Methods that mutate take `&mut self`: a `LocalStore` holds an exclusive lock
/// on its file, so single-writer semantics are honest at the type level.
pub trait Store {
/// Create a node, assigning it an id and timestamps. Returns the stored row.
fn create_node(&mut self, input: NewNode) -> Result<Node>;
/// Fetch a node by id. Returns `None` if it does not exist.
///
/// Tombstoned nodes are still returned here (callers that must exclude them
/// — `next`, `list`, `search`, `export` — filter explicitly).
fn get_node(&self, id: &str) -> Result<Option<Node>>;
}

View file

@ -0,0 +1 @@
Begin the v1 prototype (Phase 1): scaffold the Cargo workspace and the `heph-core` crate with a migration-run SQLite schema (tech-spec §4.5), a clock-injected `Store` trait, and a `LocalStore` backend with node create/get. CI now runs the Rust suite (fmt/clippy/test) via the project build hook.