Changelog

Copy Markdown

0.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/1 calls 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 ps scan refuses with {:error, {:data_dir_lock_suspected, dir, suspects}} when a process whose executable is named surreal carries the exact surrealkv://dir/data endpoint as a whitespace-delimited argv token — verified against a real foreign surreal start child 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.
  • 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 by stop/1 under 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}} — for stop/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 rescued port_info branch, not just :no_os_pid) and retains the claim with the receipt. Test teardown returns explicit evidence — teardown_foreign_holder/4 reports {: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/1 normalizes omitted ns/db to explicit nils before connecting, so a root config with the keys omitted behaves exactly like ns: nil, db: nil — previously it raised a KeyError whose 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 integer connect_timeout), connect and establishment — runs under rescue/catch that closes any acquired socket and returns a sanitized typed error; open/1 neither 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.call in Tx.run/4 (which exited the caller with :noproc on 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/2 is 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 :full at 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.Outcome with 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 the failed mark be written); everything else — including the commit-failure rewrite (multiple rewritten Internal errors, 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. Crashing vars/verify_applied callbacks finalize normally ({:callback_crash, class}, lock released); a crashed query function during an operation with durable effect is treated like transport loss. check/1 refuses non-map members with a typed error instead of raising.
  • Version qualification (A-03): the package claims SurrealDB 3.0.5, not "3.x". TestServer resolves the executable, records its full surreal version identity as the first line of server.log, and refuses to start a different version unless SURREAL_ALLOW_UNQUALIFIED=1 explicitly 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, so Admin.migrate there 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_all process is gone before on_exit runs — 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!/1 on 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 sets SURREAL_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/0 is 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.
  • Tx module docs no longer recommend <string>$p for all strings — date-shaped strings survive only via DrinkMeAlice.exact_string/1.
  • Json module introduction describes the decoded-byte preflight the implementation actually performs (the old text described a raw-span scan).
  • DrinkMeAlice.check_param_names/1 no 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 /proc where present and fall back to ps elsewhere (no more Linux-shaped placeholders on macOS).

Release preparation still open

  • Package links are empty pending a public repository home.