From bbac338f760762edc17c5e177a00d11ff6be3ccb Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 18:52:15 -0700 Subject: [PATCH] Scaffold cargo workspace + heph-core foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .forgejo/scripts/build | 30 ++ .gitignore | 3 + AGENTS.md | 33 +- Cargo.lock | 420 ++++++++++++++++++++++ Cargo.toml | 20 ++ crates/heph-core/Cargo.toml | 14 + crates/heph-core/src/clock.rs | 22 ++ crates/heph-core/src/error.rs | 24 ++ crates/heph-core/src/lib.rs | 21 ++ crates/heph-core/src/model.rs | 91 +++++ crates/heph-core/src/sqlite/migrations.rs | 112 ++++++ crates/heph-core/src/sqlite/mod.rs | 213 +++++++++++ crates/heph-core/src/store.rs | 23 ++ docs/changelog.d/v1-prototype.feature.md | 1 + 14 files changed, 1010 insertions(+), 17 deletions(-) create mode 100755 .forgejo/scripts/build create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/heph-core/Cargo.toml create mode 100644 crates/heph-core/src/clock.rs create mode 100644 crates/heph-core/src/error.rs create mode 100644 crates/heph-core/src/lib.rs create mode 100644 crates/heph-core/src/model.rs create mode 100644 crates/heph-core/src/sqlite/migrations.rs create mode 100644 crates/heph-core/src/sqlite/mod.rs create mode 100644 crates/heph-core/src/store.rs create mode 100644 docs/changelog.d/v1-prototype.feature.md diff --git a/.forgejo/scripts/build b/.forgejo/scripts/build new file mode 100755 index 0000000..1a6afd8 --- /dev/null +++ b/.forgejo/scripts/build @@ -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 diff --git a/.gitignore b/.gitignore index 46e9957..f74e48c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ __pycache__/ *.pyo .venv/ +# Rust +/target/ + # Linter caches .ruff_cache/ diff --git a/AGENTS.md b/AGENTS.md index 4e0d4cc..53ec884 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..766cb10 --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ee065a0 --- /dev/null +++ b/Cargo.toml @@ -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 "] +rust-version = "1.85" + +[workspace.dependencies] +rusqlite = { version = "0.32", features = ["bundled"] } +ulid = "1" +thiserror = "2" +anyhow = "1" + +[profile.release] +lto = "thin" diff --git a/crates/heph-core/Cargo.toml b/crates/heph-core/Cargo.toml new file mode 100644 index 0000000..f70dc26 --- /dev/null +++ b/crates/heph-core/Cargo.toml @@ -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 diff --git a/crates/heph-core/src/clock.rs b/crates/heph-core/src/clock.rs new file mode 100644 index 0000000..caf54d1 --- /dev/null +++ b/crates/heph-core/src/clock.rs @@ -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 + } +} diff --git a/crates/heph-core/src/error.rs b/crates/heph-core/src/error.rs new file mode 100644 index 0000000..e69957b --- /dev/null +++ b/crates/heph-core/src/error.rs @@ -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 = std::result::Result; diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs new file mode 100644 index 0000000..566774d --- /dev/null +++ b/crates/heph-core/src/lib.rs @@ -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; diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs new file mode 100644 index 0000000..2278999 --- /dev/null +++ b/crates/heph-core/src/model.rs @@ -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 { + 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, + /// 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, +} + +impl NewNode { + /// A document node with a title and body. + pub fn doc(title: impl Into, body: impl Into) -> NewNode { + NewNode { + kind: NodeKind::Doc, + title: title.into(), + body: Some(body.into()), + } + } +} diff --git a/crates/heph-core/src/sqlite/migrations.rs b/crates/heph-core/src/sqlite/migrations.rs new file mode 100644 index 0000000..62a7e55 --- /dev/null +++ b/crates/heph-core/src/sqlite/migrations.rs @@ -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) +} diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs new file mode 100644 index 0000000..6db3734 --- /dev/null +++ b/crates/heph-core/src/sqlite/mod.rs @@ -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, +} + +impl LocalStore { + /// Open (creating if needed) a SQLite database at `path`. + pub fn open(path: impl AsRef, clock: Box) -> Result { + let conn = Connection::open(path)?; + Self::init(conn, clock) + } + + /// Open a throwaway in-memory database — for tests. + pub fn open_in_memory(clock: Box) -> Result { + let conn = Connection::open_in_memory()?; + Self::init(conn, clock) + } + + fn init(conn: Connection, clock: Box) -> Result { + 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 { + 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 { + 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 { + 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> { + 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); + } +} diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs new file mode 100644 index 0000000..6c7a8ed --- /dev/null +++ b/crates/heph-core/src/store.rs @@ -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; + + /// 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>; +} diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md new file mode 100644 index 0000000..df5c325 --- /dev/null +++ b/docs/changelog.d/v1-prototype.feature.md @@ -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.