Menu
Public repository / c2f43d4988686a5b5142bb6dd4255f9b473792dc
AkurAI-Notes / .plan/epics/E04-notes-core-and-storage.md
Latest change f1d9b8cc6ff48113d891a2d95fd79b2c49f1f4ff - Initial plan and .plan directory by Claude Code
# E04 — notes-core + storage + collections schema
## Goal
The data model: `notes`, `notebooks`, `tags`. Storage is the Framework's `BTree`
via `akurai-storage`. Schema is declarative in
`site/backend/collections.toml` (Framework principle #5). Auto-REST API mounts
`/api/notes`, `/api/notebooks`, `/api/tags` via `akurai-collections`.
## Reference sources
- `~/Projects/AkurAI-Framework/AGENTS_DATA.md` — the B+tree key layout, the `coll:`
/ `\0`-prefix reserved keyspaces, and the embedding / `?search` lifecycle.
- `~/Projects/akurai-crm/crates/crm-core/src/{entities,metadata,types,error.rs}` —
full entity-metadata pattern to copy 1:1.
- `~/Projects/AkurAI-Framework/site/backend/collections.toml` — Framework's
canonical demo collection (one collection, fields with `embed = true`).
- **Oblivion references:** read the data-model files in
`.references/oblivion/` after cloning (E01 made `.references/` a dir).
## Pre-flight
Read `~/Projects/AkurAI-Framework/crates/collections/src/lib.rs` and
`AGENTS_DATA.md` to confirm the keyspace prefix the auto-API uses for each
collection. The CRM keyspace is `people:<id>`, `companies:<id>`, etc. — Notes
will use `note:<id>`, `notebook:<id>`, `tag:<id>`. Confirm by reading
`~/Projects/AkurAI-Framework/crates/collections/src/<the file that constructs
the prefix>` (likely `store.rs` or `lib.rs`).
Also, clone the references for UX inspiration (no build imports):
```bash
cd ~/Projects/AkurAI-Notes/.references
git clone --depth 1 https://github.com/elie222/oblivion.git oblivion 2>&1 | tail -5
# Top 4 open-source note apps (from memory / Ólafur's preferences):
git clone --depth 1 https://github.com/logseq/logseq.git logseq
git clone --depth 1 https://github.com/silverbulletmd/silverbullet.git silverbullet
git clone --depth 1 https://github.com/usememos/memos.git memos
git clone --depth 1 https://github.com/obsidianmd/obsidian-sample.git obsidian-sample 2>/dev/null || echo "obsidian missing — skip"
# These are refs only — they will never be imported into our build, only read.
# Add a `.gitignore` rule so they're not committed to our repo:
printf '\n/.references/\n' >> ~/Projects/AkurAI-Notes/.gitignore
```
(If `oblivion` is private/missing, fall back to `elie222/oblivion-docs` or note
the clone failure in the PR and proceed — it's reference material, not a build
dependency.)
## Data model
### `notes`
```toml
[[collection]]
name = "notes"
[[collection.field]]
name = "title"
type = "text"
required = true
embed = true # Framework auto-API: this field is embedded on insert
[[collection.field]]
name = "body"
type = "text"
embed = true # body also embedded (title + body concat = search corpus)
[[collection.field]]
name = "format"
type = "text" # "markdown" | "plain" — v0.0.1 supports markdown only
[[collection.field]]
name = "notebookId"
type = "int"
index = true # filter by notebook quickly
[[collection.field]]
name = "ownerSub"
type = "text"
index = true # every query is scoped to the current user
[[collection.field]]
name = "tagIds"
type = "text" # comma-separated ints for v0.0.1 simplicity
[[collection.field]]
name = "createdAt"
type = "int"
index = true
[[collection.field]]
name = "updatedAt"
type = "int"
index = true
[[collection.field]]
name = "archived"
type = "bool"
```
### `notebooks`
```toml
[[collection]]
name = "notebooks"
[[collection.field]] name = "name", type = "text", required = true, embed = true
[[collection.field]] name = "description", type = "text"
[[collection.field]] name = "ownerSub", type = "text", index = true
[[collection.field]] name = "color", type = "text" # hex token for the chip
[[collection.field]] name = "createdAt", type = "int", index = true
```
### `tags`
```toml
[[collection]]
name = "tags"
[[collection.field]] name = "name", type = "text", required = true, embed = true
[[collection.field]] name = "ownerSub", type = "text", index = true
[[collection.field]] name = "color", type = "text"
[[collection.field]] name = "createdAt", type = "int"
```
## Steps
### 1. Write `crates/notes-core/src/entities/`
One Rust file per entity, each defining a plain struct with `pub` fields. Copy
the CRM pattern exactly (`~/Projects/AkurAI-crm/crates/crm-core/src/entities/`).
The fields are informational — the storage layout comes from `collections.toml`,
but having a typed Rust struct lets handlers pass strongly-typed data around
instead of `Value` everywhere.
`notes-core/src/entities/note.rs`:
```rust
use akurai_json::Value;
pub struct Note {
pub id: i64,
pub title: String,
pub body: String,
pub format: String,
pub notebook_id: i64,
pub owner_sub: String,
pub tag_ids: Vec<i64>,
pub created_at: i64,
pub updated_at: i64,
pub archived: bool,
}
impl Note {
pub fn from_json(v: &Value) -> Option<Self> { /* read fields via v.get(…) */ }
pub fn to_json(&self) -> Value { /* build Value::Object */ }
pub fn embed_text(&self) -> String {
// The text feed to the embedding model: title + body concat.
format!("{} {}", self.title, self.body)
}
}
```
`notebook.rs` and `tag.rs` are smaller versions of the same.
`entities/mod.rs` re-exports them: `pub use note::*; pub use notebook::*; pub use tag::*;`
### 2. Write `crates/notes-core/src/metadata/`
One `ObjectMetadata` struct per entity. The metadata crate at
`~/Projects/AkurAI-crm/crates/crm-core/src/metadata/` defines
`ObjectMetadata::standard_objects()` which returns the three CRM entities.
Notes does the same, returning the three Notes objects. The metadata is used by
the `GET /api/_meta` route to self-describe the API.
### 3. Persist the TOML schema in `site/backend/collections.toml`
Concatenate the three collections above into the file
`~/Projects/AkurAI-Notes/site/backend/collections.toml`. Framework cli loads it
on `serve`. The auto-API mounts at `/api/notes`, `/api/notebooks`, `/api/tags`
with full CRUD + `?search`.
### 4. Write `notes-api/src/handlers/crud.rs`
For v0.0.1 we mostly lean on the Framework's auto-collections-API helper (see
`CODE-PATTERNS.md §6`). The two notes-specific concerns:
- **Owner scoping.** Every list/get/update/delete must verify `owner_sub ==
session.user.sub`. The auto-API doesn't do this — wrap it.
- **Embedding on insert.** After an `INSERT` on `notes`, schedule an embedding
POST to the embedding URL and persist the f32 byte vec under
`note_emb:<id>`. The Framework's auto-API knows about `embed = true` already
so this may be automatic if `AKURAI_NOTES_EMBED_URL` is set — verify in
`~/Projects/AkurAI-Framework/crates/cli/src/collections_api.rs` and `crates/
vector/client.rs`. **E05 owns this** — E04 only writes the handler scaffolding
and leaves the embed call as a `// TODO: see E05` placeholder.
A `crud.rs` handler shape (port of CRM's `handlers/crud.rs`):
```rust
pub fn list_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteHandler {
Box::new(move |req: &Request| -> Response {
let user = match require_session(&state, req) { Some(u) => u, None => return Response::unauthorized() };
let s = state.lock();
let prefix = format!("{}:", coll);
let mut out = Vec::new();
for (k, v) in s.db.range_prefix(prefix.as_bytes()) {
if let Ok(rec) = akurai_json::parse(&String::from_utf8_lossy(&v)) {
if rec.get("ownerSub").and_then(|v| v.as_str()) == Some(&user.sub) {
out.push(rec);
}
}
}
json_response(Value::Array(out))
})
}
```
Use the CRM `handlers/crud.rs` as the port source — the per-collection dispatch
shape is identical; only `ownerSub` filtering is Notes-specific.
### 5. Write `notes-api/src/handlers/meta.rs` (port of CRM's `meta.rs`)
Lists all routes + their inputs/outputs as JSON. Read the CRM version (read it on
disk; it's <80 lines) and port with Notes routes + Fields.
### 6. Wire owner-scoped routes into `router_setup.rs`
Update `router_setup.rs` to mount:
```
GET /api/notes + /api/notes/:id
POST /api/notes
PUT /api/notes/:id
DELETE /api/notes/:id
… same for notebooks and tags …
GET /api/timeline (recent edits, owner-scoped)
GET /api/search (E05; stub it returning [] for now)
```
### 7. Write `crates/notes-cli/src/cmd_seed.rs` (port of CRM's `cmd_seed.rs`)
Copy `~/Projects/AkurAI-crm/crates/crm-cli/src/cmd_seed.rs` verbatim as a
template, write the Notes seed data: 3 notebooks, 12 notes spanning all 3
notebooks, 6 tags, all owned by `olibuijr@olibuijr.com` so the live UI has
demo data on first deploy.
The confirm helper from `crates/notes-cli/src/cmd_seed.rs` (the closure
`|entity, id, fields: Vec<(&str, Value)>| -> Result<()> {\n … db.insert(format!("{}:{}", entity, id).as_bytes(), json.as_bytes()) ...}`)
is exactly what Notes needs — copy verbatim, swap the seed values.
### 8. Write tests
`crates/notes-api/tests/api_test.rs` — port CRM's `api_test.rs` skeleton and
test:
- `list_notes_returns_only_owned_notes` — insert notes with two different
`ownerSub`, list as one owner, assert the other owner's notes don't appear.
- `create_note_inserts_with_current_user_as_owner`
- `update_note_rejects_cross_user` (silver-bullet test for the v0.0.1
multi-user concern)
- `delete_note_rejects_cross_user`
## Files touched
```
crates/notes-core/src/lib.rs
crates/notes-core/src/entities/{mod.rs,note.rs,notebook.rs,tag.rs,embedding.rs}
crates/notes-core/src/metadata/mod.rs ← ObjectMetadata for the three entities
crates/notes-core/src/types.rs
crates/notes-core/src/error.rs
crates/notes-api/src/handlers/{mod.rs,crud.rs,meta.rs,search.rs,timeline.rs,static_files.rs}
site/backend/collections.toml
crates/notes-cli/src/cmd_seed.rs
crates/notes-api/tests/api_test.rs
.references/ ← gitignored clones of Oblivion + 4 apps
```
## Acceptance
```bash
cd ~/Projects/AkurAI-Notes
cargo test --workspace
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all -- --check
# Auto-API mounts the 3 collections:
AKURAI_NOTES_DB=/tmp/notes-e2e.db akurai-notes seed
akurai-notes serve --db /tmp/notes-e2e.db --frontend-dir site/frontend --port 9811 &
curl -fsS http://127.0.0.1:9811/api/_meta | jq '."collections" | keys' # → ["notes","notebooks","tags"]
kill %1
# Owner scoping holds:
cargo test -p notes-api --test api_test
```
## Time
≈ 10 minutes (porting heavy + writing CRUD handlers + seed).
## Depends on
E01 (workspace scaffold).