Files
rose-ash/plans/persist-on-sx.md
giles 156d6f12ec
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
persist: optimistic concurrency — conflict as a real result + 8 tests
concurrency.sx: persist/append-expect refuses an append when the stream
advanced past the caller's expected seq, returning {:conflict :expected
:actual} instead of crashing or overwriting. persist/conflict? + accessors.
Phase 2 complete, 54/54.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:37:49 +00:00

8.3 KiB
Raw Blame History

persist-on-sx: Durable state on the SX kernel

DRAFT outline. Foundation subsystem — the durable substrate the other five currently fake with in-memory mutable lists. Build this first.

"persist" = persistence / data store, NOT the shop. The shop/commerce vertical is commerce-on-sx.

rose-ash needs durable state: every subsystem (feed log, flow store, mod audit, search index, acl grants, sessions) today hand-rolls an in-memory structure that vanishes on restart. persist-on-sx is the one durable substrate they share. It lives directly on the SX kernel's IO-suspension primitives (perform/cek-resume — the third CEK phase) so a read/write performs and the kernel persists at the boundary. Concrete storage backends are injected.

Does it cover ALL persistence? No — and on purpose.

Event-sourcing-everything is a known trap (replay cost, event schema evolution, awkward ad-hoc queries, 5MB images in a log). So persist owns the durable source-of-truth substrate, exposed as two facets over one backend protocol, with two things explicitly delegated out:

Shape Owner Notes
Event streams (append-only, history matters) persist — log facet feed activities, mod audit, order ledger, flow state, content edits
Current-state values (KV / document, no history) persist — kv facet profiles, stock counts, config, session blobs; also where projections materialize
Snapshots / read models (derived, queryable) persist — projections → kv/log rebuildable from the log; persisted so you don't replay to answer a query
Blobs / large objects (images, media) delegated → content-addressed store (artdag/IPFS already) persist stores the reference/CID, never the bytes
Cache (ephemeral, evictable) out of scope not persistence — different lifecycle (Redis-shaped)
Ad-hoc relational query the subsystem, over a projected read model the log is bad at "all orders by X in March"; project into a queryable kv/SQL backend

So: persist is the single durable substrate for state that's either a stream of changes or a current value — but it does not force everything into an event log, it does not hold blobs (only their content-addressed refs), and it does not do caching. Those boundaries are the whole point of calling it a substrate rather than "the database."

End-state: log (append/read streams) + kv (get/put/delete by key) facets, an injectable backend protocol (mem → file → Postgres → IPFS-ref), pure projections with incremental snapshots, optimistic concurrency, and a subscription hook so read models (feeds, indices, audit logs) update incrementally.

Status (rolling)

bash lib/persist/conformance.sh54/54 (Phases 12 done)

Ground rules

  • Scope: only lib/persist/** and plans/persist-on-sx.md. May import the kernel's IO-suspension surface (perform, platform IO ops) — verify what's exported first. Do not add host primitives; a missing durable IO op is a Blockers entry (it belongs in hosts/, out of scope).
  • Architecture: an event is {:stream :seq :type :at :data}; the log is an ordered append-only vector; a projection is (fold step seed events); a kv value is (get/put/delete key). Both facets sit on one injected backend {:append :read :kv-get :kv-put :snapshot-read :snapshot-write}. The in-memory backend is the test default; real backends wire in unchanged.
  • Determinism: replay is pure — same log → same state, always. No clocks or randomness inside projections; time lives on the event.
  • Blobs: store the content-address/CID and metadata; never the bytes. The blob backend is a separate injected dependency.
  • Commits: one feature per commit. Progress log + tick boxes.

Architecture sketch

Command / write                         Read model / value
  (append stream type data)               (project stream step seed)
  (kv-put key value)                       (kv-get key)
        │                                       ▲
        ▼                                       │
lib/persist/event.sx                    lib/persist/project.sx
  — {:stream :seq :type :at :data}        — fold step seed; incremental from snapshot
        │                                       ▲
        ▼                                       │
lib/persist/log.sx   lib/persist/kv.sx   lib/persist/snapshot.sx
  — append/read       — get/put/delete    — checkpoint; replay = snapshot + tail
  — optimistic seq    — current-state
        │                  │                    ▲
        └──────────────────┴── (perform → backend) ───┘
                            │
lib/persist/backend.sx              lib/persist/api.sx
  — injected protocol                 — (persist/append) (persist/project)
  — mem | file | pg | ipfs-ref        — (persist/kv-get/put) (persist/subscribe)
        │
        └── blobs → content-addressed store (artdag/IPFS), by reference only

Phase 1 — Log + kv + in-memory backend

  • event.sx — event record, stream/seq helpers
  • backend.sx — injectable protocol + in-memory impl (log + kv)
  • log.sxappend (optimistic seq), read, read-from
  • kv.sxget/put/delete current-state
  • api.sx + tests + scoreboard + conformance.sh

Phase 2 — Projections + subscriptions

  • project.sx(project stream step seed), incremental fold
  • subscription hook — projection / kv read model re-runs on append
  • concurrency conflict surfaced as a real result, not a crash

Phase 3 — Snapshots + replay

  • snapshot.sx — checkpoint a projection; replay = snapshot + tail
  • compaction policy; replay-determinism tests

Phase 4 — Durable backends via kernel IO

  • file/log backend driven through perform (IO-suspension boundary)
  • blob backend interface (store ref/CID; bytes live in artdag/IPFS)
  • crash/restart replay test (mock IO platform)
  • migration notes for swapping mem → durable under a live subsystem

Consumers (post-foundation, not in scope here)

feed/-log, flow store, mod/audit, search index, acl grants, identity sessions all become persist log or kv. Track each migration in that subsystem's plan.

Progress log

  • Phase 2c (54/54) — Phase 2 complete. concurrency.sx — optimistic concurrency: persist/append-expect b stream expected ... refuses the append if the stream advanced past expected, returning a conflict VALUE {:conflict true :expected :actual} (never a crash, never a silent overwrite). persist/conflict? + accessors; caller re-reads actual and retries. 8 tests incl. two-writer race + retry.
  • Phase 2b (46/46). subscribe.sxpersist/hub wraps a backend with per-stream callbacks. persist/publish appends then fires subscribers (backend stream event); direct persist/append bypasses them by design (bulk load/replay). Canonical use: callback re-runs project-resume or bumps a kv counter so read models update on write. 9 tests.
  • Phase 2a (37/37). project.sx — projection state {:value :seq}; persist/project folds whole stream from seed, persist/project-resume folds only the tail (seq > prior seq) so read models update incrementally. step is pure (value event) -> value. 9 tests incl. resume==full-from-zero.
  • Phase 1 complete (28/28). event.sx (event record + accessors), backend.sx (injectable protocol + in-memory log/kv impl, closure state via set!), log.sx (append/read/read-from, sequential per-stream seq, stream isolation), kv.sx (get/put/delete/has?/keys/get-or/update), api.sx (persist/open — mem default, backend injectable). conformance.sh + three suites (event/log/kv). Gotcha logged in Blockers: map returns an array-backed list not equal? to a (list ...) literal — assertions build compared lists with list/nth.

Blockers

  • Not a blocker, a testing convention: map returns an array-backed list that is NOT equal? to a (list ...) cons-literal (two map results do compare equal to each other). When asserting list-shaped results against a (list ...) literal, build the compared value with list/nth/cons, not map. into/list-coercion needs the IO bridge and is unusable in the pure-eval harness.