Changelog
Copy Markdown0.1.0 (2026-10-26)
First release. Extracted from the Bifrost framework's SurrealDB adapter and made framework-free: supervised connection, sessions, transactions with honest outcomes, provisioning, migrations, graph edges, Ash data layer.
TestServer data-dir exclusivity (2026-09-27, sibling flake; corrected after validation V-01..V-04)
- Concurrent
TestServer.start/1calls on the SAME data dir used to race into SurrealDB's SurrealKV LOCK: the loser died with{:startup_failed, {:early_exit, 1}, ...}and its log read "Database at .../data/LOCK is already locked by another process" — reproduced with two concurrent starts on one dir. A second feeder, verified by SIGKILLing the owning BEAM: the orphaned child survives, keeps the LOCK, and the next run onto that store flaked the same way. What is now PROVEN to be refused before spawning:- within the VM, each start claims its expanded data dir through a
DEDICATED holder process (one caller may hold several stores at
once; the holder monitors the port owner, so a claim never
outlives its owner), and the second caller on the same dir fails
with
{:error, {:data_dir_in_use, dir, holder_pid}}before any filesystem effect; - against an existing store, a bounded best-effort
psscan refuses with{:error, {:data_dir_lock_suspected, dir, suspects}}when a process whose executable is namedsurrealcarries the exactsurrealkv://dir/dataendpoint as a whitespace-delimited argv token — verified against a real foreignsurreal startchild and the killed-VM orphan. This is SUSPICION, not verified lock ownership: a command-line match is a hint to identify and confirm the process before stopping it; do not kill on a match alone, and never delete a store that may still be active; - claims are released ONLY by their own start's holder and ONLY on
proven cleanup (
{:exit_status, _}or reaped-without-status): a stale handle stopped after a replacement start cannot strip the replacement's claim, and an unproven cleanup leaves the exclusion in place until the owning process dies.stop/1-released claims keep the documented restart-on-same-dir flow working; - an early exit that still slips through the scan's race window is reported with the log tail and the suspected foreign holders.
- within the VM, each start claims its expanded data dir through a
DEDICATED holder process (one caller may hold several stores at
once; the holder monitors the port owner, so a claim never
outlives its owner), and the second caller on the same dir fails
with
- NOT claimed: preventing orphans (a spawned child can outlive its owning process and a SIGKILLed VM — measured on this runtime), and race-free cross-VM exclusion (the scan is fail-open and start-moment only). Directories are claimed by expanded path; symlink aliases are not resolved.
- Follow-up corrections (validation W-01..W-04): claim release is now
a BOUNDED SYNCHRONOUS handshake — the holder removes the
registration and acknowledges before
stop/1(or a pre-spawn failed start) reports completion, so an immediate same-dir restart is never refused by our own just-released claim; a holder that cannot answer (e.g. adversarially suspended) bounds the wait with a diagnostic instead of hanging. A failed start now carries the claim identity through acquisition: pre-spawn failures release synchronously, startup failures are finalized bystop/1under the same identity+proof gate, and UNPROVEN failed-startup cleanup retains the claim — the error carries the holder pid as a receipt, and the exclusion clears when the starting process dies. The lifecycle regressions install teardown at acquisition (failure-safe try/after boundaries, child exit proven before any store deletion), and the polling helpers carry one absolute deadline instead of renewing it per retry. - Gap closure (validation round 2, W-01..W-03 at the timeout and
cleanup-failure boundaries): when the bounded release handshake
times out, the PUBLIC result preserves both facts as
{:error, {:claim_release_unconfirmed, holder, cleanup}}— forstop/1, through startup finalization, and for pre-spawn failed starts — instead of hiding a pending release behind a successful child result; the suspended-holder regression now requires that result. Acquisition state is represented directly: every failure after the port exists is wrapped{:acquisition_unclean, reason, holder}at the site where that state is known (covering the rescuedport_infobranch, not just:no_os_pid) and retains the claim with the receipt. Test teardown returns explicit evidence —teardown_foreign_holder/4reports{:error, {:exit_unobserved, pid}}instead of a silent:ok, store deletion happens only on observed child exit (uncertainty fails the test and retains the directory), a forced-unconfirmed-teardown control exercises that path, and the deadline regression's closed port comes from a child whose exit was observed, not from closing a port on a live sleep. - Round 3 (validation probes): diagnostics are BEST-EFFORT — a group
leader that answers IO requests with an error cannot prevent startup
finalization (previously the raise escaped the health rescue
handler, the outer rescue released the claim, and the child kept
running; runtime-reproduced with a controlled qualified-version
executable that execs sleep). A hard finalization boundary now wraps
everything from acquisition: any exception after the child exists —
including one raised inside a rescue handler — becomes
{:startup_failed, {:finalization_crashed, cause}, ...}with proven cleanup or a retained claim with the holder receipt, making the outer rescue pre-spawn-only by construction. Test-side, directory deletion authority is generation-scoped: once the first server is proven stopped and the replacement attempt begins, first-generation cleanup never deletes the shared directory — the replacement's retention decision governs (regression: deterministic non-owner replacement stop forces cleanup uncertainty and verifies the store survives every enclosing cleanup block, claim still held; the replacement fixture itself is failure-safe — its shutdown boundary is installed when the owner is created, covering startup failure and timeouts, tears down in proof order with retention-and-receipt on unproven cleanup, and an intentional body-failure control verifies the full shutdown).
Repairs from the 2026-09-26 Astra audit
- Session setup (A-01, widened by review R-06):
Session.open/1normalizes omittedns/dbto explicitnils before connecting, so a root config with the keys omitted behaves exactly likens: nil, db: nil— previously it raised aKeyErrorwhose message printed the credential-bearing config map and leaked the already-connected socket. The ENTIRE setup boundary — normalization, endpoint/timeout validation (integer port in range, non-negative integerconnect_timeout), connect and establishment — runs under rescue/catch that closes any acquired socket and returns a sanitized typed error;open/1neither raises nor exits on any configuration shape, including malformed top-level forms. - Write path (A-02): the mutation clock gate moved from a
pre-admission
GenServer.callinTx.run/4(which exited the caller with:noprocon a missing owner and blocked a full 5 s regardless of the caller's deadline) into the connection owner's dispatch, serial with the owner's work, immediately before the frame is written, under the credit/deadline protocol. Uncertainty latched after admission now still refuses the mutation before send (regression-tested: no frame reaches the peer). A missing owner is a typed{:unavailable, _}; a suspended owner is bounded by the caller's deadline. - Hint subscriptions (A-04):
Conn.hint_subscribe/2is idempotent — exactly one monitor per subscriber PID. A repeat subscription used to stack a fresh monitor behind the 16-PID set bound, and an existing subscriber could receive:fullat capacity; both fixed and tested. - Migrations (A-06, corrected by review R-01/R-02/R-03): every
transaction-shaped operation (lock acquisition, migration application)
is classified by
DrinkMeAlice.Outcomewith its script's positional context — the same three-way evidence rules as the write path. Observed commit (full-length, OK/nil BEGIN and COMMIT slots, mapped decision) is the only success; a QUALIFIED abort silhouette is the only definite failure (and only for it may thefailedmark be written); everything else — including the commit-failure rewrite (multiple rewrittenInternalerrors, where the storage commit may already be durable) — is UNCERTAIN: nothing is overwritten, the lock is retained, and the result says so ({:error, {:lock_retained, {:migration_unknown, version, why}}}) with a documented reconciliation procedure. Non-transaction operations (bootstrap, state read, release) demand exact counts, well-formed{status, result}entries on every slot, and the expected result shape — an empty, truncated or malformed array is never proof of execution. Lock acquisition ending uncertain (transport loss or a crashed query function) reports{:lock_state_unknown, _}; an unproven release is surfaced as{:lock_release_unproven, _}and never attempted through a session that reported transport loss. Crashingvars/verify_appliedcallbacks finalize normally ({:callback_crash, class}, lock released); a crashed query function during an operation with durable effect is treated like transport loss.check/1refuses non-map members with a typed error instead of raising. - Version qualification (A-03): the package claims SurrealDB 3.0.5,
not "3.x".
TestServerresolves the executable, records its fullsurreal versionidentity as the first line ofserver.log, and refuses to start a different version unlessSURREAL_ALLOW_UNQUALIFIED=1explicitly opts into a compatibility experiment. Live tests skip with a printed reason on any other installed version. Known 3.2.3 behavior under that opt-in: aborted transactions return reply shapes the 3.0.5-qualified classifier cannot qualify, and the client conservatively reports unknown — never a false commit or a false definite rejection. After the review correction pass this family grew from four write-path cases to include the migration runner (a 3.2.3 abort arrives as a full-length reply with a pre-commit error, not the qualified 3.0.5 abort silhouette, soAdmin.migratethere reports{:lock_state_unknown, _}/{:migration_unknown, _}instead of a definite result; successful migrations still apply and verify normally) — preserved by design until 3.2.3 is qualified explicitly. - Live fixture (A-07): the test server's port is owned by a
dedicated owner process that stays alive through teardown (the
setup_allprocess is gone beforeon_exitruns — that is why exit statuses were unreadable and TERM grace burned on every module). Connections are stopped before the server; the data directory is claimed exclusively with a collision-resistant name and removed only when cleanup is proven; an unproven teardown fails the run and retains the directory, with a receipt for late readers. The live suite's teardown time dropped from ~35 s to ~5 s. - Transport boundary (A-05, documented): the README states the deployment boundary prominently — cleartext WebSocket only, loopback / private network / protected tunnel; unprotected networks are unsupported until verified TLS/WSS exists.
- Transport regressions (A-08, partial): dedicated local
FrameGuard/Ws boundary suite (declared-length attacks before body
bytes, fragmented aggregates at and over the bound, malformed control
frames, ping floods, close echo — each rejection paired with a nearby
valid control and a transport-release assertion). CI added for the
qualified combination (Elixir 1.20 / OTP 29 / SurrealDB 3.0.5) plus a
no-Ash consumer build check (
scripts/check-no-ash-consumer.sh).
Review correction pass (2026-09-27)
- R-01: migration uncertainty is no longer converted into a definite failure — see the corrected Migrations entry above. The shared SURREAL-004 lesson was corrected alongside.
- R-04: a late
Live.stop!/1on an already-exited fixture owner (monitor sees:noproc) now resolves through the teardown receipt instead of failing, and receipts are deleted only after a proven outcome — unproven cleanup keeps its evidence. - R-05: the qualified CI job runs the explicit downloaded
./surreal, verifies the release tarball against a committed SHA-256, and setsSURREAL_REQUIRE_QUALIFIED=1— a strict mode in which anything that would skip the live suite (wrong version, unusable binary) FAILS the run instead.TestServer.resolve_bin/0is bounded (2 s probe) and typed; a failed or hung version command is an error, never a parsed substring. - Test quality: the clock-gate race regression now holds the trigger reply behind an explicit barrier and observes the mutation admitted-and-enqueued via the credit table itself; transport burst tests synchronize on the verdict rather than loopback packetization; the no-Ash consumer check executes a public-API smoke demo instead of compiling only.
Smaller repairs
Tx.build/2's SQL bound is aligned with the encoder's per-string cap (65_536 bytes): the previous 1 MiB builder bound was unreachable — a script over 64 KiB was always refused at encode time.Txmodule docs no longer recommend<string>$pfor all strings — date-shaped strings survive only viaDrinkMeAlice.exact_string/1.Jsonmodule introduction describes the decoded-byte preflight the implementation actually performs (the old text described a raw-span scan).DrinkMeAlice.check_param_names/1no longer raises on non-atom, non-binary map keys; they are left to the encoder's typed rejection.TestServer's un-reaped-child diagnostics use/procwhere present and fall back topselsewhere (no more Linux-shaped placeholders on macOS).
Release preparation still open
- Package
linksare empty pending a public repository home.