diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml index 12a35c059..570693a22 100644 --- a/.github/workflows/policy.yml +++ b/.github/workflows/policy.yml @@ -4,6 +4,7 @@ name: Policy # - msrv: the crate builds and its unit tests pass on `rust-version` # - semver-checks: no accidental public-API break against the latest release tag # - cargo-deny: dependency licenses, duplicate versions and sources (deny.toml) +# - golden-corpus: golden files in tests/golden/ are only ever added (#391) on: push: @@ -88,3 +89,25 @@ jobs: with: # Advisories are covered by cargo-audit.yml and osv-scanner.yml. command: check bans licenses sources + + golden-corpus: + name: Golden files are add-only + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Refuse modified, deleted or renamed golden files + env: + BASE_REF: ${{ github.base_ref }} + run: | + changed=$(git diff --name-only --diff-filter=MDRT "origin/${BASE_REF}...HEAD" -- \ + 'tests/golden/*.graph' 'tests/golden/*.wal') + if [ -n "$changed" ]; then + echo "::error::Golden files are never regenerated, only added (tests/golden/README.md):" + echo "$changed" + exit 1 + fi diff --git a/.gitignore b/.gitignore index e38b5879c..e4dbd321a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ minigraf-ffi/python/minigraf/minigraf_ffi/ minigraf-ffi/python/**/__pycache__/ minigraf-ffi/python/.venv/ minigraf-ffi/python/dist/ + +# golden-file generator build output (tests/golden/gen/*) +tests/golden/gen/*/target/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e9fd732b..63aab6159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Tests + +- **Golden-file compatibility corpus (#391).** `tests/golden/` holds v7 files written by minigraf 2.0.3, each with a JSON manifest of its CRC32, `tx_count` and expected query results. They cover one checkpoint, several checkpoints, indexes rebuilt on open, a WAL left by a crash, a WAL whose entries were already checkpointed, and same-transaction multi-values (#371). `tests/golden_corpus_test.rs` opens a copy of each file and checks its manifest after open, after a checkpoint and reopen, and after one more write. Golden files are never regenerated, only added; a new `golden-corpus` job in `policy.yml` fails a pull request that changes one. The generator is a standalone crate pinned to `minigraf = "=2.0.3"` (`tests/golden/gen/v7/`). + ## v2.0.3 — 2026-10-06 Patch release on the v2.x line with two data-integrity fixes, under the [support policy](PHILOSOPHY.md#support-policy). File format is unchanged (v7). No API changes. Upgrading is recommended for every v2.x user. diff --git a/CLAUDE.md b/CLAUDE.md index e66652032..ed92143ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,7 +174,7 @@ Auto-migrates v1/v2/v3/v4/v5/v6 → v7 on open/checkpoint. ## Test Coverage -**1219 tests** (1211 passing, 8 ignored; unit + integration + doc). +**1221 tests** (1213 passing, 8 ignored; unit + integration + doc). See `docs/TEST_COVERAGE.md` for the full per-file breakdown. **Testing conventions** — see the Testing Conventions section below before writing any tests. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92ae86877..0fb383d26 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,6 +100,13 @@ Bugs that can lose, corrupt or hide data are handled so that users can always se At release time, the maintainer updates the pinned issue and closes the issues whose fixes are in the release. +## Golden Files + +`tests/golden/` holds database files written by released versions, each with a manifest of the results it must give (#391). See [`tests/golden/README.md`](tests/golden/README.md). + +- Golden files are **never regenerated, only added**. CI fails a pull request that modifies, deletes or renames one. +- Every release that changes on-disk behavior (a new format version, a new page or WAL shape, a migration) adds golden files for it, with the recipe in a generator crate under `tests/golden/gen/`. + ## Philosophy Check Before submitting, ask yourself: diff --git a/Cargo.toml b/Cargo.toml index fa1e81c16..5d92f12b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = ["."] -exclude = ["fuzz"] +exclude = ["fuzz", "tests/golden/gen/v7"] resolver = "2" [package] diff --git a/README.md b/README.md index e8055c485..0c0eb461f 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ let r2 = pq.execute(&[("tx", BindValue::TxCount(2)), ("entity", BindValue::Entit ```bash cargo run # interactive Datalog REPL -cargo test # run 1219 tests +cargo test # run 1221 tests cargo run < demos/demo_recursive.txt # recursive rules demo ``` diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 495ccfa97..450203b86 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -4,9 +4,9 @@ **Verified**: 2026-10-06 with `cargo test` -**Result**: 1,211 passing tests and 8 ignored tests (1,219 total) +**Result**: 1,213 passing tests and 8 ignored tests (1,221 total), including the golden-file corpus added after v2.0.3 -The v2.0.3 suite covers the public database API, the Datalog engine, storage and recovery paths, and the v2.0.0 locking and structured-error-code changes, plus v2.0.1's regression test for rebuilding indexes on open after several checkpoints (#370). Since v2.0.1, `tests/point_query_history_test.rs` and new storage unit tests check that attribute-narrowed bound-entity lookups return exactly what a full scan returns, and that `net_asserted_facts` matches its previous implementation on randomized histories (#323). Attribute-scan tests check that the AEVT range covers exactly the queried attribute, including non-ASCII names such as `:丿` and prefix siblings such as `:ab` for `:a` (#381). Incremental B+tree rebuild tests check that checkpoints which copy untouched index leaves produce exactly the entries and file checksum a full rebuild would, including over repeated checkpoints, that single-fact checkpoints do not fragment the index, and that a cyclic leaf chain or damaged slot directory is rejected rather than copied (#315). Directory-sync unit tests check that creating the `.graph` file or the WAL, and deleting the WAL, fsync the parent directory after the file operation, and that reopening an existing file does not (#389). Disjunction tests check that `or-join` returns an empty result, not INT-031, when no rows reach it: on an empty database, after a pattern that matches nothing, after a `not` that removes every row, and in a rule body (#405). A stratification unit test builds the same two-rule negative cycle 64 times and checks that the INT-054 message names the same predicate pair every time (#410). Magic-sets tests check that a recursive rule with a literal start that recurses through another rule returns the same results as without the literal (#297). API tests check that `execute()` and `WriteTransaction::execute()` reject `$slot` bind tokens in every position with API-010 (#407). The REPL `run()` test now runs in a child process with null stdin, so it no longer hangs when the test runner's stdin is an open non-TTY stream (#415). A fault-injection test crashes `save()` at each of its 47 page writes and syncs and checks that every reopen finds exactly the old or the new checkpointed facts (#374). For v2.0.3, storage unit tests and two `edge_cases_test.rs` tests check that more than 65,535 uncheckpointed facts each resolve to their own fact through entity lookups and attribute scans, in memory and file-backed, before and after a checkpoint, and that the widened `FactRef` encodes to the same bytes as before (#445). A WAL test reopens a database next to a WAL whose entries are all checkpointed, writes again, and checks that `:as-of` history is unchanged before and after another reopen; a unit test checks that restoring the transaction counter never lowers it (#447). +The v2.0.3 suite covers the public database API, the Datalog engine, storage and recovery paths, and the v2.0.0 locking and structured-error-code changes, plus v2.0.1's regression test for rebuilding indexes on open after several checkpoints (#370). Since v2.0.1, `tests/point_query_history_test.rs` and new storage unit tests check that attribute-narrowed bound-entity lookups return exactly what a full scan returns, and that `net_asserted_facts` matches its previous implementation on randomized histories (#323). Attribute-scan tests check that the AEVT range covers exactly the queried attribute, including non-ASCII names such as `:丿` and prefix siblings such as `:ab` for `:a` (#381). Incremental B+tree rebuild tests check that checkpoints which copy untouched index leaves produce exactly the entries and file checksum a full rebuild would, including over repeated checkpoints, that single-fact checkpoints do not fragment the index, and that a cyclic leaf chain or damaged slot directory is rejected rather than copied (#315). Directory-sync unit tests check that creating the `.graph` file or the WAL, and deleting the WAL, fsync the parent directory after the file operation, and that reopening an existing file does not (#389). Disjunction tests check that `or-join` returns an empty result, not INT-031, when no rows reach it: on an empty database, after a pattern that matches nothing, after a `not` that removes every row, and in a rule body (#405). A stratification unit test builds the same two-rule negative cycle 64 times and checks that the INT-054 message names the same predicate pair every time (#410). Magic-sets tests check that a recursive rule with a literal start that recurses through another rule returns the same results as without the literal (#297). API tests check that `execute()` and `WriteTransaction::execute()` reject `$slot` bind tokens in every position with API-010 (#407). The REPL `run()` test now runs in a child process with null stdin, so it no longer hangs when the test runner's stdin is an open non-TTY stream (#415). A fault-injection test crashes `save()` at each of its 47 page writes and syncs and checks that every reopen finds exactly the old or the new checkpointed facts (#374). For v2.0.3, storage unit tests and two `edge_cases_test.rs` tests check that more than 65,535 uncheckpointed facts each resolve to their own fact through entity lookups and attribute scans, in memory and file-backed, before and after a checkpoint, and that the widened `FactRef` encodes to the same bytes as before (#445). A WAL test reopens a database next to a WAL whose entries are all checkpointed, writes again, and checks that `:as-of` history is unchanged before and after another reopen; a unit test checks that restoring the transaction counter never lowers it (#447). Since v2.0.3, `tests/golden_corpus_test.rs` opens a copy of each committed golden file in `tests/golden/` (v7 files written by minigraf 2.0.3: one checkpoint, several checkpoints, indexes rebuilt on open, a crash-left WAL, an already-checkpointed WAL, same-transaction multi-values) and checks its manifest's CRC32, `tx_count` and query results after open, after a checkpoint and reopen, and after one more write; a second test checks that every golden file has a manifest (#391). ### Covered Areas @@ -14,7 +14,7 @@ The v2.0.3 suite covers the public database API, the Datalog engine, storage and - **Storage correctness**: packed pages, B+tree indexes and range scans, cache behavior, file-header validation, migration, WAL replay, checksums, corruption handling, and fault injection. - **Reliability and concurrency**: concurrent reads and writes, rollback behavior, same-process handle exclusion, cross-process locking, PID namespaces, NFS and container lock behavior, and SIGKILL recovery. - **Public diagnostics**: structured parser, query, storage, WAL, API, and internal error codes; the error-code registry is checked against [`ERROR_REFERENCE.md`](ERROR_REFERENCE.md). -- **Compatibility and quality**: cross-platform format compatibility, property-based tests, long-haul smoke coverage, XTDB/Datomic semantic compatibility, grammar conformance, and rustdoc examples. +- **Compatibility and quality**: golden-file corpus of released file formats, cross-platform format compatibility, property-based tests, long-haul smoke coverage, XTDB/Datomic semantic compatibility, grammar conformance, and rustdoc examples. ### Ignored Tests diff --git a/docs/superpowers/plans/2026-10-06-golden-file-corpus.md b/docs/superpowers/plans/2026-10-06-golden-file-corpus.md new file mode 100644 index 000000000..37975d064 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-golden-file-corpus.md @@ -0,0 +1,35 @@ +# Golden-file corpus: implementation plan (#391) + +Spec: `docs/superpowers/specs/2026-10-06-golden-file-corpus-design.md`. + +## PR A: `main` (branch `test/391-golden-corpus`) + +1. **Generator crate** `tests/golden/gen/v7/` with `minigraf = "=2.0.3"` and + `crc32fast`, its own `[workspace]`, and a committed `Cargo.lock`. Add the + crate to root `workspace.exclude`. It holds one function per recipe: + `basic`, `multi_checkpoint`, `index_rebuilt`, `pending_wal` and `multivalue`. + It refuses to overwrite existing files and prints the CRC32 and `tx_count` + of each file. +2. **Generate** into `tests/golden/`. Check the version bytes (`xxd -l 8`) and + that `v7_pending_wal.graph.wal` exists. +3. **Manifests**: write the five `.json` files by hand from the recipes, as in + spec §5. +4. **Harness** `tests/golden_corpus_test.rs` with `READER = 2`, implementing + the steps in spec §6. Check that it is green, then that it catches a broken + manifest: change one expected row and one CRC, see both failures reported, + then revert. +5. **Policy job** `golden-corpus` in `.github/workflows/policy.yml`. This job + lets PRs add golden files but never modify or delete them. +6. **Docs**: `tests/golden/README.md`, CONTRIBUTING.md (the add-only rule), + `docs/TEST_COVERAGE.md`, CHANGELOG Unreleased, and the CLAUDE.md test count. +7. Run fmt, clippy, and `cargo test`. Open the PR to `main` and own CI until it + passes. + +## PR B: `v3` (after `main` is merged into `v3`) + +1. Set `READER = 3`. Add the migration checks: the header is v8 after open, and + the v1 WAL is replayed and then removed. +2. **Generator crate** `tests/golden/gen/v8/` pinned to a `v3` git rev. It + writes `basic`, `multi_checkpoint`, `pending_wal`, `multivalue`, + `migrated_from_v7` and `large_values`. +3. Write the v8 manifests and update the v3 docs. Open the PR to `v3`. diff --git a/docs/superpowers/specs/2026-10-06-golden-file-corpus-design.md b/docs/superpowers/specs/2026-10-06-golden-file-corpus-design.md new file mode 100644 index 000000000..d313d87c5 --- /dev/null +++ b/docs/superpowers/specs/2026-10-06-golden-file-corpus-design.md @@ -0,0 +1,189 @@ +# Golden-file compatibility corpus (#391) + +**Issue:** #391 (parent #383, milestone v3.0.0) +**Status:** design +**Branches:** PR A on `main` (v7 corpus), PR B on `v3` (migration checks + v8 corpus) + +## 1. Problem + +`tests/fixtures/` holds `compat.graph`, a two-fact v7 file. On `v3` there is also +`v7_multivalue.graph` (#371). No committed file covers the shapes real users have +on disk: several checkpoints, an index rebuilt on open, a leftover WAL. v3.0.0 +migrates v7 to v8 one way, so the v7 shapes must be frozen with expected results +before the migration ships. v8 must be frozen too, now that its layout is settled +(v8 spec §11). + +## 2. Scope + +- **Supported versions.** On `v3`, v1–v6 are rejected with STG-028 + (`migration_matrix_test.rs` covers this), so "every supported version" means v7 + and v8. Real-data v1–v6 files are out of scope. Those versions only matter to + v2.x, which gets data-integrity fixes only. +- **`main` (v2.x)** gets the v7 corpus with read, checkpoint and reopen checks. That + guards v2.x patch releases against breaking their own format. +- **`v3`** gets the same v7 files (through the regular `main` → `v3` merge), checks + them after migration, and adds a v8 corpus. + +## 3. Layout + +``` +tests/golden/ + README.md rules + how each file was made + v7_basic.graph + v7_basic.json + v7_multi_checkpoint.graph + .json + v7_index_rebuilt.graph + .json + v7_pending_wal.graph + v7_pending_wal.graph.wal + .json + v7_stale_wal.graph + v7_stale_wal.graph.wal + .json + v7_multivalue.graph + .json + v8_*.graph / .wal / .json (PR B) + gen/v7/ standalone crate, minigraf = "=2.0.3" (crates.io) + gen/v8/ standalone crate, minigraf = { git, rev = } (PR B) +tests/golden_corpus_test.rs one harness, runs every manifest +``` + +Each generator crate has its own `[workspace]` table and a committed `Cargo.lock`. +It is listed in the root `workspace.exclude`, so `cargo build` and `cargo test` never +build it. `cargo package` skips directories that have their own `Cargo.toml`. + +## 4. Generators + +A generator records how a file was made. It is never re-run to replace a committed +file. `cargo run -- ` writes every file into ``. It refuses to run +if a target already exists, then prints each file's CRC32 and final `tx_count` for +the manifest. + +Shared dataset (`base`), three transactions: + +1. Entities `:alice`, `:bob` and `:carol` with every value type: string (ASCII, + Unicode `"Zoë 🚀"`, a 2,000-byte string), integer (including `i64::MIN` and + `i64::MAX`), float, boolean, keyword, and ref (`:alice :person/friend :bob`). +2. Valid-time facts: `:alice :employment/status` with two non-overlapping + `{:valid-from :valid-to}` windows, plus one open-ended window. +3. Retract `[:alice :person/age 30]`, assert `[:alice :person/age 31]`. + +v7 files (gen/v7, minigraf 2.0.3): + +| File | Recipe | +|---|---| +| `v7_basic` | `base`, one checkpoint | +| `v7_multi_checkpoint` | `base` with a checkpoint after each transaction, then 300 filler entities in 3 transacts with checkpoints between them, so the facts span several fact pages and saves | +| `v7_index_rebuilt` | Same as `v7_multi_checkpoint`, then flip byte 64 (`index_checksum`) and re-seal the header CRC (bytes 80..84), then open with 2.0.3, which rebuilds the indexes and rewrites the header (#370 path), then close | +| `v7_pending_wal` | `base` checkpointed, then 2 more transacts (one with a retraction) under `wal_checkpoint_threshold(usize::MAX)`, then `mem::forget` on the handle, as a crash would: the transactions live only in a v1 WAL | +| `v7_stale_wal` | As `v7_pending_wal`, but the handle is dropped. 2.0.3's `PersistentFactStorage` saves on drop even under `usize::MAX`, so the facts reach the file and the WAL holds only already-checkpointed entries (#447 shape; also what a crash between a checkpoint's save and its WAL delete leaves) | +| `v7_multivalue` | The #371 shape: `[:t/x :kind :k/a] [:t/x :kind :k/b]` plus 30 fillers in one transact, then `[:t/y :tag :g/a] [:t/y :tag :g/b]` and a batched retract of both, then checkpoint | + +v8 files (gen/v8, PR B, `v3` pinned at a commit after #452): + +| File | Recipe | +|---|---| +| `v8_basic` | `base`, one checkpoint | +| `v8_multi_checkpoint` | As for v7. COW checkpoints reuse pages from the free list and alternate meta slots A/B | +| `v8_pending_wal` | As for v7. WAL version 2 with `base_generation` | +| `v8_multivalue` | The #371 shape, written natively | +| `v8_migrated_from_v7` | A copy of `v7_multi_checkpoint` opened by the pinned `v3` build, which migrates it, then closed | +| `v8_large_values` | Values that need value pages, near `MAX_VALUE_BYTES` | + +## 5. Manifest + +One JSON file per golden file, read with `serde_json` (already a dev-dependency). +The expected rows are **written by hand from the recipe**, not recorded from a +read. A recorded read would freeze a bug such as #371. + +```json +{ + "file": "v7_pending_wal.graph", + "wal": "v7_pending_wal.graph.wal", + "format": 7, + "written_by": "minigraf 2.0.3 (crates.io), tests/golden/gen/v7 recipe pending_wal", + "crc32": { "graph": "0x1a2b3c4d", "wal": "0x5e6f7081" }, + "tx_count": 5, + "queries": [ + { "name": "ages now", + "query": "(query [:find ?n ?a :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [["\"Alice\"", "31"], ["\"Bob\"", "42"]] }, + { "name": "multi-value via EAVT", + "query": "(query [:find ?v :where [:t/x :kind ?v]])", + "rows": [[":k/a"], [":k/b"]], + "min_reader": 3 } + ] +} +``` + +- Each value in `rows` is written in canonical text: strings as JSON-escaped literals + in quotes, integers in decimal, floats as Rust `{}` output, `true`/`false`, + keywords as `:ns/name`, `nil` for null. Queries never return entity ids or refs. + They join through them instead, because ids are not a stable output. +- Rows are compared as **sets**, since the v8 spec allows a different row order. +- `min_reader` skips a query on older readers. It is used only where a release + is known to be wrong (#371 on v2.x). Each use carries a `"known_issue"` field. +- Queries cover: every value type; EAVT, AEVT and AVET lookups; joins through refs; + `:as-of N` for each `tx_count`; `:valid-at` inside, between and outside windows; + `:any-valid-time`; `not`; and `count` aggregation. + +## 6. Harness (`tests/golden_corpus_test.rs`) + +`const READER: u32` is 2 on `main` and 3 on `v3`. The harness finds every +`tests/golden/*.json` and, for each one: + +1. **Frozen check.** The CRC32 of the committed `.graph` (and `.wal`) equals the + manifest value. +2. **Skip.** A manifest whose `format` this reader cannot open is skipped: v8 on + `main`. On `v3` nothing is skipped. +3. **Open.** Copy the file (and WAL) into a tempdir and open it. Check + `current_tx_count() == tx_count` and run every query. +4. **Persist.** Checkpoint, close, reopen, and run the queries again. On `v3`, the + header must now be v8 and the WAL must be gone. (v2.x keeps an already-checkpointed + WAL after a checkpoint, because replay counted no new entries. The data is correct, + so `main` does not check this.) +5. **Write after.** Transact one fact. `tx_count` becomes `tx_count + 1`. Reopen: + the new fact is present and the counter has not rewound (#447). +6. **Untouched.** The committed fixture's bytes are unchanged. The harness only + ever writes to the tempdir copy. + +PR B adds two `v3`-only checks: + +- The first open of each v7 file migrates it. Opening the same copy again + without a checkpoint gives the same results. +- `compat.graph` and `tests/fixtures/v7_multivalue.graph` stay where they are and + keep their current tests. They are not moved, because moving them breaks + `include_bytes!` users (`src/browser/mod.rs`). + +One `#[test]` per manifest would need a macro or build script. Instead, a single +test walks every manifest, collects all failures, and reports each one by manifest +and query name, without `{:?}` on `Value`/`Result` (CodeQL rule). One failing +manifest does not hide the others. + +## 7. Add-only rule + +- **CI.** A new `golden-corpus` job in `policy.yml` runs on pull requests. It fails + if `git diff --diff-filter=MDR origin/...HEAD -- 'tests/golden/*.graph' + 'tests/golden/*.wal'` lists anything. Manifests may gain queries, and reviewers + check edits to existing expectations. +- **CRC.** The CRC in the manifest catches a changed file even when the CI job + is bypassed. +- **Docs.** `tests/golden/README.md` and CONTRIBUTING.md: every release that + changes on-disk behavior adds golden files with a generator recipe. Golden files + are never regenerated, only added. + +## 8. Delivery + +**PR A (`main`)**: `tests/golden/` with the v7 files, manifests, README and +`gen/v7`; the harness with `READER = 2`; `workspace.exclude`; the policy job; and +docs (CONTRIBUTING, TEST_COVERAGE, CHANGELOG Unreleased, CLAUDE.md test count). No +release needed. This is test-only. + +**PR B (`v3`)**, after `main` is merged into `v3`: `READER = 3`; the migration +checks; `gen/v8` and the v8 files; v3 docs. The format is frozen from this point: +a change to v8 needs a new golden file and a new feature bit, never an edit. + +## 9. Risks + +- **The 2.0.3 crate differs from the `v2.0.3` tag.** crates.io holds the published + source, which is what users ran. The generator records the version, and its + `Cargo.lock` pins the dependency tree. +- **Floats in manifests.** Only literals with an exact decimal form are used + (`1.5`, `-0.25`), so formatting cannot drift. +- **Wall-clock `tx_id`.** Files hold real timestamps. Queries use `:as-of N` + (tx_count) and explicit valid-time strings, never "now"-relative times. Facts + asserted without `:valid-from` are valid from their `tx_id`, so `:valid-at` + queries use dates after 2026-10-06 or explicit windows only. diff --git a/tests/golden/README.md b/tests/golden/README.md new file mode 100644 index 000000000..56bcdf774 --- /dev/null +++ b/tests/golden/README.md @@ -0,0 +1,69 @@ +# Golden-file compatibility corpus + +Database files written by released versions of Minigraf, each with a manifest of +the results it must give (#391). `tests/golden_corpus_test.rs` opens a copy of every +file with the current build and checks its manifest: + +1. The committed files still match the CRC32 values in the manifest. +2. After open, `tx_count` and every query result match the manifest. +3. After a checkpoint and a reopen, they still match. +4. One more write moves `tx_count` past the file's value and survives a reopen. + +Manifests for a newer format than the reader supports are skipped. + +## Rules + +- **Golden files are never regenerated, only added.** A `.graph` or `.wal` file here + is a record of what a released version wrote. CI (`policy.yml`, job + `golden-corpus`) fails a pull request that modifies, deletes or renames one. +- **Every release that changes on-disk behavior adds golden files** for that + behavior, with a recipe in a generator crate. +- **Expected rows are written by hand from the recipe**, not recorded from a read. + A recorded read would freeze a bug as expected output. +- A query that a release is known to get wrong carries `"min_reader"` (the first + major version that gets it right) and `"known_issue"`. + +## Manifest format + +```json +{ + "file": "v7_basic.graph", + "wal": null, + "format": 7, + "written_by": "minigraf 2.0.3 (crates.io), tests/golden/gen/v7 recipe basic", + "crc32": { "graph": "0x368e4f45", "wal": null }, + "tx_count": 4, + "queries": [ + { "name": "eavt lookup", + "query": "(query [:find ?a :where [:alice :person/age ?a]])", + "rows": [["31"]] } + ] +} +``` + +Rows are compared as sets. Each value is written as canonical text: strings as +JSON-escaped literals in quotes, integers in decimal, floats as Rust `{}` output, +`true`/`false`, keywords as `:ns/name`, and `nil` for null. Queries never return +entity ids, because ids are not a stable output. + +## Files + +| File | Written by | Shape | +|---|---|---| +| `v7_basic` | 2.0.3 | Shared dataset (every value type, refs, Unicode, a 2,000-byte string, valid-time windows, a retraction), one checkpoint | +| `v7_multi_checkpoint` | 2.0.3 | Shared dataset with a checkpoint after each transaction, plus 300 filler entities over three more checkpoints | +| `v7_index_rebuilt` | 2.0.3 | `v7_multi_checkpoint` with a damaged `index_checksum`, opened once by 2.0.3, which rebuilt the indexes and rewrote the header. Stale pages remain past `page_count` | +| `v7_pending_wal` | 2.0.3 | Shared dataset checkpointed, then two transactions held only in a v1 WAL (the session ended without running destructors, as a crash would) | +| `v7_stale_wal` | 2.0.3 | Same as `v7_pending_wal`, but the session closed normally: the facts are in the file, and the WAL next to it holds only entries already checkpointed (#447 shape) | +| `v7_multivalue` | 2.0.3 | Same-transaction multi-values and a batched retract (#371). v2.x reads these wrong | + +## Generators + +`gen/v7/` is a standalone crate pinned to `minigraf = "=2.0.3"` from crates.io, +with a committed `Cargo.lock`. It records how each v7 file was made. It is not part +of the workspace and is never re-run to replace a file: it refuses to write over an +existing one. + +```sh +cd tests/golden/gen/v7 && cargo run -- +``` diff --git a/tests/golden/gen/v7/Cargo.lock b/tests/golden/gen/v7/Cargo.lock new file mode 100644 index 000000000..c238c53b9 --- /dev/null +++ b/tests/golden/gen/v7/Cargo.lock @@ -0,0 +1,438 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "minigraf" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46a3a991ca47660196a3595826a080fc31f1a0f0d9a1dd043969e96a18d1fa4" +dependencies = [ + "anyhow", + "chrono", + "crc32fast", + "postcard", + "regex-lite", + "serde", + "uuid", +] + +[[package]] +name = "minigraf-golden-gen-v7" +version = "0.0.0" +dependencies = [ + "crc32fast", + "minigraf", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] diff --git a/tests/golden/gen/v7/Cargo.toml b/tests/golden/gen/v7/Cargo.toml new file mode 100644 index 000000000..d1d015ba0 --- /dev/null +++ b/tests/golden/gen/v7/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "minigraf-golden-gen-v7" +version = "0.0.0" +edition = "2024" +publish = false +description = "Records how the v7 golden files in tests/golden/ were written (#391). Never re-run to replace a committed file." + +# Standalone: not a member of the minigraf workspace. +[workspace] + +[dependencies] +minigraf = "=2.0.3" +crc32fast = "1.4" diff --git a/tests/golden/gen/v7/src/main.rs b/tests/golden/gen/v7/src/main.rs new file mode 100644 index 000000000..aabfd80b4 --- /dev/null +++ b/tests/golden/gen/v7/src/main.rs @@ -0,0 +1,228 @@ +//! Writes the v7 golden files in `tests/golden/` with minigraf 2.0.3 (#391). +//! +//! This program records how each committed file was made. It is never re-run to +//! replace one: golden files are only ever added. Usage: +//! +//! ```text +//! cargo run -- +//! ``` +//! +//! It refuses to run if any target file already exists in ``, and prints +//! each file's CRC32 and final `tx_count` for its manifest. + +use std::error::Error; +use std::path::{Path, PathBuf}; + +use minigraf::{Minigraf, OpenOptions}; + +type Res = Result>; +type Recipe = fn(&Path) -> Res<()>; + +const RECIPES: &[(&str, Recipe)] = &[ + ("v7_basic", basic), + ("v7_multi_checkpoint", multi_checkpoint), + ("v7_index_rebuilt", index_rebuilt), + ("v7_pending_wal", pending_wal), + ("v7_stale_wal", stale_wal), + ("v7_multivalue", multivalue), +]; + +fn main() -> Res<()> { + let out = PathBuf::from(std::env::args().nth(1).ok_or("usage: ")?); + for (name, _) in RECIPES { + for ext in ["graph", "graph.wal"] { + let p = out.join(format!("{name}.{ext}")); + if p.exists() { + return Err( + format!("{} exists; golden files are never regenerated", p.display()).into(), + ); + } + } + } + for (name, recipe) in RECIPES { + let path = out.join(format!("{name}.graph")); + recipe(&path)?; + // Hash before anything else opens the file: an open can write to it. + print!("{name}: graph=0x{:08x}", crc(&path)?); + let wal = wal_path(&path); + if wal.exists() { + print!(" wal=0x{:08x}", crc(&wal)?); + } + println!(" tx_count={}", tx_count_of_copy(&path)?); + } + Ok(()) +} + +fn crc(path: &Path) -> Res { + Ok(crc32fast::hash(&std::fs::read(path)?)) +} + +/// Reads `tx_count` from a scratch copy, so the written file stays as it is. +fn tx_count_of_copy(path: &Path) -> Res { + let scratch = path.with_extension("probe.graph"); + std::fs::copy(path, &scratch)?; + if wal_path(path).exists() { + std::fs::copy(wal_path(path), wal_path(&scratch))?; + } + let n = open_no_auto_checkpoint(&scratch)?.current_tx_count(); + std::fs::remove_file(&scratch)?; + let _ = std::fs::remove_file(wal_path(&scratch)); + Ok(n) +} + +fn wal_path(path: &Path) -> PathBuf { + let mut s = path.as_os_str().to_os_string(); + s.push(".wal"); + PathBuf::from(s) +} + +/// Opens without the checkpoint-on-close, so only explicit checkpoints write the file. +fn open_no_auto_checkpoint(path: &Path) -> Res { + Ok(OpenOptions::new() + .wal_checkpoint_threshold(usize::MAX) + .path(path) + .open()?) +} + +/// The shared dataset: four transactions (tx_count 1..=4). +const BASE: &[&str] = &[ + // tx 1: every value type, refs, Unicode and a 2,000-byte string. + r#"(transact [[:alice :person/name "Alice"] + [:alice :person/age 30] + [:alice :person/height 1.5] + [:alice :person/active true] + [:alice :person/role :role/admin] + [:alice :person/friend :bob] + [:alice :person/motto "Zoë 🚀"] + [:bob :person/name "Bob"] + [:bob :person/age 42] + [:bob :person/height -0.25] + [:bob :person/active false] + [:bob :person/role :role/user] + [:bob :person/friend :carol] + [:carol :person/name "Carol"] + [:carol :person/age -9223372036854775808] + [:carol :person/score 9223372036854775807] + [:carol :person/bio "BIO"]])"#, + // tx 2: valid-time windows, one per entity. + r#"(transact [[:carol :employment/status :intern {:valid-from "2020-01-01" :valid-to "2021-01-01"}] + [:alice :employment/status :employee {:valid-from "2021-01-01" :valid-to "2024-01-01"}] + [:bob :employment/status :contractor {:valid-from "2022-01-01"}]])"#, + // tx 3: retraction. + r#"(retract [[:alice :person/age 30]])"#, + // tx 4: re-assertion with a new value. + r#"(transact [[:alice :person/age 31]])"#, +]; + +/// The 2,000-byte string stored as `:carol :person/bio`. +fn bio() -> String { + "0123456789".repeat(200) +} + +fn run_base(db: &Minigraf, checkpoint_each: bool) -> Res<()> { + for cmd in BASE { + db.execute(&cmd.replace("\"BIO\"", &format!("\"{}\"", bio())))?; + if checkpoint_each { + db.checkpoint()?; + } + } + Ok(()) +} + +/// 300 filler entities in three transacts (tx_count 5..=7), with a checkpoint +/// after each, so the facts span several fact pages and saves. +fn run_fillers(db: &Minigraf) -> Res<()> { + for batch in 0..3 { + let mut cmd = String::from("(transact ["); + for i in (batch * 100)..(batch * 100 + 100) { + cmd.push_str(&format!( + "[:f/e{i} :filler/n {i}] [:f/e{i} :filler/group :g/{}] ", + i % 3 + )); + } + cmd.push_str("])"); + db.execute(&cmd)?; + db.checkpoint()?; + } + Ok(()) +} + +/// `base`, one checkpoint. tx_count 4. +fn basic(path: &Path) -> Res<()> { + let db = open_no_auto_checkpoint(path)?; + run_base(&db, false)?; + db.checkpoint()?; + Ok(()) +} + +/// `base` with a checkpoint after each transaction, then the fillers. tx_count 7. +fn multi_checkpoint(path: &Path) -> Res<()> { + let db = open_no_auto_checkpoint(path)?; + run_base(&db, true)?; + run_fillers(&db)?; + Ok(()) +} + +/// `multi_checkpoint`, then a damaged `index_checksum` (header re-sealed), so +/// 2.0.3 rebuilds the indexes on open and rewrites the header (#370 path). +fn index_rebuilt(path: &Path) -> Res<()> { + multi_checkpoint(path)?; + let mut bytes = std::fs::read(path)?; + bytes[64] ^= 0xFF; // index_checksum, bytes 64..68 + let mut h = [0u8; 80]; + h.copy_from_slice(&bytes[..80]); + let seal = crc32fast::hash(&h); // header_checksum covers bytes 0..80 + bytes[80..84].copy_from_slice(&seal.to_le_bytes()); + std::fs::write(path, &bytes)?; + let db = open_no_auto_checkpoint(path)?; + drop(db); + Ok(()) +} + +/// `base` checkpointed, then two transactions written to the WAL by a session that +/// ends without dropping the handle (a crash after the WAL fsync). tx_count 6. +fn pending_wal(path: &Path) -> Res<()> { + let db = wal_session(path)?; + // Skip every destructor, as a killed process would. The lock is released at exit. + std::mem::forget(db); + if !wal_path(path).exists() { + return Err("pending_wal: expected a WAL next to the file".into()); + } + Ok(()) +} + +/// As `pending_wal`, but the handle is dropped. 2.0.3 then saves the facts into the +/// file and leaves the WAL in place, so every WAL entry is already checkpointed. +/// A crash between a checkpoint's save and its WAL delete leaves the same shape +/// (#447). tx_count 6. +fn stale_wal(path: &Path) -> Res<()> { + drop(wal_session(path)?); + if !wal_path(path).exists() { + return Err("stale_wal: expected a WAL next to the file".into()); + } + Ok(()) +} + +fn wal_session(path: &Path) -> Res { + basic(path)?; + let db = open_no_auto_checkpoint(path)?; + db.execute(r#"(transact [[:dave :person/name "Dave"] [:dave :person/age 25]])"#)?; + db.execute(r#"(retract [[:bob :person/active false]])"#)?; + Ok(db) +} + +/// The #371 shape: same-transaction multi-values and a batched retract. tx_count 3. +fn multivalue(path: &Path) -> Res<()> { + let db = open_no_auto_checkpoint(path)?; + let mut cmd = + String::from(r#"(transact [[:t/x :kind :k/a] [:t/x :kind :k/b] [:t/x :note "two"] "#); + for i in 0..30 { + cmd.push_str(&format!(r#"[:t/f-{i} :kind :k/c] [:t/f-{i} :note "f"] "#)); + } + cmd.push_str("])"); + db.execute(&cmd)?; + db.execute("(transact [[:t/y :tag :g/a] [:t/y :tag :g/b]])")?; + db.execute("(retract [[:t/y :tag :g/a] [:t/y :tag :g/b]])")?; + db.checkpoint()?; + Ok(()) +} diff --git a/tests/golden/v7_basic.graph b/tests/golden/v7_basic.graph new file mode 100644 index 000000000..bfbdfd999 Binary files /dev/null and b/tests/golden/v7_basic.graph differ diff --git a/tests/golden/v7_basic.json b/tests/golden/v7_basic.json new file mode 100644 index 000000000..819ab4b7a --- /dev/null +++ b/tests/golden/v7_basic.json @@ -0,0 +1,263 @@ +{ + "file": "v7_basic.graph", + "wal": null, + "format": 7, + "written_by": "minigraf 2.0.3 (crates.io), tests/golden/gen/v7 recipe basic", + "crc32": { + "graph": "0x368e4f45", + "wal": null + }, + "tx_count": 4, + "queries": [ + { + "name": "all names", + "query": "(query [:find ?n :where [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"" + ], + [ + "\"Bob\"" + ], + [ + "\"Carol\"" + ] + ] + }, + { + "name": "ages now", + "query": "(query [:find ?n ?a :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Alice\"", + "31" + ], + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "ages as-of 1", + "query": "(query [:find ?n ?a :as-of 1 :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Alice\"", + "30" + ], + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "ages as-of 3 (alice retracted)", + "query": "(query [:find ?n ?a :as-of 3 :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "eavt lookup", + "query": "(query [:find ?a :where [:alice :person/age ?a]])", + "rows": [ + [ + "31" + ] + ] + }, + { + "name": "avet lookup", + "query": "(query [:find ?n :where [?e :person/role :role/admin] [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"" + ] + ] + }, + { + "name": "keywords", + "query": "(query [:find ?n ?r :where [?e :person/role ?r] [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"", + ":role/admin" + ], + [ + "\"Bob\"", + ":role/user" + ] + ] + }, + { + "name": "ref join", + "query": "(query [:find ?n :where [:alice :person/friend ?f] [?f :person/name ?n]])", + "rows": [ + [ + "\"Bob\"" + ] + ] + }, + { + "name": "ref join two hops", + "query": "(query [:find ?n :where [:alice :person/friend ?f] [?f :person/friend ?g] [?g :person/name ?n]])", + "rows": [ + [ + "\"Carol\"" + ] + ] + }, + { + "name": "vaet reverse ref", + "query": "(query [:find ?n :where [?e :person/friend :carol] [?e :person/name ?n]])", + "rows": [ + [ + "\"Bob\"" + ] + ] + }, + { + "name": "floats", + "query": "(query [:find ?n ?h :where [?e :person/name ?n] [?e :person/height ?h]])", + "rows": [ + [ + "\"Alice\"", + "1.5" + ], + [ + "\"Bob\"", + "-0.25" + ] + ] + }, + { + "name": "unicode", + "query": "(query [:find ?m :where [:alice :person/motto ?m]])", + "rows": [ + [ + "\"Zoë 🚀\"" + ] + ] + }, + { + "name": "i64 max", + "query": "(query [:find ?s :where [:carol :person/score ?s]])", + "rows": [ + [ + "9223372036854775807" + ] + ] + }, + { + "name": "long string", + "query": "(query [:find ?b :where [:carol :person/bio ?b]])", + "rows": [ + [ + "\"01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789\"" + ] + ] + }, + { + "name": "valid-at inside window", + "query": "(query [:find ?s :valid-at \"2022-06-01\" :where [?e :employment/status ?s]])", + "rows": [ + [ + ":employee" + ], + [ + ":contractor" + ] + ] + }, + { + "name": "valid-at earlier window", + "query": "(query [:find ?s :valid-at \"2020-06-01\" :where [?e :employment/status ?s]])", + "rows": [ + [ + ":intern" + ] + ] + }, + { + "name": "valid-at before all windows", + "query": "(query [:find ?s :valid-at \"2019-01-01\" :where [?e :employment/status ?s]])", + "rows": [] + }, + { + "name": "valid now", + "query": "(query [:find ?s :where [?e :employment/status ?s]])", + "rows": [ + [ + ":contractor" + ] + ] + }, + { + "name": "any valid time", + "query": "(query [:find ?s :valid-at :any-valid-time :where [?e :employment/status ?s]])", + "rows": [ + [ + ":intern" + ], + [ + ":employee" + ], + [ + ":contractor" + ] + ] + }, + { + "name": "count names", + "query": "(query [:find (count ?e) :where [?e :person/name ?n]])", + "rows": [ + [ + "3" + ] + ] + }, + { + "name": "booleans", + "query": "(query [:find ?n ?b :where [?e :person/name ?n] [?e :person/active ?b]])", + "rows": [ + [ + "\"Alice\"", + "true" + ], + [ + "\"Bob\"", + "false" + ] + ] + }, + { + "name": "not", + "query": "(query [:find ?n :where [?e :person/name ?n] (not [?e :person/active true])])", + "rows": [ + [ + "\"Bob\"" + ], + [ + "\"Carol\"" + ] + ] + } + ] +} diff --git a/tests/golden/v7_index_rebuilt.graph b/tests/golden/v7_index_rebuilt.graph new file mode 100644 index 000000000..536cfa8d3 Binary files /dev/null and b/tests/golden/v7_index_rebuilt.graph differ diff --git a/tests/golden/v7_index_rebuilt.json b/tests/golden/v7_index_rebuilt.json new file mode 100644 index 000000000..7445d6366 --- /dev/null +++ b/tests/golden/v7_index_rebuilt.json @@ -0,0 +1,309 @@ +{ + "file": "v7_index_rebuilt.graph", + "wal": null, + "format": 7, + "written_by": "minigraf 2.0.3 (crates.io), tests/golden/gen/v7 recipe index_rebuilt", + "crc32": { + "graph": "0xbe8357a7", + "wal": null + }, + "tx_count": 7, + "notes": "indexes rebuilt on open by 2.0.3 after index_checksum damage; page_count shrank, stale pages remain past it", + "queries": [ + { + "name": "all names", + "query": "(query [:find ?n :where [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"" + ], + [ + "\"Bob\"" + ], + [ + "\"Carol\"" + ] + ] + }, + { + "name": "ages now", + "query": "(query [:find ?n ?a :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Alice\"", + "31" + ], + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "ages as-of 1", + "query": "(query [:find ?n ?a :as-of 1 :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Alice\"", + "30" + ], + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "ages as-of 3 (alice retracted)", + "query": "(query [:find ?n ?a :as-of 3 :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "eavt lookup", + "query": "(query [:find ?a :where [:alice :person/age ?a]])", + "rows": [ + [ + "31" + ] + ] + }, + { + "name": "avet lookup", + "query": "(query [:find ?n :where [?e :person/role :role/admin] [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"" + ] + ] + }, + { + "name": "keywords", + "query": "(query [:find ?n ?r :where [?e :person/role ?r] [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"", + ":role/admin" + ], + [ + "\"Bob\"", + ":role/user" + ] + ] + }, + { + "name": "ref join", + "query": "(query [:find ?n :where [:alice :person/friend ?f] [?f :person/name ?n]])", + "rows": [ + [ + "\"Bob\"" + ] + ] + }, + { + "name": "ref join two hops", + "query": "(query [:find ?n :where [:alice :person/friend ?f] [?f :person/friend ?g] [?g :person/name ?n]])", + "rows": [ + [ + "\"Carol\"" + ] + ] + }, + { + "name": "vaet reverse ref", + "query": "(query [:find ?n :where [?e :person/friend :carol] [?e :person/name ?n]])", + "rows": [ + [ + "\"Bob\"" + ] + ] + }, + { + "name": "floats", + "query": "(query [:find ?n ?h :where [?e :person/name ?n] [?e :person/height ?h]])", + "rows": [ + [ + "\"Alice\"", + "1.5" + ], + [ + "\"Bob\"", + "-0.25" + ] + ] + }, + { + "name": "unicode", + "query": "(query [:find ?m :where [:alice :person/motto ?m]])", + "rows": [ + [ + "\"Zoë 🚀\"" + ] + ] + }, + { + "name": "i64 max", + "query": "(query [:find ?s :where [:carol :person/score ?s]])", + "rows": [ + [ + "9223372036854775807" + ] + ] + }, + { + "name": "long string", + "query": "(query [:find ?b :where [:carol :person/bio ?b]])", + "rows": [ + [ + "\"01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789\"" + ] + ] + }, + { + "name": "valid-at inside window", + "query": "(query [:find ?s :valid-at \"2022-06-01\" :where [?e :employment/status ?s]])", + "rows": [ + [ + ":employee" + ], + [ + ":contractor" + ] + ] + }, + { + "name": "valid-at earlier window", + "query": "(query [:find ?s :valid-at \"2020-06-01\" :where [?e :employment/status ?s]])", + "rows": [ + [ + ":intern" + ] + ] + }, + { + "name": "valid-at before all windows", + "query": "(query [:find ?s :valid-at \"2019-01-01\" :where [?e :employment/status ?s]])", + "rows": [] + }, + { + "name": "valid now", + "query": "(query [:find ?s :where [?e :employment/status ?s]])", + "rows": [ + [ + ":contractor" + ] + ] + }, + { + "name": "any valid time", + "query": "(query [:find ?s :valid-at :any-valid-time :where [?e :employment/status ?s]])", + "rows": [ + [ + ":intern" + ], + [ + ":employee" + ], + [ + ":contractor" + ] + ] + }, + { + "name": "count names", + "query": "(query [:find (count ?e) :where [?e :person/name ?n]])", + "rows": [ + [ + "3" + ] + ] + }, + { + "name": "booleans", + "query": "(query [:find ?n ?b :where [?e :person/name ?n] [?e :person/active ?b]])", + "rows": [ + [ + "\"Alice\"", + "true" + ], + [ + "\"Bob\"", + "false" + ] + ] + }, + { + "name": "not", + "query": "(query [:find ?n :where [?e :person/name ?n] (not [?e :person/active true])])", + "rows": [ + [ + "\"Bob\"" + ], + [ + "\"Carol\"" + ] + ] + }, + { + "name": "filler count", + "query": "(query [:find (count ?e) :where [?e :filler/n ?n]])", + "rows": [ + [ + "300" + ] + ] + }, + { + "name": "filler group", + "query": "(query [:find (count ?e) :where [?e :filler/group :g/1]])", + "rows": [ + [ + "100" + ] + ] + }, + { + "name": "filler sum", + "query": "(query [:find (sum ?n) :where [?e :filler/n ?n]])", + "rows": [ + [ + "44850" + ] + ] + }, + { + "name": "filler point", + "query": "(query [:find ?g :where [:f/e250 :filler/group ?g]])", + "rows": [ + [ + ":g/1" + ] + ] + }, + { + "name": "filler as-of 5", + "query": "(query [:find (count ?e) :as-of 5 :where [?e :filler/n ?n]])", + "rows": [ + [ + "100" + ] + ] + } + ] +} diff --git a/tests/golden/v7_multi_checkpoint.graph b/tests/golden/v7_multi_checkpoint.graph new file mode 100644 index 000000000..235fedb99 Binary files /dev/null and b/tests/golden/v7_multi_checkpoint.graph differ diff --git a/tests/golden/v7_multi_checkpoint.json b/tests/golden/v7_multi_checkpoint.json new file mode 100644 index 000000000..31a7791fb --- /dev/null +++ b/tests/golden/v7_multi_checkpoint.json @@ -0,0 +1,308 @@ +{ + "file": "v7_multi_checkpoint.graph", + "wal": null, + "format": 7, + "written_by": "minigraf 2.0.3 (crates.io), tests/golden/gen/v7 recipe multi_checkpoint", + "crc32": { + "graph": "0x0fb687cd", + "wal": null + }, + "tx_count": 7, + "queries": [ + { + "name": "all names", + "query": "(query [:find ?n :where [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"" + ], + [ + "\"Bob\"" + ], + [ + "\"Carol\"" + ] + ] + }, + { + "name": "ages now", + "query": "(query [:find ?n ?a :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Alice\"", + "31" + ], + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "ages as-of 1", + "query": "(query [:find ?n ?a :as-of 1 :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Alice\"", + "30" + ], + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "ages as-of 3 (alice retracted)", + "query": "(query [:find ?n ?a :as-of 3 :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "eavt lookup", + "query": "(query [:find ?a :where [:alice :person/age ?a]])", + "rows": [ + [ + "31" + ] + ] + }, + { + "name": "avet lookup", + "query": "(query [:find ?n :where [?e :person/role :role/admin] [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"" + ] + ] + }, + { + "name": "keywords", + "query": "(query [:find ?n ?r :where [?e :person/role ?r] [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"", + ":role/admin" + ], + [ + "\"Bob\"", + ":role/user" + ] + ] + }, + { + "name": "ref join", + "query": "(query [:find ?n :where [:alice :person/friend ?f] [?f :person/name ?n]])", + "rows": [ + [ + "\"Bob\"" + ] + ] + }, + { + "name": "ref join two hops", + "query": "(query [:find ?n :where [:alice :person/friend ?f] [?f :person/friend ?g] [?g :person/name ?n]])", + "rows": [ + [ + "\"Carol\"" + ] + ] + }, + { + "name": "vaet reverse ref", + "query": "(query [:find ?n :where [?e :person/friend :carol] [?e :person/name ?n]])", + "rows": [ + [ + "\"Bob\"" + ] + ] + }, + { + "name": "floats", + "query": "(query [:find ?n ?h :where [?e :person/name ?n] [?e :person/height ?h]])", + "rows": [ + [ + "\"Alice\"", + "1.5" + ], + [ + "\"Bob\"", + "-0.25" + ] + ] + }, + { + "name": "unicode", + "query": "(query [:find ?m :where [:alice :person/motto ?m]])", + "rows": [ + [ + "\"Zoë 🚀\"" + ] + ] + }, + { + "name": "i64 max", + "query": "(query [:find ?s :where [:carol :person/score ?s]])", + "rows": [ + [ + "9223372036854775807" + ] + ] + }, + { + "name": "long string", + "query": "(query [:find ?b :where [:carol :person/bio ?b]])", + "rows": [ + [ + "\"01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789\"" + ] + ] + }, + { + "name": "valid-at inside window", + "query": "(query [:find ?s :valid-at \"2022-06-01\" :where [?e :employment/status ?s]])", + "rows": [ + [ + ":employee" + ], + [ + ":contractor" + ] + ] + }, + { + "name": "valid-at earlier window", + "query": "(query [:find ?s :valid-at \"2020-06-01\" :where [?e :employment/status ?s]])", + "rows": [ + [ + ":intern" + ] + ] + }, + { + "name": "valid-at before all windows", + "query": "(query [:find ?s :valid-at \"2019-01-01\" :where [?e :employment/status ?s]])", + "rows": [] + }, + { + "name": "valid now", + "query": "(query [:find ?s :where [?e :employment/status ?s]])", + "rows": [ + [ + ":contractor" + ] + ] + }, + { + "name": "any valid time", + "query": "(query [:find ?s :valid-at :any-valid-time :where [?e :employment/status ?s]])", + "rows": [ + [ + ":intern" + ], + [ + ":employee" + ], + [ + ":contractor" + ] + ] + }, + { + "name": "count names", + "query": "(query [:find (count ?e) :where [?e :person/name ?n]])", + "rows": [ + [ + "3" + ] + ] + }, + { + "name": "booleans", + "query": "(query [:find ?n ?b :where [?e :person/name ?n] [?e :person/active ?b]])", + "rows": [ + [ + "\"Alice\"", + "true" + ], + [ + "\"Bob\"", + "false" + ] + ] + }, + { + "name": "not", + "query": "(query [:find ?n :where [?e :person/name ?n] (not [?e :person/active true])])", + "rows": [ + [ + "\"Bob\"" + ], + [ + "\"Carol\"" + ] + ] + }, + { + "name": "filler count", + "query": "(query [:find (count ?e) :where [?e :filler/n ?n]])", + "rows": [ + [ + "300" + ] + ] + }, + { + "name": "filler group", + "query": "(query [:find (count ?e) :where [?e :filler/group :g/1]])", + "rows": [ + [ + "100" + ] + ] + }, + { + "name": "filler sum", + "query": "(query [:find (sum ?n) :where [?e :filler/n ?n]])", + "rows": [ + [ + "44850" + ] + ] + }, + { + "name": "filler point", + "query": "(query [:find ?g :where [:f/e250 :filler/group ?g]])", + "rows": [ + [ + ":g/1" + ] + ] + }, + { + "name": "filler as-of 5", + "query": "(query [:find (count ?e) :as-of 5 :where [?e :filler/n ?n]])", + "rows": [ + [ + "100" + ] + ] + } + ] +} diff --git a/tests/golden/v7_multivalue.graph b/tests/golden/v7_multivalue.graph new file mode 100644 index 000000000..7bbdd40d3 Binary files /dev/null and b/tests/golden/v7_multivalue.graph differ diff --git a/tests/golden/v7_multivalue.json b/tests/golden/v7_multivalue.json new file mode 100644 index 000000000..268c53128 --- /dev/null +++ b/tests/golden/v7_multivalue.json @@ -0,0 +1,80 @@ +{ + "file": "v7_multivalue.graph", + "wal": null, + "format": 7, + "written_by": "minigraf 2.0.3 (crates.io), tests/golden/gen/v7 recipe multivalue", + "crc32": { + "graph": "0xde15da22", + "wal": null + }, + "tx_count": 3, + "queries": [ + { + "name": "note", + "query": "(query [:find ?n :where [:t/x :note ?n]])", + "rows": [ + [ + "\"two\"" + ] + ] + }, + { + "name": "filler kinds", + "query": "(query [:find (count ?e) :where [?e :kind :k/c]])", + "rows": [ + [ + "30" + ] + ] + }, + { + "name": "multi-value via eavt", + "query": "(query [:find ?v :where [:t/x :kind ?v]])", + "rows": [ + [ + ":k/a" + ], + [ + ":k/b" + ] + ], + "min_reader": 3, + "known_issue": "#371: same-transaction multi-values read back as one value on v2.x" + }, + { + "name": "multi-value via aevt", + "query": "(query [:find ?v :where [?e :kind ?v] [?e :note \"two\"]])", + "rows": [ + [ + ":k/a" + ], + [ + ":k/b" + ] + ], + "min_reader": 3, + "known_issue": "#371: same-transaction multi-values read back as one value on v2.x" + }, + { + "name": "batched retract", + "query": "(query [:find ?t :where [:t/y :tag ?t]])", + "rows": [], + "min_reader": 3, + "known_issue": "#371: same-transaction multi-values read back as one value on v2.x" + }, + { + "name": "multi-value as-of 2", + "query": "(query [:find ?t :as-of 2 :where [:t/y :tag ?t]])", + "rows": [ + [ + ":g/a" + ], + [ + ":g/b" + ] + ], + "min_reader": 3, + "known_issue": "#371: same-transaction multi-values read back as one value on v2.x" + } + ] +} diff --git a/tests/golden/v7_pending_wal.graph b/tests/golden/v7_pending_wal.graph new file mode 100644 index 000000000..6da62adf6 Binary files /dev/null and b/tests/golden/v7_pending_wal.graph differ diff --git a/tests/golden/v7_pending_wal.graph.wal b/tests/golden/v7_pending_wal.graph.wal new file mode 100644 index 000000000..51503ced5 Binary files /dev/null and b/tests/golden/v7_pending_wal.graph.wal differ diff --git a/tests/golden/v7_pending_wal.json b/tests/golden/v7_pending_wal.json new file mode 100644 index 000000000..82af24c87 --- /dev/null +++ b/tests/golden/v7_pending_wal.json @@ -0,0 +1,279 @@ +{ + "file": "v7_pending_wal.graph", + "wal": "v7_pending_wal.graph.wal", + "format": 7, + "written_by": "minigraf 2.0.3 (crates.io), tests/golden/gen/v7 recipe pending_wal", + "crc32": { + "graph": "0xcfc67322", + "wal": "0x6b6dea21" + }, + "tx_count": 6, + "notes": "tx 5 and 6 live only in the v1 WAL: the writing session ended without running destructors, as a crash would", + "queries": [ + { + "name": "all names", + "query": "(query [:find ?n :where [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"" + ], + [ + "\"Bob\"" + ], + [ + "\"Carol\"" + ], + [ + "\"Dave\"" + ] + ] + }, + { + "name": "ages now", + "query": "(query [:find ?n ?a :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Alice\"", + "31" + ], + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ], + [ + "\"Dave\"", + "25" + ] + ] + }, + { + "name": "ages as-of 1", + "query": "(query [:find ?n ?a :as-of 1 :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Alice\"", + "30" + ], + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "ages as-of 3 (alice retracted)", + "query": "(query [:find ?n ?a :as-of 3 :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "eavt lookup", + "query": "(query [:find ?a :where [:alice :person/age ?a]])", + "rows": [ + [ + "31" + ] + ] + }, + { + "name": "avet lookup", + "query": "(query [:find ?n :where [?e :person/role :role/admin] [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"" + ] + ] + }, + { + "name": "keywords", + "query": "(query [:find ?n ?r :where [?e :person/role ?r] [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"", + ":role/admin" + ], + [ + "\"Bob\"", + ":role/user" + ] + ] + }, + { + "name": "ref join", + "query": "(query [:find ?n :where [:alice :person/friend ?f] [?f :person/name ?n]])", + "rows": [ + [ + "\"Bob\"" + ] + ] + }, + { + "name": "ref join two hops", + "query": "(query [:find ?n :where [:alice :person/friend ?f] [?f :person/friend ?g] [?g :person/name ?n]])", + "rows": [ + [ + "\"Carol\"" + ] + ] + }, + { + "name": "vaet reverse ref", + "query": "(query [:find ?n :where [?e :person/friend :carol] [?e :person/name ?n]])", + "rows": [ + [ + "\"Bob\"" + ] + ] + }, + { + "name": "floats", + "query": "(query [:find ?n ?h :where [?e :person/name ?n] [?e :person/height ?h]])", + "rows": [ + [ + "\"Alice\"", + "1.5" + ], + [ + "\"Bob\"", + "-0.25" + ] + ] + }, + { + "name": "unicode", + "query": "(query [:find ?m :where [:alice :person/motto ?m]])", + "rows": [ + [ + "\"Zoë 🚀\"" + ] + ] + }, + { + "name": "i64 max", + "query": "(query [:find ?s :where [:carol :person/score ?s]])", + "rows": [ + [ + "9223372036854775807" + ] + ] + }, + { + "name": "long string", + "query": "(query [:find ?b :where [:carol :person/bio ?b]])", + "rows": [ + [ + "\"01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789\"" + ] + ] + }, + { + "name": "valid-at inside window", + "query": "(query [:find ?s :valid-at \"2022-06-01\" :where [?e :employment/status ?s]])", + "rows": [ + [ + ":employee" + ], + [ + ":contractor" + ] + ] + }, + { + "name": "valid-at earlier window", + "query": "(query [:find ?s :valid-at \"2020-06-01\" :where [?e :employment/status ?s]])", + "rows": [ + [ + ":intern" + ] + ] + }, + { + "name": "valid-at before all windows", + "query": "(query [:find ?s :valid-at \"2019-01-01\" :where [?e :employment/status ?s]])", + "rows": [] + }, + { + "name": "valid now", + "query": "(query [:find ?s :where [?e :employment/status ?s]])", + "rows": [ + [ + ":contractor" + ] + ] + }, + { + "name": "any valid time", + "query": "(query [:find ?s :valid-at :any-valid-time :where [?e :employment/status ?s]])", + "rows": [ + [ + ":intern" + ], + [ + ":employee" + ], + [ + ":contractor" + ] + ] + }, + { + "name": "count names", + "query": "(query [:find (count ?e) :where [?e :person/name ?n]])", + "rows": [ + [ + "4" + ] + ] + }, + { + "name": "booleans", + "query": "(query [:find ?n ?b :where [?e :person/name ?n] [?e :person/active ?b]])", + "rows": [ + [ + "\"Alice\"", + "true" + ] + ] + }, + { + "name": "not", + "query": "(query [:find ?n :where [?e :person/name ?n] (not [?e :person/active true])])", + "rows": [ + [ + "\"Bob\"" + ], + [ + "\"Carol\"" + ], + [ + "\"Dave\"" + ] + ] + }, + { + "name": "bob active as-of 5", + "query": "(query [:find ?b :as-of 5 :where [:bob :person/active ?b]])", + "rows": [ + [ + "false" + ] + ] + } + ] +} diff --git a/tests/golden/v7_stale_wal.graph b/tests/golden/v7_stale_wal.graph new file mode 100644 index 000000000..7f14b201e Binary files /dev/null and b/tests/golden/v7_stale_wal.graph differ diff --git a/tests/golden/v7_stale_wal.graph.wal b/tests/golden/v7_stale_wal.graph.wal new file mode 100644 index 000000000..0585d5104 Binary files /dev/null and b/tests/golden/v7_stale_wal.graph.wal differ diff --git a/tests/golden/v7_stale_wal.json b/tests/golden/v7_stale_wal.json new file mode 100644 index 000000000..333056024 --- /dev/null +++ b/tests/golden/v7_stale_wal.json @@ -0,0 +1,279 @@ +{ + "file": "v7_stale_wal.graph", + "wal": "v7_stale_wal.graph.wal", + "format": 7, + "written_by": "minigraf 2.0.3 (crates.io), tests/golden/gen/v7 recipe stale_wal", + "crc32": { + "graph": "0x15703fbe", + "wal": "0xd171e032" + }, + "tx_count": 6, + "notes": "tx 5 and 6 are in the file and also in the v1 WAL, whose entries are all already checkpointed (#447 shape)", + "queries": [ + { + "name": "all names", + "query": "(query [:find ?n :where [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"" + ], + [ + "\"Bob\"" + ], + [ + "\"Carol\"" + ], + [ + "\"Dave\"" + ] + ] + }, + { + "name": "ages now", + "query": "(query [:find ?n ?a :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Alice\"", + "31" + ], + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ], + [ + "\"Dave\"", + "25" + ] + ] + }, + { + "name": "ages as-of 1", + "query": "(query [:find ?n ?a :as-of 1 :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Alice\"", + "30" + ], + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "ages as-of 3 (alice retracted)", + "query": "(query [:find ?n ?a :as-of 3 :where [?e :person/name ?n] [?e :person/age ?a]])", + "rows": [ + [ + "\"Bob\"", + "42" + ], + [ + "\"Carol\"", + "-9223372036854775808" + ] + ] + }, + { + "name": "eavt lookup", + "query": "(query [:find ?a :where [:alice :person/age ?a]])", + "rows": [ + [ + "31" + ] + ] + }, + { + "name": "avet lookup", + "query": "(query [:find ?n :where [?e :person/role :role/admin] [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"" + ] + ] + }, + { + "name": "keywords", + "query": "(query [:find ?n ?r :where [?e :person/role ?r] [?e :person/name ?n]])", + "rows": [ + [ + "\"Alice\"", + ":role/admin" + ], + [ + "\"Bob\"", + ":role/user" + ] + ] + }, + { + "name": "ref join", + "query": "(query [:find ?n :where [:alice :person/friend ?f] [?f :person/name ?n]])", + "rows": [ + [ + "\"Bob\"" + ] + ] + }, + { + "name": "ref join two hops", + "query": "(query [:find ?n :where [:alice :person/friend ?f] [?f :person/friend ?g] [?g :person/name ?n]])", + "rows": [ + [ + "\"Carol\"" + ] + ] + }, + { + "name": "vaet reverse ref", + "query": "(query [:find ?n :where [?e :person/friend :carol] [?e :person/name ?n]])", + "rows": [ + [ + "\"Bob\"" + ] + ] + }, + { + "name": "floats", + "query": "(query [:find ?n ?h :where [?e :person/name ?n] [?e :person/height ?h]])", + "rows": [ + [ + "\"Alice\"", + "1.5" + ], + [ + "\"Bob\"", + "-0.25" + ] + ] + }, + { + "name": "unicode", + "query": "(query [:find ?m :where [:alice :person/motto ?m]])", + "rows": [ + [ + "\"Zoë 🚀\"" + ] + ] + }, + { + "name": "i64 max", + "query": "(query [:find ?s :where [:carol :person/score ?s]])", + "rows": [ + [ + "9223372036854775807" + ] + ] + }, + { + "name": "long string", + "query": "(query [:find ?b :where [:carol :person/bio ?b]])", + "rows": [ + [ + "\"01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789\"" + ] + ] + }, + { + "name": "valid-at inside window", + "query": "(query [:find ?s :valid-at \"2022-06-01\" :where [?e :employment/status ?s]])", + "rows": [ + [ + ":employee" + ], + [ + ":contractor" + ] + ] + }, + { + "name": "valid-at earlier window", + "query": "(query [:find ?s :valid-at \"2020-06-01\" :where [?e :employment/status ?s]])", + "rows": [ + [ + ":intern" + ] + ] + }, + { + "name": "valid-at before all windows", + "query": "(query [:find ?s :valid-at \"2019-01-01\" :where [?e :employment/status ?s]])", + "rows": [] + }, + { + "name": "valid now", + "query": "(query [:find ?s :where [?e :employment/status ?s]])", + "rows": [ + [ + ":contractor" + ] + ] + }, + { + "name": "any valid time", + "query": "(query [:find ?s :valid-at :any-valid-time :where [?e :employment/status ?s]])", + "rows": [ + [ + ":intern" + ], + [ + ":employee" + ], + [ + ":contractor" + ] + ] + }, + { + "name": "count names", + "query": "(query [:find (count ?e) :where [?e :person/name ?n]])", + "rows": [ + [ + "4" + ] + ] + }, + { + "name": "booleans", + "query": "(query [:find ?n ?b :where [?e :person/name ?n] [?e :person/active ?b]])", + "rows": [ + [ + "\"Alice\"", + "true" + ] + ] + }, + { + "name": "not", + "query": "(query [:find ?n :where [?e :person/name ?n] (not [?e :person/active true])])", + "rows": [ + [ + "\"Bob\"" + ], + [ + "\"Carol\"" + ], + [ + "\"Dave\"" + ] + ] + }, + { + "name": "bob active as-of 5", + "query": "(query [:find ?b :as-of 5 :where [:bob :person/active ?b]])", + "rows": [ + [ + "false" + ] + ] + } + ] +} diff --git a/tests/golden_corpus_test.rs b/tests/golden_corpus_test.rs new file mode 100644 index 000000000..b3991f2ed --- /dev/null +++ b/tests/golden_corpus_test.rs @@ -0,0 +1,243 @@ +//! Golden-file compatibility corpus (#391). +//! +//! Every `tests/golden/*.json` manifest names a committed `.graph` file (and +//! optionally its `.wal`), the CRC32 of each, the expected `tx_count`, and a list of +//! queries with their expected rows. This test opens a copy of each file with the +//! current build and checks the manifest before and after a checkpoint, then after +//! one more write. Golden files are never regenerated, only added: see +//! `tests/golden/README.md`. +#![cfg(not(target_arch = "wasm32"))] + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use minigraf::{Minigraf, QueryResult, Value}; +use serde_json::Value as Json; + +/// The major version of this reader, matched against a query's `min_reader`. +const READER: u64 = 2; + +/// The newest file format this reader opens. Manifests for newer formats are skipped. +const MAX_FORMAT: u64 = 7; + +fn golden_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden") +} + +fn wal_path(path: &Path) -> PathBuf { + let mut s = path.as_os_str().to_os_string(); + s.push(".wal"); + PathBuf::from(s) +} + +/// Canonical text for one value, as written in manifest `rows`. +fn render(v: &Value) -> String { + match v { + Value::String(s) => serde_json::to_string(s).unwrap(), + Value::Integer(i) => i.to_string(), + Value::Float(f) => f.to_string(), + Value::Boolean(b) => b.to_string(), + Value::Keyword(k) if k.starts_with(':') => k.clone(), + Value::Keyword(k) => format!(":{k}"), + Value::Null => "nil".to_string(), + // Entity ids are not a stable output; manifests never return them. + Value::Ref(_) => "#ref".to_string(), + } +} + +fn parse_crc(s: &str) -> u32 { + u32::from_str_radix(s.trim_start_matches("0x"), 16).expect("manifest crc32 must be hex") +} + +struct Manifest { + name: String, + json: Json, +} + +fn load_manifests() -> Vec { + let mut out: Vec = std::fs::read_dir(golden_dir()) + .expect("tests/golden must exist") + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|x| x == "json")) + .map(|p| Manifest { + name: p.file_name().unwrap().to_string_lossy().into_owned(), + json: serde_json::from_slice(&std::fs::read(&p).unwrap()) + .expect("manifest must be valid JSON"), + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +/// Runs every query that applies to this reader and records each mismatch. +fn check_queries(m: &Manifest, db: &Minigraf, phase: &str, failures: &mut Vec) { + for q in m.json["queries"] + .as_array() + .expect("queries must be an array") + { + let qname = q["name"].as_str().expect("query name"); + if q["min_reader"].as_u64().is_some_and(|r| r > READER) { + continue; + } + let expected: BTreeSet> = q["rows"] + .as_array() + .expect("rows must be an array") + .iter() + .map(|row| { + row.as_array() + .expect("row must be an array") + .iter() + .map(|c| c.as_str().expect("cell must be a string").to_string()) + .collect() + }) + .collect(); + let got: BTreeSet> = match db.execute(q["query"].as_str().expect("query")) { + Ok(QueryResult::QueryResults { results, .. }) => results + .iter() + .map(|row| row.iter().map(render).collect()) + .collect(), + Ok(_) => { + failures.push(format!("{} [{phase}] {qname}: not a query result", m.name)); + continue; + } + Err(e) => { + failures.push(format!("{} [{phase}] {qname}: error {}", m.name, e.code())); + continue; + } + }; + if got != expected { + failures.push(format!( + "{} [{phase}] {qname}: expected {expected:?}, got {got:?}", + m.name + )); + } + } +} + +fn check_tx_count(m: &Manifest, db: &Minigraf, want: u64, phase: &str, f: &mut Vec) { + let got = db.current_tx_count(); + if got != want { + f.push(format!( + "{} [{phase}] tx_count: expected {want}, got {got}", + m.name + )); + } +} + +fn check_manifest(m: &Manifest, failures: &mut Vec) { + let dir = golden_dir(); + let file = m.json["file"].as_str().expect("manifest file"); + let src = dir.join(file); + let wal = m.json["wal"].as_str().map(|w| dir.join(w)); + + // 1. Frozen: the committed files match the manifest CRCs. + let src_bytes = std::fs::read(&src).expect("golden file must exist"); + let wal_bytes = wal + .as_ref() + .map(|w| std::fs::read(w).expect("golden WAL must exist")); + let crc = &m.json["crc32"]; + if crc32fast::hash(&src_bytes) != parse_crc(crc["graph"].as_str().expect("crc32.graph")) { + failures.push(format!("{}: {file} does not match its crc32", m.name)); + } + match (&wal_bytes, crc["wal"].as_str()) { + (Some(b), Some(c)) if crc32fast::hash(b) == parse_crc(c) => {} + (None, None) => {} + _ => failures.push(format!("{}: WAL does not match its crc32", m.name)), + } + + // 2. Skip formats this reader cannot open. + if m.json["format"].as_u64().expect("format") > MAX_FORMAT { + return; + } + + // 3. Open a copy; never touch the committed file. + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(file); + std::fs::write(&path, &src_bytes).unwrap(); + if let Some(b) = &wal_bytes { + std::fs::write(wal_path(&path), b).unwrap(); + } + let tx_count = m.json["tx_count"].as_u64().expect("tx_count"); + { + let db = match Minigraf::open(&path) { + Ok(db) => db, + Err(e) => { + failures.push(format!("{}: open failed with {}", m.name, e.code())); + return; + } + }; + check_tx_count(m, &db, tx_count, "open", failures); + check_queries(m, &db, "open", failures); + db.checkpoint().unwrap(); + } + + // 4. Persist: reopen after the checkpoint. v2.x leaves a WAL whose entries + // were all checkpointed already (`v7_stale_wal`) in place; v3 removes it. + if READER >= 3 && wal_path(&path).exists() { + failures.push(format!("{}: WAL still present after checkpoint", m.name)); + } + { + let db = Minigraf::open(&path).unwrap(); + check_tx_count(m, &db, tx_count, "reopen", failures); + check_queries(m, &db, "reopen", failures); + + // 5. Write after: the counter moves on from the file's floor (#447). + db.execute("(transact [[:golden/probe :golden/n 1]])") + .unwrap(); + check_tx_count(m, &db, tx_count + 1, "write", failures); + } + { + let db = Minigraf::open(&path).unwrap(); + check_tx_count(m, &db, tx_count + 1, "write-reopen", failures); + match db.execute("(query [:find ?n :where [:golden/probe :golden/n ?n]])") { + Ok(QueryResult::QueryResults { results, .. }) if results.len() == 1 => {} + _ => failures.push(format!("{} [write-reopen]: probe fact missing", m.name)), + } + check_queries(m, &db, "write-reopen", failures); + } + + // 6. Untouched: the committed files are unchanged. + if std::fs::read(&src).unwrap() != src_bytes { + failures.push(format!("{}: committed {file} was modified", m.name)); + } +} + +#[test] +fn golden_corpus() { + let manifests = load_manifests(); + assert!(!manifests.is_empty(), "tests/golden holds no manifests"); + let mut failures = Vec::new(); + for m in &manifests { + check_manifest(m, &mut failures); + } + assert!( + failures.is_empty(), + "golden corpus failures:\n{}", + failures.join("\n") + ); +} + +/// Every golden file has a manifest, so none goes unchecked. +#[test] +fn every_golden_file_has_a_manifest() { + let manifests = load_manifests(); + let named: BTreeSet = manifests + .iter() + .flat_map(|m| { + let mut v = vec![m.json["file"].as_str().unwrap().to_string()]; + if let Some(w) = m.json["wal"].as_str() { + v.push(w.to_string()); + } + v + }) + .collect(); + for entry in std::fs::read_dir(golden_dir()).unwrap() { + let name = entry.unwrap().file_name().to_string_lossy().into_owned(); + if name.ends_with(".graph") || name.ends_with(".wal") { + assert!( + named.contains(&name), + "golden file without a manifest: {name}" + ); + } + } +}