From 80d74256d2133a3cf35269571debb338f24923bf Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 15:18:50 +0000 Subject: [PATCH 01/20] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/link-cli/issues/100 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..b159e90 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-08-29T15:18:50.089Z for PR creation at branch issue-100-f2e0ccb162ad for issue https://github.com/link-foundation/link-cli/issues/100 \ No newline at end of file From 293c00ad5eed06d72ef0a963a5369e40a838d9d1 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 15:29:43 +0000 Subject: [PATCH 02/20] chore(rust): update doublets basis crate to 0.5.0 --- rust/Cargo.lock | 4 ++-- rust/Cargo.toml | 2 +- rust/changelog.d/20260829_150000_issue_100_doublets_050.md | 5 +++++ rust/wasm/Cargo.lock | 4 ++-- 4 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 rust/changelog.d/20260829_150000_issue_100_doublets_050.md diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9ec3a31..cd2718e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -158,9 +158,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "doublets" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec36305f729ca75677ebe6be2f45020a52894c32cac4842e18d4ad320f7a31f" +checksum = "677be8ee593349204e8e7cbc2398d4b98fb95fd1386f3a7eaf286860a5788c8f" dependencies = [ "cfg-if", "leak_slice", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e5cce90..4412b55 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -28,7 +28,7 @@ thiserror = "2.0.20" anyhow = "1.0.104" # Issue 67 Rust basis crates: # Source: http://github.com/linksplatform/doublets-rs -doublets = "0.4.0" +doublets = "0.5.0" # Source: http://github.com/link-foundation/links-notation links-notation = "0.16.1" # Source: http://github.com/link-foundation/lino-arguments diff --git a/rust/changelog.d/20260829_150000_issue_100_doublets_050.md b/rust/changelog.d/20260829_150000_issue_100_doublets_050.md new file mode 100644 index 0000000..a162893 --- /dev/null +++ b/rust/changelog.d/20260829_150000_issue_100_doublets_050.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Updated the `doublets` basis crate to 0.5.0 (both the `link-cli` and `clink-wasm` lockfiles), which brings the upstream `doublets::decorators` layer into reach for the Rust storage stack. diff --git a/rust/wasm/Cargo.lock b/rust/wasm/Cargo.lock index 814bfcb..7fcbd35 100644 --- a/rust/wasm/Cargo.lock +++ b/rust/wasm/Cargo.lock @@ -215,9 +215,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "doublets" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec36305f729ca75677ebe6be2f45020a52894c32cac4842e18d4ad320f7a31f" +checksum = "677be8ee593349204e8e7cbc2398d4b98fb95fd1386f3a7eaf286860a5788c8f" dependencies = [ "cfg-if", "leak_slice", From 83f324e93773f555abc9703d6aad17d9ad7f9f4e Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 15:32:44 +0000 Subject: [PATCH 03/20] feat(rust): reuse the doublets 0.5.0 decorator layer in DoubletsStorage --- .../20260829_151000_issue_100_decorators.md | 5 + rust/src/lib.rs | 10 +- rust/src/storage/doublets_storage.rs | 68 +++++++++ rust/src/storage/mod.rs | 8 +- rust/tests/doublets_decorators_tests.rs | 137 ++++++++++++++++++ 5 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 rust/changelog.d/20260829_151000_issue_100_decorators.md create mode 100644 rust/tests/doublets_decorators_tests.rs diff --git a/rust/changelog.d/20260829_151000_issue_100_decorators.md b/rust/changelog.d/20260829_151000_issue_100_decorators.md new file mode 100644 index 0000000..a0d8e92 --- /dev/null +++ b/rust/changelog.d/20260829_151000_issue_100_decorators.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Reused the `doublets` 0.5.0 decorator layer in the Rust storage stack: `DoubletsStorage::map_store` composes any upstream (or custom) decorator onto an open database while keeping its path, advisory lock and change-detection fingerprint, and `DoubletsStorage::with_automatic_uniqueness_and_usages_resolution` applies the same stack C# gets from `ILinksExtensions.DecorateWithAutomaticUniquenessAndUsagesResolution`. The `doublets` crate and its `decorators` module are re-exported from `link_cli` so downstream crates can build custom stacks without a version-mismatched direct dependency. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 6afe7b1..6b75622 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -39,6 +39,12 @@ pub mod transactions; mod unicode_string_storage; pub mod version_control; +/// The `doublets` crate this library is built on, re-exported so +/// downstream crates can name upstream types (stores, decorators, +/// `LinkReference` implementations) without adding their own dependency +/// and risking a version mismatch. +pub use doublets; + // Re-export main types for easy access pub use changes_simplifier::simplify_changes; pub use error::LinkError; @@ -55,8 +61,8 @@ pub use pinned_types::{PinnedTypes, PinnedTypesAccess, PinnedTypesDecorator}; pub use query_options::QueryOptions; pub use query_processor::QueryProcessor; pub use storage::{ - lock_file_path, DoubletsStorage, FileLock, FileMappedUnitStore, LinksStorage, LinksStorageRef, - LockMode, PersistentFileMapped, StorageRevision, + decorators, lock_file_path, DoubletsStorage, FileLock, FileMappedUnitStore, LinksStorage, + LinksStorageRef, LockMode, PersistentFileMapped, ResolvedFileMappedUnitStore, StorageRevision, }; pub use transactions::{ CommitMode, DoubletLink, FileTransitionLog, GenericDoubletLink, GenericTransactionsDecorator, diff --git a/rust/src/storage/doublets_storage.rs b/rust/src/storage/doublets_storage.rs index ce064d8..69f5850 100644 --- a/rust/src/storage/doublets_storage.rs +++ b/rust/src/storage/doublets_storage.rs @@ -35,6 +35,7 @@ use std::marker::PhantomData; use std::path::{Path, PathBuf}; use doublets::data::{Flow, LinkReference}; +use doublets::decorators::{AutomaticUniquenessAndUsagesResolution, DecoratorsExt}; use doublets::unit::{LinkPart, Store as UnitStore}; use doublets::Doublets; @@ -47,6 +48,17 @@ use crate::storage::traits::{LinksStorage, StorageRevision}; /// The file-mapped `doublets` store used by [`DoubletsStorage::open`]. pub type FileMappedUnitStore = UnitStore>>; +/// The file-mapped store wrapped in the upstream decorator stack that C# +/// applies by default, produced by +/// [`DoubletsStorage::with_automatic_uniqueness_and_usages_resolution`]. +/// +/// This is the Rust spelling of the C# type produced by +/// `ILinksExtensions.DecorateWithAutomaticUniquenessAndUsagesResolution`, +/// which `Foundation.Data.Doublets.Cli.Library` applies to every +/// `UnitedMemoryLinks` it opens. +pub type ResolvedFileMappedUnitStore = + AutomaticUniquenessAndUsagesResolution>; + /// A [`LinksStorage`] over any `doublets` store. pub struct DoubletsStorage> { store: S, @@ -145,6 +157,62 @@ impl> DoubletsStorage { }) } + /// Replaces the underlying store with `map(store)`, keeping the path, + /// advisory lock and change-detection fingerprint of this storage. + /// + /// This is the extension point for stacking any `doublets` decorator + /// (or a custom one) under the transactions and version control + /// layers: + /// + /// ```no_run + /// use doublets::decorators::DecoratorsExt; + /// use link_cli::storage::DoubletsStorage; + /// + /// # fn main() -> Result<(), link_cli::LinkError> { + /// let storage = DoubletsStorage::::open("links.data")? + /// .map_store(|store| store.with_inner_reference_existence_validation()); + /// # Ok(()) } + /// ``` + pub fn map_store(self, map: F) -> DoubletsStorage + where + S2: Doublets, + F: FnOnce(S) -> S2, + { + DoubletsStorage { + store: map(self.store), + path: self.path, + known_revision: self.known_revision, + lock: self.lock, + address: PhantomData, + } + } + + /// Wraps the underlying store in the same decorator stack C# applies + /// through `ILinksExtensions.DecorateWithAutomaticUniquenessAndUsagesResolution`. + /// + /// After this call `(source, target)` pairs are unique: creating or + /// updating a link into a pair that already exists resolves to the + /// existing link, re-points every usage of the redundant link at the + /// survivor and deletes the redundant link. Deleting a link cascades + /// to its usages and resets its contents first. + /// + /// ```no_run + /// use link_cli::storage::{DoubletsStorage, LinksStorage}; + /// + /// # fn main() -> Result<(), link_cli::LinkError> { + /// let mut storage = DoubletsStorage::::open("links.data")? + /// .with_automatic_uniqueness_and_usages_resolution(); + /// let first = storage.create_link(1, 1)?; + /// let second = storage.create_link(1, 1)?; + /// assert_eq!(first, second); + /// # Ok(()) } + /// ``` + pub fn with_automatic_uniqueness_and_usages_resolution( + self, + ) -> DoubletsStorage> { + self.map_store(DecoratorsExt::with_automatic_uniqueness_and_usages_resolution) + } + /// The database file backing this storage, when known. pub fn path(&self) -> Option<&Path> { self.path.as_deref() diff --git a/rust/src/storage/mod.rs b/rust/src/storage/mod.rs index f51208c..1572c47 100644 --- a/rust/src/storage/mod.rs +++ b/rust/src/storage/mod.rs @@ -5,13 +5,19 @@ //! written against, and [`DoubletsStorage`] for the file-mapped //! `doublets` implementation. +/// The upstream `doublets` decorator layer, re-exported so downstream +/// crates can stack decorators onto a [`DoubletsStorage`] through +/// [`DoubletsStorage::map_store`] without depending on `doublets` +/// directly (and without risking a version mismatch). +pub use doublets::decorators; + mod decorator_impls; mod doublets_storage; mod file_mem; pub mod lock; mod traits; -pub use doublets_storage::{DoubletsStorage, FileMappedUnitStore}; +pub use doublets_storage::{DoubletsStorage, FileMappedUnitStore, ResolvedFileMappedUnitStore}; pub use file_mem::PersistentFileMapped; pub use lock::{lock_file_path, FileLock, LockMode}; pub use traits::{LinksStorage, LinksStorageRef, StorageRevision}; diff --git a/rust/tests/doublets_decorators_tests.rs b/rust/tests/doublets_decorators_tests.rs new file mode 100644 index 0000000..5323e12 --- /dev/null +++ b/rust/tests/doublets_decorators_tests.rs @@ -0,0 +1,137 @@ +//! Coverage for the upstream `doublets` decorator stack wired into +//! [`DoubletsStorage`] for issue #100. +//! +//! `Foundation.Data.Doublets.Cli.Library` opens every C# database as +//! `new UnitedMemoryLinks(file).DecorateWithAutomaticUniquenessAndUsagesResolution()`. +//! `doublets` 0.5.0 ships that exact stack as +//! `DecoratorsExt::with_automatic_uniqueness_and_usages_resolution`, so these +//! tests pin the Rust behaviour to the C# one instead of re-implementing it here. + +use anyhow::Result; +use link_cli::decorators::DecoratorsExt; +use link_cli::{DoubletsStorage, LinksStorage, ResolvedFileMappedUnitStore}; +use tempfile::TempDir; + +fn open_resolved( + path: &std::path::Path, +) -> Result>> { + Ok(DoubletsStorage::::open(path)?.with_automatic_uniqueness_and_usages_resolution()) +} + +#[test] +fn undecorated_storage_still_allows_duplicate_pairs() -> Result<()> { + let dir = TempDir::new()?; + let mut storage = DoubletsStorage::::open(dir.path().join("links.doublets"))?; + + let point = storage.create_link(0, 0)?; + let first = storage.create_link(point, point)?; + let second = storage.create_link(point, point)?; + + assert_ne!( + first, second, + "the bare unit store has no uniqueness policy of its own" + ); + Ok(()) +} + +#[test] +fn resolved_storage_turns_duplicate_creation_into_get_or_create() -> Result<()> { + let dir = TempDir::new()?; + let mut storage = open_resolved(&dir.path().join("links.doublets"))?; + + let point = storage.create_link(0, 0)?; + let first = storage.create_link(point, point)?; + let second = storage.create_link(point, point)?; + + assert_eq!( + first, second, + "creating an existing (source, target) pair must resolve to the existing link" + ); + assert_eq!(storage.search_link(point, point), Some(first)); + assert_eq!( + storage.links_count(), + 2, + "the redundant link must be deleted, leaving only the point and the survivor" + ); + Ok(()) +} + +#[test] +fn resolved_storage_repoints_usages_of_the_redundant_link() -> Result<()> { + let dir = TempDir::new()?; + let mut storage = open_resolved(&dir.path().join("links.doublets"))?; + + let a = storage.create_link(0, 0)?; + let b = storage.create_link(0, 0)?; + let survivor = storage.create_link(a, a)?; + let redundant = storage.create_link(a, b)?; + let usage = storage.create_link(redundant, redundant)?; + + // Updating `redundant` onto the `(a, a)` pair collides with `survivor`. + storage.update_link(redundant, a, a)?; + + assert!( + !storage.link_exists(redundant), + "the redundant link must be deleted once its usages are migrated" + ); + assert_eq!( + storage + .get_link(usage) + .map(|link| (link.source, link.target)), + Some((survivor, survivor)), + "every usage of the redundant link must be re-pointed at the survivor" + ); + Ok(()) +} + +#[test] +fn resolved_storage_cascades_deletion_to_usages() -> Result<()> { + let dir = TempDir::new()?; + let mut storage = open_resolved(&dir.path().join("links.doublets"))?; + + let a = storage.create_link(0, 0)?; + let b = storage.create_link(a, a)?; + let usage = storage.create_link(b, b)?; + + storage.delete_link(b)?; + + assert!(!storage.link_exists(b)); + assert!( + !storage.link_exists(usage), + "deleting a link must cascade to the links that reference it" + ); + assert!(storage.link_exists(a)); + Ok(()) +} + +#[test] +fn map_store_preserves_the_backing_path_and_durability() -> Result<()> { + let dir = TempDir::new()?; + let path = dir.path().join("links.doublets"); + + let mut storage = open_resolved(&path)?; + assert_eq!(storage.path(), Some(path.as_path())); + + let point = storage.create_link(0, 0)?; + storage.flush()?; + drop(storage); + + let reopened = open_resolved(&path)?; + assert!(reopened.link_exists(point)); + Ok(()) +} + +#[test] +fn map_store_accepts_any_upstream_decorator() -> Result<()> { + let dir = TempDir::new()?; + let mut storage = DoubletsStorage::::open(dir.path().join("links.doublets"))? + .map_store(DecoratorsExt::with_inner_reference_existence_validation); + + let point = storage.create_link(0, 0)?; + assert!(storage.create_link(point, point).is_ok()); + assert!( + storage.create_link(point, 4321).is_err(), + "the existence validator must reject references to links that do not exist" + ); + Ok(()) +} From 76320382b1e2eedb422a43fd821d5109de29c37d Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 16:26:32 +0000 Subject: [PATCH 04/20] fix(rust): resolve uniqueness and cascades through the doublets decorators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust CLI kept its own ad-hoc write path, so four behaviours the C# CLI gets for free from `DecorateWithAutomaticUniquenessAndUsagesResolution` were missing: deleting a link left dangling references behind, the cascade did not chain, an update into an existing pair created a duplicate, and the named variant of the cascade delete diverged too. `LinkStorage` now implements the upstream `Links`/`Doublets` traits (an `each_core` port that reproduces the index semantics of the unit store, including that a link with a null part is not reachable through a `(source, target)` lookup), which lets the same upstream resolver stack C# uses sit on top of it. Two layers had to follow: - The transactions decorator recorded a single transition per logical write, so every link a cascade touched was invisible to the log and rollback and branch switching silently lost those changes. It now folds the observed `(before, after)` callbacks the way C#'s `TransactionsDecorator.RunWrite` does — first `before`, last `after`, first-seen order — and derives the transition kind per link, because a cascade can delete a link during an `update` and recording that as an `Update` makes the revert a no-op. - The query processor gained C#'s `intendedFinalStates` / `RestoreUnexpectedLinkDeletions` pass, so a link a merge removed as a side effect is put back instead of leaving the query half-applied. The two `get_link(..).unwrap()` sites that assumed the link survived the write are guarded rather than panicking. A cross-language harness lives in `docs/case-studies/issue-100/evidence/cli-parity/`: 21 of 22 scenarios now produce byte-identical databases *and* accept the same queries. The remaining one is an upstream C# defect, reproduced in isolation in `evidence/csharp-merge-usages/`: `ILinksExtensions.MergeUsages` builds its substitutions with the two-argument `Link` constructor, which `SetValues` reads as `(index, source)` with a null target rather than as `(source, target)`, so every usage it repoints has its source overwritten and its target blanked. It is recorded as a known difference that turns red if the two languages ever agree. --- .../issue-100/evidence/cli-parity/run.sh | 131 +++++++++ .../evidence/csharp-merge-usages/Program.cs | 82 ++++++ .../csharp-merge-usages.csproj | 11 + .../evidence/csharp-merge-usages/run.sh | 7 + ...0260829_160000_issue_100_cascade_parity.md | 5 + rust/src/lib.rs | 2 + rust/src/link_reference_validator.rs | 52 +++- rust/src/link_storage.rs | 134 ++++++++- rust/src/link_storage_doublets.rs | 274 ++++++++++++++++++ rust/src/named_types.rs | 72 ++++- rust/src/pinned_types.rs | 22 +- rust/src/query_processor.rs | 95 +++++- rust/src/storage/decorator_impls.rs | 19 ++ rust/src/storage/traits.rs | 39 +++ rust/src/transactions/mod.rs | 105 ++++++- .../doublets_resolver_semantics_tests.rs | 70 +++++ rust/tests/lino_database_input_tests.rs | 7 +- .../query_processor_csharp_parity_tests.rs | 88 ++++++ 18 files changed, 1179 insertions(+), 36 deletions(-) create mode 100755 docs/case-studies/issue-100/evidence/cli-parity/run.sh create mode 100644 docs/case-studies/issue-100/evidence/csharp-merge-usages/Program.cs create mode 100644 docs/case-studies/issue-100/evidence/csharp-merge-usages/csharp-merge-usages.csproj create mode 100755 docs/case-studies/issue-100/evidence/csharp-merge-usages/run.sh create mode 100644 rust/changelog.d/20260829_160000_issue_100_cascade_parity.md create mode 100644 rust/src/link_storage_doublets.rs create mode 100644 rust/tests/doublets_resolver_semantics_tests.rs diff --git a/docs/case-studies/issue-100/evidence/cli-parity/run.sh b/docs/case-studies/issue-100/evidence/cli-parity/run.sh new file mode 100755 index 0000000..5823620 --- /dev/null +++ b/docs/case-studies/issue-100/evidence/cli-parity/run.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Runs the same sequence of `clink` queries through the C# and the Rust CLI and +# diffs the resulting databases, so cross-language behaviour gaps are evidence, +# not guesswork. +# +# Usage: docs/case-studies/issue-100/evidence/cli-parity/run.sh [rust-binary] [csharp-binary] +set -u + +REPO="$(cd "$(dirname "$0")/../../../../.." && pwd)" +RS="${1:-$REPO/rust/target/debug/clink}" +CS="${2:-$REPO/csharp/Foundation.Data.Doublets.Cli/bin/Debug/net10.0/clink}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +failures=0 + +# Runs the query sequence through both CLIs and leaves, for each of them, the +# final database dump in "$WORK//final" and one accepted/rejected verdict +# per query in "$WORK//status". +# +# The verdicts are compared alongside the dumps because a query the two CLIs +# both refuse leaves two empty databases, which a dump-only comparison would +# happily call a match. The exit status is compared rather than the message +# text: the two implementations are expected to agree on *what* they accept, +# not on how they word a rejection. +run_both() { + local rs_dir="$WORK/rs" cs_dir="$WORK/cs" + rm -rf "$rs_dir" "$cs_dir"; mkdir -p "$rs_dir" "$cs_dir" + : > "$rs_dir/status"; : > "$cs_dir/status" + + local q + for q in "$@"; do + "$RS" --db "$rs_dir/l.links" --query "$q" > "$rs_dir/out" 2>&1 + verdict "$?" "$q" >> "$rs_dir/status" + "$CS" --db "$cs_dir/l.links" --query "$q" > "$cs_dir/out" 2>&1 + verdict "$?" "$q" >> "$cs_dir/status" + done + + "$RS" --db "$rs_dir/l.links" --after > "$rs_dir/final" 2>&1 + "$CS" --db "$cs_dir/l.links" --after > "$cs_dir/final" 2>&1 +} + +verdict() { + if [ "$1" -eq 0 ]; then echo "accepted: $2"; else echo "rejected: $2"; fi +} + +# Succeeds when both CLIs accepted the same queries and ended at the same +# database. +agree() { + diff -q "$WORK/rs/status" "$WORK/cs/status" > /dev/null \ + && diff -q "$WORK/rs/final" "$WORK/cs/final" > /dev/null +} + +report_divergence() { + echo " queries: $*" + if ! diff -q "$WORK/rs/status" "$WORK/cs/status" > /dev/null; then + echo " accepted queries differ:" + diff "$WORK/rs/status" "$WORK/cs/status" | sed 's/^/ /' + fi + echo " rust:"; sed 's/^/ /' "$WORK/rs/final" + echo " c#:"; sed 's/^/ /' "$WORK/cs/final" +} + +# scenario ... +scenario() { + local name="$1"; shift + run_both "$@" + + if agree; then + echo "PASS $name" + else + failures=$((failures + 1)) + echo "FAIL $name" + report_divergence "$@" + fi +} + +# known_difference ... +# +# A scenario the two CLIs are *expected* to answer differently because of a +# defect in a dependency rather than in this repository. It does not count as a +# failure, but agreement does: the day the upstream fix lands, this turns red so +# the exemption gets removed instead of quietly outliving its reason. +known_difference() { + local name="$1" reason="$2"; shift 2 + run_both "$@" + + if agree; then + failures=$((failures + 1)) + echo "FAIL $name (the languages now agree -- drop the exemption)" + echo " reason on record: $reason" + else + echo "KNOWN $name" + echo " $reason" + report_divergence "$@" + fi +} + +scenario "create" '() ((1 1))' +scenario "duplicate create" '() ((1 1))' '() ((1 1))' +scenario "update target" '() ((1 1))' '() ((2 2))' '((1: 1 1)) ((1: 1 2))' +scenario "delete point" '() ((1 1))' '((1: 1 1)) ()' +scenario "cascade delete of usage" '() ((1 1))' '() ((2 2))' '() ((1 2))' '((2: 2 2)) ()' +scenario "cascade delete chain" '() ((1 1))' '() ((2 2))' '() ((1 2))' '() ((3 3))' '((1: 1 1)) ()' +scenario "uniqueness on update" '() ((1 1))' '() ((2 2))' '() ((1 2))' '() ((2 1))' '((4: 2 1)) ((4: 1 2))' +scenario "delete with contents" '() ((1 1))' '() ((2 2))' '() ((1 2))' '((3: 1 2)) ()' +scenario "named create" '() ((name: name name))' +scenario "named cascade delete" '() ((a: a a))' '() ((b: b b))' '() ((a b))' '((a: a a)) ()' +scenario "swap one link" '() ((1 1) (1 2))' '((2: 1 2)) ((2: 2 1))' +scenario "swap all links" '() ((1 2) (2 1))' '((($index: $source $target)) (($index: $target $source)))' +scenario "no-op variable query" '() ((1 1) (2 2))' '((($index: $source $target)) (($index: $source $target)))' +scenario "delete by wildcard" '() ((1 1) (2 2) (1 2))' '((* 1 *)) ()' +scenario "delete everything" '() ((1 1) (2 2) (1 2))' '((*: * *)) ()' +scenario "rename named link" '() ((child: father mother))' '(((child: father mother)) ((son: father mother)))' +scenario "nested composite create" '() ((a (b c)))' +scenario "explicit index after gap" '() ((5: 5 5))' +scenario "reverse update chain" '() ((1 1))' '() ((2 2))' '((1: 1 1)) ((1: 1 2))' '((1: 1 2)) ((1: 1 1))' +scenario "point to non-point" '() ((1 1))' '((1: 1 1)) ((1: 0 0))' +scenario "delete self referencing" '() ((1 1))' '() ((1 1) (1 1))' '((1: 1 1)) ()' + +known_difference "update into duplicate" \ + "Platform.Data.Doublets 0.18.1 MergeUsages corrupts the usages it repoints (see ../csharp-merge-usages), so C# leaves (2: 2 0) where doublets-rs rebases the usage onto the surviving link and leaves (2: 2 2)." \ + '() ((1 2) (2 1))' '((1: 1 2)) ((1: 2 1))' + +echo +if [ "$failures" -eq 0 ]; then + echo "All scenarios match, except the known upstream differences listed above." +else + echo "$failures scenario(s) diverge." +fi +exit "$failures" diff --git a/docs/case-studies/issue-100/evidence/csharp-merge-usages/Program.cs b/docs/case-studies/issue-100/evidence/csharp-merge-usages/Program.cs new file mode 100644 index 0000000..2c9a4e8 --- /dev/null +++ b/docs/case-studies/issue-100/evidence/csharp-merge-usages/Program.cs @@ -0,0 +1,82 @@ +// Isolates `ILinksExtensions.MergeUsages` in Platform.Data.Doublets 0.18.1. +// +// The store below carries no decorators, so nothing but `MergeUsages` can +// touch the links: whatever the dump prints is what `MergeUsages` wrote. +// +// `MergeUsages(old, new)` is supposed to repoint every reference to `old` at +// `new` and leave the other half of each doublet alone. It builds its +// substitutions with the two-argument `Link` constructor: +// +// var substitution = new Link(newLinkIndex, links.GetTarget(usageAsSource)); +// var substitution = new Link(links.GetTarget(usageAsTarget), newLinkIndex); +// +// but that constructor takes `params TLinkAddress[] values`, and `SetValues` +// reads a two-element list as `(index, source)` with a *null target* -- not as +// `(source, target)`. Both substitutions therefore land in the wrong slots. +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.Memory.United.Generic; + +var databaseFilename = Path.Combine(Path.GetTempPath(), $"merge-usages-{Guid.NewGuid():N}.links"); +var reproduced = 0; +try +{ + using var links = new UnitedMemoryLinks(databaseFilename); + + // `one` is merged away, `two` survives, `three` is an unrelated address + // that both usages keep on their other half. + var one = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + var two = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + var three = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + + var usageAsSource = links.CreateAndUpdate(one, three); + var usageAsTarget = links.CreateAndUpdate(three, one); + + Console.WriteLine($"before: {Dump(links)}"); + links.MergeUsages(one, two); + Console.WriteLine($"after: {Dump(links)}"); + + // Repointing a usage must replace only the half that named `one`. + Check("usage as source", usageAsSource, two, three); + Check("usage as target", usageAsTarget, three, two); + + void Check(string what, uint index, uint expectedSource, uint expectedTarget) + { + var link = links.GetLink(index); + var source = links.GetSource(link); + var target = links.GetTarget(link); + if (source == expectedSource && target == expectedTarget) + { + Console.WriteLine($"FIXED {what}: ({index}: {source} {target}) is what a correct merge produces"); + return; + } + reproduced++; + Console.WriteLine($"BUG {what}: expected ({index}: {expectedSource} {expectedTarget}), got ({index}: {source} {target})"); + } +} +finally +{ + File.Delete(databaseFilename); +} +// Exit 0 while the defect reproduces -- this harness records upstream +// behaviour rather than asserting it. It turns red once both usages survive +// the merge intact, which is the signal to drop the parity exemption in +// ../cli-parity/run.sh and re-check the pinned Platform.Data.Doublets version. +if (reproduced == 2) +{ + Console.WriteLine("MergeUsages still corrupts both kinds of usage."); + return 0; +} +Console.WriteLine("MergeUsages no longer matches the recorded behaviour -- revisit the case study."); +return 1; + +static string Dump(ILinks links) +{ + var parts = new List(); + links.Each(new Link(links.Constants.Any, links.Constants.Any, links.Constants.Any), link => + { + parts.Add($"({links.GetIndex(link)}: {links.GetSource(link)} {links.GetTarget(link)})"); + return links.Constants.Continue; + }); + return string.Join(" ", parts); +} diff --git a/docs/case-studies/issue-100/evidence/csharp-merge-usages/csharp-merge-usages.csproj b/docs/case-studies/issue-100/evidence/csharp-merge-usages/csharp-merge-usages.csproj new file mode 100644 index 0000000..911ff62 --- /dev/null +++ b/docs/case-studies/issue-100/evidence/csharp-merge-usages/csharp-merge-usages.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/docs/case-studies/issue-100/evidence/csharp-merge-usages/run.sh b/docs/case-studies/issue-100/evidence/csharp-merge-usages/run.sh new file mode 100755 index 0000000..98ae41d --- /dev/null +++ b/docs/case-studies/issue-100/evidence/csharp-merge-usages/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Reproduces the Platform.Data.Doublets 0.18.1 `MergeUsages` defect. +# Exits 0 while the defect reproduces, and non-zero once upstream fixes it -- +# which is the signal to drop the parity exemption in ../cli-parity/run.sh. +set -u +cd "$(dirname "$0")" +dotnet run -v q --nologo diff --git a/rust/changelog.d/20260829_160000_issue_100_cascade_parity.md b/rust/changelog.d/20260829_160000_issue_100_cascade_parity.md new file mode 100644 index 0000000..ff98a59 --- /dev/null +++ b/rust/changelog.d/20260829_160000_issue_100_cascade_parity.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Routed the CLI's `LinkStorage` through the upstream `doublets` uniqueness and cascade resolvers, so a delete now removes the links that referenced the deleted one and an update that would duplicate an existing link merges into it, matching the C# CLI. The transactions log records one transition per link a write actually touched — including the ones a cascade touched — so rollback and version-control branch switching no longer lose the cascaded changes, and the query processor restores links that a resolved write deleted as a side effect, mirroring `RestoreUnexpectedLinkDeletions` in the C# processor. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 6b75622..0d053da 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -22,6 +22,7 @@ mod hybrid_reference; mod link; mod link_reference_validator; mod link_storage; +mod link_storage_doublets; mod lino_database_input; mod lino_link; mod named_links; @@ -51,6 +52,7 @@ pub use error::LinkError; pub use hybrid_reference::{external_reference, external_reference_value, HybridReference}; pub use link::{DoubletsLink, GenericLink, Link}; pub use link_storage::LinkStorage; +pub use link_storage_doublets::link_storage_constants; pub use lino_database_input::{import_lino_file, import_lino_text}; pub use lino_link::LinoLink; pub use named_links::NamedLinks; diff --git a/rust/src/link_reference_validator.rs b/rust/src/link_reference_validator.rs index df978d1..d552a46 100644 --- a/rust/src/link_reference_validator.rs +++ b/rust/src/link_reference_validator.rs @@ -15,6 +15,13 @@ pub(crate) struct LinkReferenceValidator { struct LinkReferencePlan { numeric_ids_to_be_created: HashSet, names_to_be_created: HashSet, + /// `(source, target)` pairs the substitution itself defines. + /// + /// A missing numeric reference whose own point pair `(id, id)` appears + /// here is left as a `(id: 0 0)` placeholder instead of being turned into + /// a point link, so that the substitution which is about to write that + /// exact pair does not collide with it under uniqueness resolution. + composite_pairs_to_be_created: HashSet<(u32, u32)>, missing_references: Vec, missing_reference_keys: HashSet, } @@ -98,7 +105,7 @@ impl LinkReferenceValidator { .into()); } - let created = self.auto_create_missing_references(storage, &plan.missing_references)?; + let created = self.auto_create_missing_references(storage, &plan)?; self.trace_msg("[ValidateLinksExistOrWillBeCreated] Validation completed"); Ok(created) } @@ -124,6 +131,10 @@ impl LinkReferenceValidator { ); } + for pattern in substitution_patterns { + Self::collect_composite_pairs(pattern, &mut plan); + } + plan } @@ -151,6 +162,27 @@ impl LinkReferenceValidator { } } + fn collect_composite_pairs(pattern: &LinoLink, plan: &mut LinkReferencePlan) { + if Self::is_composite_lino(pattern) + && Self::concrete_identifier(pattern.id.as_deref()).is_some() + { + if let Some(values) = &pattern.values { + if let (Some(source), Some(target)) = ( + Self::concrete_numeric_identifier(values[0].id.as_deref()), + Self::concrete_numeric_identifier(values[1].id.as_deref()), + ) { + plan.composite_pairs_to_be_created.insert((source, target)); + } + } + } + + if let Some(values) = &pattern.values { + for sub_pattern in values { + Self::collect_composite_pairs(sub_pattern, plan); + } + } + } + fn collect_implicit_definitions( &self, storage: &mut impl NamedTypeLinks, @@ -278,8 +310,9 @@ impl LinkReferenceValidator { fn auto_create_missing_references( &self, storage: &mut impl NamedTypeLinks, - missing_references: &[MissingLinkReference], + plan: &LinkReferencePlan, ) -> Result> { + let missing_references = &plan.missing_references; let mut created = Vec::new(); let mut numeric_references = missing_references .iter() @@ -294,9 +327,18 @@ impl LinkReferenceValidator { } self.trace_msg(&format!( - "[ValidateLinksExistOrWillBeCreated] Auto-creating missing numeric reference {link_id} as point link." + "[ValidateLinksExistOrWillBeCreated] Auto-creating missing numeric reference {link_id}." )); storage.try_ensure_created(link_id)?; + if plan + .composite_pairs_to_be_created + .contains(&(link_id, link_id)) + { + self.trace_msg(&format!( + "[ValidateLinksExistOrWillBeCreated] Link {link_id} exists as a placeholder because ({link_id}, {link_id}) is defined by the substitution." + )); + continue; + } storage.update(link_id, link_id, link_id)?; if let Some(link) = storage.get_link(link_id) { created.push(link); @@ -332,6 +374,10 @@ impl LinkReferenceValidator { lino_link.values_count() == 2 } + fn concrete_numeric_identifier(id: Option<&str>) -> Option { + Self::concrete_identifier(id).and_then(|identifier| identifier.parse::().ok()) + } + fn concrete_identifier(id: Option<&str>) -> Option { let identifier = id?.trim_end_matches(':'); if identifier.is_empty() || identifier == "*" || identifier.starts_with('$') { diff --git a/rust/src/link_storage.rs b/rust/src/link_storage.rs index d492302..e8b7795 100644 --- a/rust/src/link_storage.rs +++ b/rust/src/link_storage.rs @@ -3,6 +3,8 @@ //! This module provides the LinkStorage struct for managing link persistence. use anyhow::{Context, Result}; +use doublets::decorators::DecoratorsExt; +use doublets::Doublets; use std::collections::{HashMap, HashSet}; use std::fs::{File, OpenOptions}; use std::io::{BufRead, BufReader, BufWriter, Write}; @@ -12,6 +14,25 @@ use crate::error::LinkError; use crate::link::Link; use crate::storage::StorageRevision; +/// Callback invoked once per `(before, after)` change a write produced. +/// +/// The upstream decorators turn a single write into a cascade of changes, so +/// the layers above the storage — names, transactions, the query processor — +/// only stay in sync if they can see all of them. This is the equivalent of the +/// `WriteHandler` the C# implementation threads through every decorator. A +/// change whose `after` [`is null`](Link::is_null) is a deletion. +pub type ChangeObserver<'a> = &'a mut dyn FnMut(Link, Link); + +/// Adapts a [`ChangeObserver`] to the `doublets` write handler signature. +fn observe( + observer: &mut dyn FnMut(Link, Link), + before: doublets::Link, + after: doublets::Link, +) -> doublets::data::Flow { + observer(Link::from(before), Link::from(after)); + doublets::data::Flow::Continue +} + /// LinkStorage provides persistent storage for links /// Corresponds to the storage functionality in NamedLinksDecorator in C# pub struct LinkStorage { @@ -240,8 +261,14 @@ impl LinkStorage { self.links.contains_key(&id) } - /// Updates a link's source and target - pub fn update(&mut self, id: u32, source: u32, target: u32) -> Result { + /// Updates a link's source and target **without** applying any policy. + /// + /// This is the raw store operation, the equivalent of writing straight to + /// `UnitedMemoryLinks` in the C# implementation. [`LinkStorage::update`] + /// wraps it with the upstream uniqueness/usages decorators; use this method + /// when you are supplying your own decorator stack (or deliberately want + /// none). + pub fn update_raw(&mut self, id: u32, source: u32, target: u32) -> Result { if let Some(link) = self.links.get_mut(&id) { let before = *link; if self.trace { @@ -258,8 +285,12 @@ impl LinkStorage { } } - /// Deletes a link by ID - pub fn delete(&mut self, id: u32) -> Result { + /// Deletes a link by ID **without** applying any policy. + /// + /// The raw counterpart of [`LinkStorage::delete`]: it removes exactly the + /// requested link (and its name), leaving any link that referenced it + /// dangling. + pub fn delete_raw(&mut self, id: u32) -> Result { // Also remove the name mapping if let Some(name) = self.names.remove(&id) { self.name_to_id.remove(&name); @@ -278,6 +309,85 @@ impl LinkStorage { } } + /// Updates a link's source and target through the upstream + /// `doublets` uniqueness and usages resolution stack. + /// + /// This mirrors the C# implementation, which always talks to a + /// `UnitedMemoryLinks` wrapped in + /// `DecorateWithAutomaticUniquenessAndUsagesResolution()`. Concretely: if + /// another link already holds `(source, target)`, every reference to `id` + /// is re-pointed at that link and `id` is deleted, instead of storing a + /// duplicate doublet. + /// + /// Returns the state the link was in before the operation. Use + /// [`LinkStorage::update_raw`] for the undecorated write. + pub fn update(&mut self, id: u32, source: u32, target: u32) -> Result { + self.update_observed(id, source, target, &mut |_, _| {}) + } + + /// [`LinkStorage::update`], reporting every change the decorator stack made. + /// + /// Resolving a duplicate doublet re-points and deletes other links, so one + /// call can produce several changes. Layers above the storage need to see + /// all of them — the C# implementation gets them for free because its + /// decorators forward to a `WriteHandler`: + /// + /// ```csharp + /// var result = _links.Update(restriction, substitution, (before, after) => { ... }); + /// ``` + /// + /// `observer` is that handler. A change with a null `after` is a deletion. + pub fn update_observed( + &mut self, + id: u32, + source: u32, + target: u32, + observer: ChangeObserver<'_>, + ) -> Result { + let before = *self + .links + .get(&id) + .ok_or_else(|| LinkError::not_found(id))?; + let mut resolved = (&mut *self).with_automatic_uniqueness_and_usages_resolution(); + resolved + .update_by_with([id], [id, source, target], &mut |before, after| { + observe(observer, before, after) + }) + .map_err(LinkError::from)?; + Ok(before) + } + + /// Deletes a link through the upstream `doublets` uniqueness and usages + /// resolution stack, cascading to every link that references it. + /// + /// This mirrors the C# implementation's + /// `DecorateWithAutomaticUniquenessAndUsagesResolution()` behaviour: the + /// link is reset to `(null, null)`, everything that still references it is + /// deleted first, and only then is the link itself removed. Cycles + /// terminate rather than recursing forever. + /// + /// Returns the state the requested link was in before the operation. Use + /// [`LinkStorage::delete_raw`] for the undecorated removal. + pub fn delete(&mut self, id: u32) -> Result { + self.delete_observed(id, &mut |_, _| {}) + } + + /// [`LinkStorage::delete`], reporting every change the decorator stack made. + /// + /// A cascading delete removes every link that still referenced `id`, so one + /// call can produce several changes; see [`LinkStorage::update_observed`]. + pub fn delete_observed(&mut self, id: u32, observer: ChangeObserver<'_>) -> Result { + let before = *self + .links + .get(&id) + .ok_or_else(|| LinkError::not_found(id))?; + let mut resolved = (&mut *self).with_automatic_uniqueness_and_usages_resolution(); + resolved + .delete_by_with([id], &mut |before, after| observe(observer, before, after)) + .map_err(LinkError::from)?; + Ok(before) + } + /// Returns all links pub fn all(&self) -> Vec<&Link> { self.links.values().collect() @@ -300,14 +410,16 @@ impl LinkStorage { .collect() } - /// Searches for a link with the given source and target + /// Searches for a link with the given source and target. + /// + /// When several links share the pair, the lowest address wins, so the + /// result never depends on hash map iteration order. pub fn search(&self, source: u32, target: u32) -> Option { - for link in self.links.values() { - if link.source == source && link.target == target { - return Some(link.index); - } - } - None + self.links + .values() + .filter(|link| link.source == source && link.target == target) + .map(|link| link.index) + .min() } /// Gets or creates a link with the given source and target diff --git a/rust/src/link_storage_doublets.rs b/rust/src/link_storage_doublets.rs new file mode 100644 index 0000000..427c808 --- /dev/null +++ b/rust/src/link_storage_doublets.rs @@ -0,0 +1,274 @@ +//! Bridge between the CLI's [`LinkStorage`] and the upstream `doublets` traits. +//! +//! Implementing [`Links`] and [`Doublets`] for [`LinkStorage`] lets this crate +//! reuse the `doublets::decorators` layer instead of re-implementing uniqueness +//! resolution and cascading deletion by hand. It is the direct analogue of what +//! the C# implementation does in +//! `Foundation.Data.Doublets.Cli.NamedTypesDecorator.MakeLinks`: +//! +//! ```csharp +//! var links = new UnitedMemoryLinks(databaseFilename); +//! return links.DecorateWithAutomaticUniquenessAndUsagesResolution(); +//! ``` +//! +//! [`LinkStorage`] plays the `UnitedMemoryLinks` role — a plain store with no +//! policy of its own — and +//! [`DecoratorsExt::with_automatic_uniqueness_and_usages_resolution`](doublets::decorators::DecoratorsExt::with_automatic_uniqueness_and_usages_resolution) +//! supplies the policy. +//! +//! The impls are also part of the public API on purpose: any embedder can now +//! stack arbitrary upstream decorators (or its own) onto a [`LinkStorage`], and +//! pass one anywhere a `doublets::Doublets` is expected. + +use std::sync::OnceLock; + +use doublets::data::{Flow, LinksConstants, ReadHandler, WriteHandler}; +use doublets::{Doublets, Error, Link as DoubletsLink, Links}; + +use crate::link::Link; +use crate::link_storage::LinkStorage; + +/// The [`LinksConstants`] every [`LinkStorage`] reports. +/// +/// [`LinkStorage`] addresses links with plain `u32` values and has no external +/// reference range, so the default internal-only constants apply: `null` is +/// `0`, and the service values (`any`, `itself`, ...) live just below +/// [`u32::MAX`], outside the range the storage ever allocates. +pub fn link_storage_constants() -> &'static LinksConstants { + static CONSTANTS: OnceLock> = OnceLock::new(); + CONSTANTS.get_or_init(LinksConstants::new) +} + +fn as_doublets_link(link: &Link) -> DoubletsLink { + DoubletsLink::new(link.index, link.source, link.target) +} + +/// Reads one part out of a raw query slice, defaulting to `null` when the slice +/// is shorter — the same rule `doublets` uses internally. +fn part(query: &[u32], index: usize) -> u32 { + query.get(index).copied().unwrap_or(0) +} + +/// Every link, ordered by address. +/// +/// Mirrors `each_core(handler, &[])` in the upstream unit store, which walks +/// allocated addresses from `1` upwards. +fn all(storage: &LinkStorage) -> Vec { + let mut matched: Vec = storage.all().into_iter().copied().collect(); + matched.sort_by_key(|link| link.index); + matched +} + +/// The `(any, source, target)` case, reproducing the *index* semantics of the +/// upstream unit store rather than a plain scan. +/// +/// In `doublets` a link is only reachable through the `(source, target)` +/// lookups while it is attached to the source and target trees, and +/// [`mem::united::Store::update_links`] only attaches the parts that are not +/// `null`: +/// +/// ```rust,ignore +/// if place.source != T::from_byte(0) { unsafe { self.attach_source(index); } } +/// if place.target != T::from_byte(0) { unsafe { self.attach_target(index); } } +/// ``` +/// +/// A freshly created — or deliberately blanked — link therefore never answers a +/// `(source, target)` query, which is exactly what keeps +/// [`UniquenessResolver`](doublets::decorators::UniquenessResolver) from merging +/// all the not-yet-filled links with each other. Matching that rule here is what +/// makes the decorators behave the same on top of a [`LinkStorage`]. +fn by_pattern(storage: &LinkStorage, source: u32, target: u32) -> Vec { + let constants = link_storage_constants(); + let (any, null) = (constants.any, constants.null); + + match (source == any, target == any) { + (true, true) => all(storage), + // `targets.each_usages(target)`: the tree rooted at a null target is empty. + (true, false) if target == null => Vec::new(), + (true, false) => { + let mut matched: Vec = storage + .all() + .into_iter() + .filter(|link| link.target == target) + .copied() + .collect(); + matched.sort_by_key(|link| link.index); + matched + } + // `sources.each_usages(source)`: the tree rooted at a null source is empty. + (false, true) if source == null => Vec::new(), + (false, true) => { + let mut matched: Vec = storage + .all() + .into_iter() + .filter(|link| link.source == source) + .copied() + .collect(); + matched.sort_by_key(|link| link.index); + matched + } + (false, false) if source == null || target == null => Vec::new(), + // `sources.search(source, target)` yields at most one link; the lowest + // address wins so the answer never depends on hash map ordering. + (false, false) => storage + .all() + .into_iter() + .filter(|link| link.source == source && link.target == target) + .min_by_key(|link| link.index) + .copied() + .into_iter() + .collect(), + } +} + +/// Links matching `query`, ordered by address so that every traversal (and +/// therefore every cascade) is deterministic regardless of hash map ordering. +/// +/// This is a faithful port of `each_core` in the upstream unit store, including +/// its handling of one- and two-element queries and of the `any` constant. +/// Query shapes the raw interface does not define match nothing. +fn matching(storage: &LinkStorage, query: &[u32]) -> Vec { + let any = link_storage_constants().any; + + match *query { + [] => all(storage), + [index] if index == any => all(storage), + [index] => storage.get(index).copied().into_iter().collect(), + [index, value] if index == any && value == any => all(storage), + [index, value] if index == any => { + // Upstream unions the two usage trees without deduplicating, so a + // link that both starts and ends at `value` is visited twice. + let mut matched = by_pattern(storage, value, any); + matched.extend(by_pattern(storage, any, value)); + matched + } + [index, value] => storage + .get(index) + .filter(|link| value == any || link.source == value || link.target == value) + .copied() + .into_iter() + .collect(), + [index, source, target] if index == any => by_pattern(storage, source, target), + [index, source, target] => storage + .get(index) + .filter(|link| { + (source == any || link.source == source) && (target == any || link.target == target) + }) + .copied() + .into_iter() + .collect(), + _ => Vec::new(), + } +} + +impl Links for LinkStorage { + fn constants(&self) -> &LinksConstants { + link_storage_constants() + } + + fn count_links(&self, query: &[u32]) -> u32 { + matching(self, query).len() as u32 + } + + fn create_links( + &mut self, + _query: &[u32], + handler: WriteHandler<'_, u32>, + ) -> Result> { + let index = self.create(0, 0); + let created = Link::new(index, 0, 0); + Ok(handler(DoubletsLink::nothing(), as_doublets_link(&created))) + } + + fn each_links(&self, query: &[u32], handler: ReadHandler<'_, u32>) -> Flow { + for link in matching(self, query) { + if handler(as_doublets_link(&link)) == Flow::Break { + return Flow::Break; + } + } + Flow::Continue + } + + fn update_links( + &mut self, + query: &[u32], + change: &[u32], + handler: WriteHandler<'_, u32>, + ) -> Result> { + let index = part(query, 0); + let source = part(change, 1); + let target = part(change, 2); + let before = self + .update_raw(index, source, target) + .map_err(|_| Error::NotExists(index))?; + let after = Link::new(index, source, target); + Ok(handler(as_doublets_link(&before), as_doublets_link(&after))) + } + + fn delete_links( + &mut self, + query: &[u32], + handler: WriteHandler<'_, u32>, + ) -> Result> { + let index = part(query, 0); + let before = self + .delete_raw(index) + .map_err(|_| Error::NotExists(index))?; + Ok(handler(as_doublets_link(&before), DoubletsLink::nothing())) + } +} + +impl Doublets for LinkStorage { + fn get_link(&self, index: u32) -> Option> { + self.get(index).map(as_doublets_link) + } +} + +/// `doublets` ships no blanket implementation for references, so decorating a +/// borrowed store (instead of moving it into the decorator) needs this +/// forwarding impl. It is what lets [`LinkStorage`] decorate itself for the +/// duration of a single operation. +impl Links for &mut LinkStorage { + fn constants(&self) -> &LinksConstants { + (**self).constants() + } + + fn count_links(&self, query: &[u32]) -> u32 { + (**self).count_links(query) + } + + fn create_links( + &mut self, + query: &[u32], + handler: WriteHandler<'_, u32>, + ) -> Result> { + (**self).create_links(query, handler) + } + + fn each_links(&self, query: &[u32], handler: ReadHandler<'_, u32>) -> Flow { + (**self).each_links(query, handler) + } + + fn update_links( + &mut self, + query: &[u32], + change: &[u32], + handler: WriteHandler<'_, u32>, + ) -> Result> { + (**self).update_links(query, change, handler) + } + + fn delete_links( + &mut self, + query: &[u32], + handler: WriteHandler<'_, u32>, + ) -> Result> { + (**self).delete_links(query, handler) + } +} + +impl Doublets for &mut LinkStorage { + fn get_link(&self, index: u32) -> Option> { + (**self).get_link(index) + } +} diff --git a/rust/src/named_types.rs b/rust/src/named_types.rs index a957e19..ba0f8f6 100644 --- a/rust/src/named_types.rs +++ b/rust/src/named_types.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use crate::link::Link; -use crate::link_storage::LinkStorage; +use crate::link_storage::{ChangeObserver, LinkStorage}; use crate::named_links::NamedLinks; use crate::pinned_types::{PinnedTypesAccess, PinnedTypesDecorator}; @@ -138,13 +138,75 @@ impl NamedTypesDecorator { } pub fn update(&mut self, id: u32, source: u32, target: u32) -> Result { - self.pinned_types_decorator.update(id, source, target) + self.update_observed(id, source, target, &mut |_, _| {}) + } + + /// [`Self::update`], reporting every change the decorator stack made. + /// + /// Resolving a duplicate doublet deletes links, and a deleted link must not + /// keep its name. This mirrors the C# `NamedTypesDecorator.Update`, which + /// drops the name of every link the write handler reports as deleted: + /// + /// ```csharp + /// if (before != null && after == null) + /// { + /// var deletedLinkIndex = _links.GetIndex(link: before); + /// RemoveName(deletedLinkIndex); + /// } + /// ``` + pub fn update_observed( + &mut self, + id: u32, + source: u32, + target: u32, + observer: ChangeObserver<'_>, + ) -> Result { + let mut deleted = Vec::new(); + let updated = self.pinned_types_decorator.update_observed( + id, + source, + target, + &mut |before, after| { + if after.is_null() && !before.is_null() { + deleted.push(before.index); + } + observer(before, after); + }, + )?; + self.remove_names(&deleted)?; + Ok(updated) } pub fn delete(&mut self, id: u32) -> Result { - let deleted = self.pinned_types_decorator.delete(id)?; - self.remove_name(id)?; - Ok(deleted) + self.delete_observed(id, &mut |_, _| {}) + } + + /// [`Self::delete`], reporting every change the decorator stack made. + /// + /// A cascading delete removes every link that still referenced `id`; each of + /// them loses its name too, exactly as C# `NamedTypesDecorator.Delete` does. + pub fn delete_observed(&mut self, id: u32, observer: ChangeObserver<'_>) -> Result { + let mut deleted = Vec::new(); + let removed = self + .pinned_types_decorator + .delete_observed(id, &mut |before, after| { + if after.is_null() && !before.is_null() { + deleted.push(before.index); + } + observer(before, after); + })?; + if !deleted.contains(&id) { + deleted.push(id); + } + self.remove_names(&deleted)?; + Ok(removed) + } + + fn remove_names(&mut self, indexes: &[u32]) -> Result<()> { + for index in indexes { + self.remove_name(*index)?; + } + Ok(()) } pub fn all(&self) -> Vec<&Link> { diff --git a/rust/src/pinned_types.rs b/rust/src/pinned_types.rs index 12dc6f6..3d0a554 100644 --- a/rust/src/pinned_types.rs +++ b/rust/src/pinned_types.rs @@ -4,7 +4,7 @@ use anyhow::{bail, Result}; use std::path::Path; use crate::link::Link; -use crate::link_storage::LinkStorage; +use crate::link_storage::{ChangeObserver, LinkStorage}; pub trait PinnedTypesAccess { fn pinned_types(&mut self, count: usize) -> Result>; @@ -122,10 +122,30 @@ impl PinnedTypesDecorator { self.links.update(id, source, target) } + /// [`Self::update`], reporting every change the decorator stack made. + /// + /// See [`LinkStorage::update_observed`](crate::LinkStorage::update_observed). + pub fn update_observed( + &mut self, + id: u32, + source: u32, + target: u32, + observer: ChangeObserver<'_>, + ) -> Result { + self.links.update_observed(id, source, target, observer) + } + pub fn delete(&mut self, id: u32) -> Result { self.links.delete(id) } + /// [`Self::delete`], reporting every change the decorator stack made. + /// + /// See [`LinkStorage::delete_observed`](crate::LinkStorage::delete_observed). + pub fn delete_observed(&mut self, id: u32, observer: ChangeObserver<'_>) -> Result { + self.links.delete_observed(id, observer) + } + pub fn all(&self) -> Vec<&Link> { self.links.all() } diff --git a/rust/src/query_processor.rs b/rust/src/query_processor.rs index 0b62b88..fae78c5 100644 --- a/rust/src/query_processor.rs +++ b/rust/src/query_processor.rs @@ -213,17 +213,24 @@ impl QueryProcessor { return Ok(changes_list); } + let mut all_planned_operations = Vec::new(); for solution in &solutions { let restriction_links = self.resolve_patterns(storage, &restriction_patterns, solution, false)?; let substitution_links = self.resolve_patterns(storage, &substitution_patterns, solution, true)?; - let operations = self.determine_operations(&restriction_links, &substitution_links); - for (before, after) in operations { - self.apply_operation(storage, before, after, &mut changes_list)?; - } + all_planned_operations + .extend(self.determine_operations(&restriction_links, &substitution_links)); + } + + let intended_final_states = Self::intended_final_states(&all_planned_operations); + + for (before, after) in all_planned_operations { + self.apply_operation(storage, before, after, &mut changes_list)?; } + self.restore_unexpected_deletions(storage, &intended_final_states, &mut changes_list)?; + storage.save()?; // Simplify changes @@ -616,7 +623,13 @@ impl QueryProcessor { if let Some(name) = &after.name { storage.set_name(before.index, name)?; } - let after_link = storage.get_link(before.index).unwrap(); + // The update can be resolved into a merge, which deletes + // `before.index`; report the state the query asked for and + // let `restore_unexpected_deletions` put the link back, + // exactly as the C# processor does. + let after_link = storage + .get_link(before.index) + .unwrap_or_else(|| Link::new(before.index, after.source, after.target)); changes.push((Some(before_link), Some(after_link))); } else { self.apply_operation(storage, Some(before), None, changes)?; @@ -629,6 +642,74 @@ impl QueryProcessor { Ok(()) } + /// Final state every planned operation asks for, keyed by link address + /// and kept in the order the operations were planned. + /// + /// `None` marks a link the query deliberately deletes, so a cascade that + /// removes it is expected rather than a side effect. Mirrors the + /// `intendedFinalStates` dictionary the C# processor builds before + /// applying its planned operations. + fn intended_final_states( + operations: &[(Option, Option)], + ) -> Vec<(u32, Option)> { + let mut states: Vec<(u32, Option)> = Vec::new(); + let mut set = |index: u32, state: Option| match states + .iter_mut() + .find(|(existing, _)| *existing == index) + { + Some(entry) => entry.1 = state, + None => states.push((index, state)), + }; + for (before, after) in operations { + match (before, after) { + (_, Some(after)) if Self::is_normal_index(after.index) => { + set(after.index, Some(after.clone())) + } + (Some(before), None) if Self::is_normal_index(before.index) => { + set(before.index, None) + } + _ => {} + } + } + states + } + + /// Recreates links that a resolved write removed as a side effect. + /// + /// Mirrors `RestoreUnexpectedLinkDeletions` in the C# processor. The + /// uniqueness resolver merges a link into an existing duplicate by + /// deleting it, and the usages resolver cascades through the links that + /// reference it. When the query itself asked for such a link to exist, + /// the deletion is a side effect of the resolution order and has to be + /// undone — otherwise a query like + /// `((($index: $source $target)) (($index: $target $source)))` would lose + /// half of the links it swaps, because the first swap temporarily + /// duplicates a link that the second swap would have made unique again. + fn restore_unexpected_deletions( + &self, + storage: &mut impl NamedTypeLinks, + intended_final_states: &[(u32, Option)], + changes: &mut Vec<(Option, Option)>, + ) -> Result<()> { + for (index, intended) in intended_final_states { + let Some(intended) = intended else { + self.trace_msg(&format!( + "[RestoreUnexpectedLinkDeletions] Link {index} was intended-deletion => skip restore." + )); + continue; + }; + if storage.exists(*index) { + continue; + } + self.trace_msg(&format!( + "[RestoreUnexpectedLinkDeletions] Recreating link {index} => was unexpected deletion." + )); + let restored = self.create_or_update_resolved_link(storage, intended)?; + changes.push((None, Some(restored))); + } + Ok(()) + } + fn create_or_update_resolved_link( &self, storage: &mut impl NamedTypeLinks, @@ -648,7 +729,9 @@ impl QueryProcessor { storage.set_name(id, name)?; } - Ok(storage.get_link(id).unwrap()) + Ok(storage + .get_link(id) + .unwrap_or_else(|| Link::new(id, definition.source, definition.target))) } fn links_matching_definition( diff --git a/rust/src/storage/decorator_impls.rs b/rust/src/storage/decorator_impls.rs index f1b2a33..cd09018 100644 --- a/rust/src/storage/decorator_impls.rs +++ b/rust/src/storage/decorator_impls.rs @@ -62,6 +62,25 @@ macro_rules! impl_in_memory_links_storage { self.delete(index).map_err(storage_error) } + fn update_link_observed( + &mut self, + index: u32, + source: u32, + target: u32, + observer: &mut dyn FnMut(GenericLink, GenericLink), + ) -> Result, LinkError> { + self.update_observed(index, source, target, observer) + .map_err(storage_error) + } + + fn delete_link_observed( + &mut self, + index: u32, + observer: &mut dyn FnMut(GenericLink, GenericLink), + ) -> Result, LinkError> { + self.delete_observed(index, observer).map_err(storage_error) + } + fn all_links(&self) -> Vec> { self.all().into_iter().copied().collect() } diff --git a/rust/src/storage/traits.rs b/rust/src/storage/traits.rs index fd31372..598e49e 100644 --- a/rust/src/storage/traits.rs +++ b/rust/src/storage/traits.rs @@ -80,6 +80,45 @@ pub trait LinksStorage { /// Deletes `index`, returning the link that was removed. fn delete_link(&mut self, index: T) -> Result, LinkError>; + /// [`Self::update_link`], reporting every `(before, after)` change it made. + /// + /// A store that resolves duplicate doublets turns one write into a cascade + /// of changes, and the transactions layer has to log each of them or a + /// rollback cannot restore what the write actually did. This is the + /// equivalent of the `WriteHandler` the C# decorators forward to. + /// + /// The default implementation reports the single change a store without any + /// policy of its own makes, so implementors only override it when they + /// really can cascade. + fn update_link_observed( + &mut self, + index: T, + source: T, + target: T, + observer: &mut dyn FnMut(GenericLink, GenericLink), + ) -> Result, LinkError> { + let previous = self.update_link(index, source, target)?; + let after = self + .get_link(index) + .unwrap_or_else(|| GenericLink::new(index, source, target)); + observer(previous, after); + Ok(previous) + } + + /// [`Self::delete_link`], reporting every `(before, after)` change it made. + /// + /// See [`Self::update_link_observed`]; a cascading delete removes every link + /// that still referenced `index`. + fn delete_link_observed( + &mut self, + index: T, + observer: &mut dyn FnMut(GenericLink, GenericLink), + ) -> Result, LinkError> { + let deleted = self.delete_link(index)?; + observer(deleted, GenericLink::null()); + Ok(deleted) + } + /// Returns every link in the store. fn all_links(&self) -> Vec>; diff --git a/rust/src/transactions/mod.rs b/rust/src/transactions/mod.rs index 90b367d..444578a 100644 --- a/rust/src/transactions/mod.rs +++ b/rust/src/transactions/mod.rs @@ -99,6 +99,45 @@ struct PendingTransaction { started_ms: i64, } +/// One link address and the `(before, after)` states a single logical +/// write left it in, after collapsing repeated callbacks for that address. +type ObservedChange = (T, GenericDoubletLink, GenericDoubletLink); + +/// Folds one `(before, after)` callback into `observed`. +/// +/// Mirrors the handler `TransactionsDecorator.RunWrite` installs in the C# +/// implementation: repeated callbacks for the same address are collapsed into +/// a single change that keeps the *first* `before` (the state the rollback has +/// to restore) and the *last* `after` (the state the write ended at), and the +/// first-seen order of addresses is preserved so the transitions replay in the +/// order the storage produced them. +fn record_observed( + observed: &mut Vec>, + before: GenericLink, + after: GenericLink, +) { + let zero = T::from_byte(0); + let key = if before.index != zero { + before.index + } else { + after.index + }; + if key == zero { + return; + } + let before = GenericDoubletLink::from_link(&before); + let after = GenericDoubletLink::from_link(&after); + match observed.iter_mut().find(|(index, _, _)| *index == key) { + Some(entry) => { + if entry.1.index == zero { + entry.1 = before; + } + entry.2 = after; + } + None => observed.push((key, before, after)), + } +} + /// Snapshot of an open transaction (returned by [`GenericTransactionsDecorator::begin_transaction`]). #[derive(Debug, Clone)] pub struct TransactionHandle { @@ -288,7 +327,13 @@ where } let before = self.snapshot(id); let owns = self.ensure_open_transaction(); - let prev = match self.inner.update_link(id, source, target) { + let mut observed: Vec> = Vec::new(); + let outcome = self + .inner + .update_link_observed(id, source, target, &mut |before, after| { + record_observed(&mut observed, before, after) + }); + let prev = match outcome { Ok(prev) => prev, Err(err) => { if owns { @@ -297,12 +342,16 @@ where return Err(err); } }; - let after = self - .inner - .get_link(id) - .map(|link| GenericDoubletLink::from_link(&link)) - .unwrap_or_else(|| GenericDoubletLink::new(id, source, target)); - self.record_transition(TransitionKind::Update, before, after)?; + if observed.is_empty() { + let after = self + .inner + .get_link(id) + .map(|link| GenericDoubletLink::from_link(&link)) + .unwrap_or_else(|| GenericDoubletLink::new(id, source, target)); + self.record_transition(TransitionKind::Update, before, after)?; + } else { + self.record_observed_transitions(&observed)?; + } if owns { self.commit_current()?; } @@ -315,7 +364,11 @@ where } let before = self.snapshot(id); let owns = self.ensure_open_transaction(); - let deleted = match self.inner.delete_link(id) { + let mut observed: Vec> = Vec::new(); + let outcome = self.inner.delete_link_observed(id, &mut |before, after| { + record_observed(&mut observed, before, after) + }); + let deleted = match outcome { Ok(d) => d, Err(err) => { if owns { @@ -324,7 +377,11 @@ where return Err(err); } }; - self.record_transition(TransitionKind::Delete, before, GenericDoubletLink::empty())?; + if observed.is_empty() { + self.record_transition(TransitionKind::Delete, before, GenericDoubletLink::empty())?; + } else { + self.record_observed_transitions(&observed)?; + } if owns { self.commit_current()?; } @@ -390,6 +447,36 @@ where } } + /// Writes one transition per link a single logical write touched. + /// + /// A resolved write is not necessarily a single-link change: the + /// upstream uniqueness and usages decorators merge duplicates and + /// cascade through usages, so one `update`/`delete` call can rewrite + /// or remove several links. Each of them needs its own transition, + /// otherwise a rollback (or a version-control branch switch, which + /// replays the same transitions) cannot restore the links the + /// cascade touched. + /// + /// The kind is derived from the observed pair rather than taken from + /// the outer operation, because a cascade can delete a link during an + /// `update` — recording that as an `Update` would make the revert a + /// no-op, since the link no longer exists to be updated back. + fn record_observed_transitions( + &mut self, + observed: &[ObservedChange], + ) -> Result<(), LinkError> { + let zero = T::from_byte(0); + for (_, before, after) in observed { + let kind = match (before.index != zero, after.index != zero) { + (false, true) => TransitionKind::Create, + (true, false) => TransitionKind::Delete, + _ => TransitionKind::Update, + }; + self.record_transition(kind, *before, *after)?; + } + Ok(()) + } + fn record_transition( &mut self, kind: TransitionKind, diff --git a/rust/tests/doublets_resolver_semantics_tests.rs b/rust/tests/doublets_resolver_semantics_tests.rs new file mode 100644 index 0000000..77bc6e6 --- /dev/null +++ b/rust/tests/doublets_resolver_semantics_tests.rs @@ -0,0 +1,70 @@ +//! Pins down the `doublets::decorators` semantics this crate relies on. +//! +//! The CLI delegates uniqueness and cascade resolution to the upstream +//! decorator stack instead of reimplementing it, so a change in upstream +//! behaviour is a change in `clink` behaviour. These tests exercise the +//! upstream stack directly — no `link-cli` types are involved — so that such a +//! change is reported here, against `doublets`, rather than as a puzzling +//! failure somewhere in the query processor. + +use doublets::data::Flow; +use doublets::decorators::DecoratorsExt; +use doublets::mem::Global; +use doublets::unit::Store; +use doublets::Doublets; + +fn dump(links: &impl Doublets) -> Vec<(u32, u32, u32)> { + let mut all = Vec::new(); + links.each_links(&[], &mut |link| { + all.push((link.index, link.source, link.target)); + Flow::Continue + }); + all.sort_unstable(); + all +} + +/// An update that turns a link into a duplicate of an existing one merges the +/// two, and every usage of the merged-away link is **rebased onto the +/// survivor** rather than blanked. +/// +/// This is the behaviour the `update into duplicate` scenario in +/// `docs/case-studies/issue-100/evidence/cli-parity/run.sh` records as +/// diverging from C#: `Platform.Data.Doublets` 0.18.1 corrupts the usage +/// instead (see `../csharp-merge-usages` in the same folder). If this test +/// ever fails, doublets-rs has moved and the exemption needs revisiting. +#[test] +fn update_into_an_existing_pair_rebases_usages_onto_the_survivor() { + let mut links = Store::::new(Global::new()) + .unwrap() + .with_automatic_uniqueness_and_usages_resolution(); + + let one = links.create_link(1, 2).unwrap(); + // `two` uses `one` as its target, so the merge below has something to + // rebase. + let two = links.create_link(2, one).unwrap(); + assert_eq!(dump(&links), vec![(1, 1, 2), (2, 2, 1)]); + + // Updating `one` to `(2, 1)` makes it a duplicate of `two`. + links.update(one, 2, 1).unwrap(); + + // `one` is gone, and `two`'s dangling target now points at `two` itself + // — the address the merge kept — instead of at a deleted link. + assert_eq!(dump(&links), vec![(two, 2, two)]); +} + +/// Deleting a link deletes everything that referenced it, transitively. +#[test] +fn deleting_a_link_cascades_to_its_usages() { + let mut links = Store::::new(Global::new()) + .unwrap() + .with_automatic_uniqueness_and_usages_resolution(); + + let one = links.create_link(1, 1).unwrap(); + let two = links.create_link(2, 2).unwrap(); + let usage = links.create_link(one, two).unwrap(); + assert_eq!(dump(&links), vec![(1, 1, 1), (2, 2, 2), (usage, one, two)]); + + links.delete(two).unwrap(); + + assert_eq!(dump(&links), vec![(one, 1, 1)]); +} diff --git a/rust/tests/lino_database_input_tests.rs b/rust/tests/lino_database_input_tests.rs index 006eb9d..ddf7e66 100644 --- a/rust/tests/lino_database_input_tests.rs +++ b/rust/tests/lino_database_input_tests.rs @@ -63,7 +63,12 @@ fn import_lino_text_treats_out_of_range_numbers_as_names() -> Result<()> { let mut storage = LinkStorage::new(db_path, false)?; let out_of_range_number = u64::from(u32::MAX) + 1; - import_lino_text(&mut storage, &format!("(child: {out_of_range_number} 1)"))?; + // Target `2` rather than `1` for the same reason the C# counterpart + // (`LinoDatabaseInputTests.ImportText_TreatsOutOfRangeNumbersAsNames`) + // uses it: the out-of-range number becomes the point link `(1: 1 1)`, + // so `(child: 1)` would ask for a second `(1, 1)` link and + // uniqueness resolution would merge `child` into it. + import_lino_text(&mut storage, &format!("(child: {out_of_range_number} 2)"))?; let numeric_name = storage .get_by_name(&out_of_range_number.to_string()) diff --git a/rust/tests/query_processor_csharp_parity_tests.rs b/rust/tests/query_processor_csharp_parity_tests.rs index 69c113e..1f8541a 100644 --- a/rust/tests/query_processor_csharp_parity_tests.rs +++ b/rust/tests/query_processor_csharp_parity_tests.rs @@ -291,3 +291,91 @@ fn test_issue_20_substitute_full_point_with_unbound_parts_matches_csharp() -> Re Ok(()) }) } + +/// Deleting a link deletes the links that reference it, which is what the +/// upstream cascade resolvers do and what the `cascade delete of usage` +/// scenario in `docs/case-studies/issue-100/evidence/cli-parity/run.sh` +/// compares against C#. +#[test] +fn test_delete_cascades_to_usages_matches_csharp() -> Result<()> { + with_storage(|storage, processor| { + processor.process_query(storage, "(() ((1 1)))")?; + processor.process_query(storage, "(() ((2 2)))")?; + processor.process_query(storage, "(() ((1 2)))")?; + + processor.process_query(storage, "(((2: 2 2)) ())")?; + + assert_eq!(sorted_links(storage), vec![Link::new(1, 1, 1)]); + Ok(()) + }) +} + +/// The cascade is transitive and leaves untouched links alone. +#[test] +fn test_delete_cascade_chain_matches_csharp() -> Result<()> { + with_storage(|storage, processor| { + processor.process_query(storage, "(() ((1 1)))")?; + processor.process_query(storage, "(() ((2 2)))")?; + processor.process_query(storage, "(() ((1 2)))")?; + processor.process_query(storage, "(() ((3 3)))")?; + + processor.process_query(storage, "(((1: 1 1)) ())")?; + + assert_eq!(sorted_links(storage), vec![Link::new(2, 2, 2)]); + Ok(()) + }) +} + +/// An update that would duplicate an existing link merges into it instead of +/// creating a second copy, so the updated address disappears. +#[test] +fn test_update_into_existing_pair_merges_matches_csharp() -> Result<()> { + with_storage(|storage, processor| { + processor.process_query(storage, "(() ((1 1)))")?; + processor.process_query(storage, "(() ((2 2)))")?; + processor.process_query(storage, "(() ((1 2)))")?; + processor.process_query(storage, "(() ((2 1)))")?; + + processor.process_query(storage, "(((4: 2 1)) ((4: 1 2)))")?; + + assert_eq!( + sorted_links(storage), + vec![Link::new(1, 1, 1), Link::new(2, 2, 2), Link::new(3, 1, 2)] + ); + Ok(()) + }) +} + +/// Deleting a named link cascades through the links that use it, and the names +/// of the survivors are untouched. +#[test] +fn test_named_delete_cascades_to_usages_matches_csharp() -> Result<()> { + with_storage(|storage, processor| { + processor.process_query(storage, "(() ((a: a a)))")?; + processor.process_query(storage, "(() ((b: b b)))")?; + processor.process_query(storage, "(() ((a b)))")?; + + processor.process_query(storage, "(((a: a a)) ())")?; + + let b = name_id(storage, "b")?; + assert_eq!(sorted_links(storage), vec![Link::new(b, b, b)]); + Ok(()) + }) +} + +/// A single-link swap that does not collide with an existing pair keeps its +/// address, which is the plain-update path through the uniqueness resolver. +#[test] +fn test_swap_one_link_keeps_its_address_matches_csharp() -> Result<()> { + with_storage(|storage, processor| { + processor.process_query(storage, "(() ((1 1) (1 2)))")?; + + processor.process_query(storage, "(((2: 1 2)) ((2: 2 1)))")?; + + assert_eq!( + sorted_links(storage), + vec![Link::new(1, 1, 1), Link::new(2, 2, 1)] + ); + Ok(()) + }) +} From 2fcda429d33abf7c45572daa1ecd1943ff770e78 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 16:56:46 +0000 Subject: [PATCH 05/20] fix(rust): keep hybrid external references out of the doublets service constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A names database stores external references as Hybrid values — 0u32.wrapping_sub(address) — but LinkStorage reported the internal-only LinksConstants, where those values either land inside internal_range or sit exactly on a service constant: the external reference of link 4 is u32::MAX - 3, which the internal-only constants define as `any`. Two independent defects fell out of that: * LinkStorage::get_or_create called Doublets::search instead of its own inherent search. LinkStorage implements Doublets for &mut LinkStorage so a borrowed store can be decorated, and inside an inherent &mut self method the receiver's type is exactly &mut LinkStorage, which method resolution reaches before it derefs to the inherent impl. The trait search treats `any` as a wildcard, so naming a link 'UnicodeSymbol' silently reused the pinned type's own name pair. * UnicodeStringStorage read the first name holder out of a HashMap-backed query, so a name shared with a pinned type resolved differently depending on hash order. Report the hybrid constants, fully qualify the inherent calls, and order name holders by address. Adds regression tests for both. --- ...60829_170000_issue_100_hybrid_constants.md | 5 ++ rust/src/link_storage.rs | 24 ++++++-- rust/src/link_storage_doublets.rs | 23 +++++-- rust/src/unicode_string_storage.rs | 61 ++++++++++++++++--- rust/tests/link_storage_tests.rs | 43 +++++++++++++ rust/tests/named_types_decorator_tests.rs | 47 ++++++++++++++ 6 files changed, 183 insertions(+), 20 deletions(-) create mode 100644 rust/changelog.d/20260829_170000_issue_100_hybrid_constants.md diff --git a/rust/changelog.d/20260829_170000_issue_100_hybrid_constants.md b/rust/changelog.d/20260829_170000_issue_100_hybrid_constants.md new file mode 100644 index 0000000..beb14fe --- /dev/null +++ b/rust/changelog.d/20260829_170000_issue_100_hybrid_constants.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Fixed Rust name lookups losing links whose external reference collided with a `doublets` service constant. `LinkStorage` now reports the hybrid `LinksConstants`, which reserve the upper half of the address range for external references, and its inherent `get_or_create` no longer resolves to `Doublets::search` — which treats `any` as a wildcard — through the `Doublets` impl for `&mut LinkStorage`. Reserved pinned type names such as `Type` or `UnicodeSymbol` also resolve deterministically now that name holders are ordered by address instead of by hash map iteration order. diff --git a/rust/src/link_storage.rs b/rust/src/link_storage.rs index e8b7795..ee48a99 100644 --- a/rust/src/link_storage.rs +++ b/rust/src/link_storage.rs @@ -422,12 +422,23 @@ impl LinkStorage { .min() } - /// Gets or creates a link with the given source and target + /// Gets or creates a link with the given source and target. + /// + /// The two calls are fully qualified on purpose. [`LinkStorage`] also + /// implements the upstream [`Doublets`] trait — including *for + /// `&mut LinkStorage`*, so that a borrowed store can be decorated — and + /// inside an inherent `&mut self` method the receiver's type is exactly + /// `&mut LinkStorage`. Method resolution reaches the trait impl on the + /// reference before it derefs to the inherent impl, so a bare + /// `self.search(..)` silently resolves to [`Doublets::search`], which + /// interprets [`LinksConstants::any`](doublets::data::LinksConstants) as a + /// wildcard instead of matching it literally. Naming the inherent methods + /// keeps the exact-match semantics this function documents. pub fn get_or_create(&mut self, source: u32, target: u32) -> u32 { - if let Some(id) = self.search(source, target) { + if let Some(id) = Self::search(self, source, target) { id } else { - self.create(source, target) + Self::create(self, source, target) } } @@ -539,8 +550,11 @@ impl LinkStorage { id } else { // Create a self-referential link for the name - let id = self.create(0, 0); - self.update(id, id, id).ok(); + // Fully qualified for the same reason as in + // [`LinkStorage::get_or_create`]: the `Doublets` impl for + // `&mut LinkStorage` shadows the inherent `create`/`update`. + let id = Self::create(self, 0, 0); + Self::update(self, id, id, id).ok(); self.names.insert(id, name.to_string()); self.name_to_id.insert(name.to_string(), id); if self.trace { diff --git a/rust/src/link_storage_doublets.rs b/rust/src/link_storage_doublets.rs index 427c808..6647372 100644 --- a/rust/src/link_storage_doublets.rs +++ b/rust/src/link_storage_doublets.rs @@ -30,13 +30,26 @@ use crate::link_storage::LinkStorage; /// The [`LinksConstants`] every [`LinkStorage`] reports. /// -/// [`LinkStorage`] addresses links with plain `u32` values and has no external -/// reference range, so the default internal-only constants apply: `null` is -/// `0`, and the service values (`any`, `itself`, ...) live just below -/// [`u32::MAX`], outside the range the storage ever allocates. +/// These are the *hybrid* constants, the direct analogue of C#'s +/// `LinksConstants` default for `Hybrid` addresses: `null` +/// is `0`, internal addresses occupy the lower half of the `u32` range, and the +/// upper half is reserved for external references. +/// +/// The external half is not optional. A [`LinkStorage`] backing a names +/// database stores exactly the values +/// [`external_reference`](crate::hybrid_reference::external_reference) +/// produces — `0 - value`, i.e. the top of the `u32` range — both for named +/// links and for the raw character codes behind Unicode symbols. With the +/// internal-only constants those values fall either inside `internal_range`, +/// where +/// [`ensure_inner_reference_exists`](doublets::decorators) would demand a +/// stored link at that address, or exactly on a service constant: the external +/// reference of link `4` is `0u32.wrapping_sub(4)`, which the internal-only +/// constants define as `any`. Declaring the external range keeps every hybrid +/// reference outside both. pub fn link_storage_constants() -> &'static LinksConstants { static CONSTANTS: OnceLock> = OnceLock::new(); - CONSTANTS.get_or_init(LinksConstants::new) + CONSTANTS.get_or_init(LinksConstants::external) } fn as_doublets_link(link: &Link) -> DoubletsLink { diff --git a/rust/src/unicode_string_storage.rs b/rust/src/unicode_string_storage.rs index 22a4fd6..7702b53 100644 --- a/rust/src/unicode_string_storage.rs +++ b/rust/src/unicode_string_storage.rs @@ -274,8 +274,19 @@ impl<'a> UnicodeStringStorage<'a> { self.get_name(external_reference(link)) } + /// The external reference named `name`, if any. + /// + /// A name link can be shared by several holders, so this looks for the + /// external reference among *all* of them instead of only inspecting the + /// first: [`UnicodeStringStorage::new`] names the pinned types, so a user + /// link named `Type`, `Name`, `String`, `UnicodeSymbol`, + /// `UnicodeSequence` or `EmptyString` always shares its name link with a + /// pinned type. pub fn get_external_reference_by_name(&mut self, name: &str) -> Result> { - Ok(self.get_by_name(name)?.and_then(external_reference_value)) + Ok(self + .name_holders(name)? + .into_iter() + .find_map(external_reference_value)) } pub fn remove_name_by_external_reference(&mut self, external_reference_id: u32) -> Result<()> { @@ -288,9 +299,12 @@ impl<'a> UnicodeStringStorage<'a> { Ok(self.links.get_or_create(link, name_link)) } + /// The name of `link`, or `None` when it has none. + /// + /// Name pairs are visited by address so that a link carrying more than one + /// name always reports the same one. pub fn get_name(&self, link: u32) -> Result> { - for name_pair in self.links.query(None, Some(link), None) { - let name_candidate = name_pair.target; + for (_, name_candidate) in self.name_pairs_of(link) { let Some(candidate) = self.links.get(name_candidate) else { continue; }; @@ -301,26 +315,53 @@ impl<'a> UnicodeStringStorage<'a> { Ok(None) } + /// The lowest-addressed link named `name`, or `None` when the name is + /// unused. See [`Self::get_external_reference_by_name`] for the + /// external-reference variant. pub fn get_by_name(&mut self, name: &str) -> Result> { + Ok(self.name_holders(name)?.into_iter().next()) + } + + /// Every link that carries `name`, ordered by the address of the name pair + /// that binds it. + /// + /// Nothing stops two links from sharing one name link, and the pinned type + /// names created by [`UnicodeStringStorage::new`] make that the normal case + /// for a handful of reserved names. Reading only the first match out of + /// [`LinkStorage::query`] — which iterates a hash map — made the answer + /// depend on hash order; ordering by address makes it reproducible, exactly + /// like [`LinkStorage::search`] does for duplicate doublets. + fn name_holders(&mut self, name: &str) -> Result> { let name_sequence = self.create_string(name)?; let Some(name_link) = self.links.search(self.name_type, name_sequence) else { - return Ok(None); + return Ok(Vec::new()); }; - Ok(self + + let mut holders = self .links .query(None, None, Some(name_link)) .into_iter() - .map(|link| link.source) - .next()) + .map(|link| (link.index, link.source)) + .collect::>(); + holders.sort_unstable(); + Ok(holders.into_iter().map(|(_, source)| source).collect()) } - pub fn remove_name(&mut self, link: u32) -> Result<()> { - let name_pairs = self + /// The `(name pair address, name candidate)` pairs anchored at `link`, + /// ordered by address. + fn name_pairs_of(&self, link: u32) -> Vec<(u32, u32)> { + let mut pairs = self .links .query(None, Some(link), None) .into_iter() - .map(|link| (link.index, link.target)) + .map(|pair| (pair.index, pair.target)) .collect::>(); + pairs.sort_unstable(); + pairs + } + + pub fn remove_name(&mut self, link: u32) -> Result<()> { + let name_pairs = self.name_pairs_of(link); for (name_pair, name_candidate) in name_pairs { let Some(candidate) = self.links.get(name_candidate).copied() else { diff --git a/rust/tests/link_storage_tests.rs b/rust/tests/link_storage_tests.rs index 6bfcf8a..c5496eb 100644 --- a/rust/tests/link_storage_tests.rs +++ b/rust/tests/link_storage_tests.rs @@ -260,3 +260,46 @@ fn test_format_structure_renders_repeated_source_and_target_as_reference_on_righ Ok(()) } + +/// `LinkStorage::get_or_create` must match `(source, target)` literally. +/// +/// `LinkStorage` also implements the upstream `doublets` traits — including +/// for `&mut LinkStorage`, so a borrowed store can be decorated — and inside an +/// inherent `&mut self` method the receiver's type is exactly +/// `&mut LinkStorage`. Method resolution reaches the trait impl on the +/// reference before it derefs to the inherent impl, so a bare `self.search(..)` +/// resolves to `Doublets::search`, which treats `LinksConstants::any` as a +/// wildcard. That made `get_or_create(any, target)` hand back an unrelated +/// existing link instead of creating a new one. +#[test] +fn get_or_create_matches_service_constants_literally() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let mut storage = LinkStorage::new(temp_file.path().to_str().unwrap(), false)?; + + let existing = storage.create(0, 0); + storage.update(existing, 7, 42)?; + + // Every `doublets` service constant, plus the external references that + // encode to them, must be stored as an ordinary value. + for reserved in [ + u32::MAX, + u32::MAX - 1, + u32::MAX - 2, + u32::MAX - 3, + u32::MAX - 4, + ] { + let created = storage.get_or_create(reserved, 42); + assert_ne!( + existing, created, + "source {reserved} must not be treated as a wildcard" + ); + assert_eq!( + Some(&link_cli::Link::new(created, reserved, 42)), + storage.get(created) + ); + // A second call has to find the link it just created. + assert_eq!(created, storage.get_or_create(reserved, 42)); + } + + Ok(()) +} diff --git a/rust/tests/named_types_decorator_tests.rs b/rust/tests/named_types_decorator_tests.rs index 49a976d..7796789 100644 --- a/rust/tests/named_types_decorator_tests.rs +++ b/rust/tests/named_types_decorator_tests.rs @@ -155,3 +155,50 @@ fn decorator_can_be_built_from_existing_link_storages() -> Result<()> { Ok(()) } + +/// `UnicodeStringStorage` names its own pinned types — `Type`, `Name`, +/// `String`, `UnicodeSymbol`, `UnicodeSequence` and `EmptyString` — inside the +/// names database, so a user link that takes one of those names shares its name +/// link with a pinned type. +/// +/// Resolving the name then has to pick the user link out of several holders. +/// Before this was handled, `get_by_name` returned whichever holder the names +/// hash map happened to yield first, and the lookup failed (or succeeded) +/// depending on hash order. +#[test] +fn reserved_pinned_type_names_can_still_be_used_for_user_links() -> Result<()> { + let db_file = NamedTempFile::new()?; + let names_file = NamedTempFile::new()?; + let mut decorator = NamedTypesDecorator::with_names_database_path( + db_file.path().to_str().unwrap(), + names_file.path().to_str().unwrap(), + false, + )?; + + let reserved = [ + "Type", + "Name", + "String", + "UnicodeSymbol", + "UnicodeSequence", + "EmptyString", + ]; + let mut created = Vec::new(); + for name in reserved { + let link = decorator.create(0, 0); + decorator.set_name(link, name)?; + created.push((name, link)); + } + + // Every name still resolves after all the others were added. + for (name, link) in &created { + assert_eq!( + Some(*link), + decorator.get_by_name(name)?, + "the user link named {name} must win over the pinned type of the same name" + ); + assert_eq!(Some((*name).to_string()), decorator.get_name(*link)?); + } + + Ok(()) +} From 66faf106a73a889b7ee4bb335ed84332c7248b19 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 16:56:55 +0000 Subject: [PATCH 06/20] feat(rust): port the persistent transformation trigger decorator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PersistentTransformationDecorator applies stored `Always` and `Once` triggers after every write, mirroring the C# decorator down to the on-disk schema — (Always ((Condition ) (Substitution ))) — so the two implementations can read each other's trigger databases. Triggers live in a .triggers.links sidecar by default, or in the decorated database itself. NamedTypeLinks is not object safe, so the trigger store is driven by free generic helpers plus a small dispatch macro instead of a trait object. create/ensure_created/get_or_create are infallible in that trait, so a failure raised while applying triggers is parked in pending_error and surfaced by the next update, delete or save. --- ...20260829_171000_issue_100_rust_triggers.md | 5 + rust/src/lib.rs | 7 + rust/src/persistent_transformations.rs | 642 ++++++++++++++++++ .../tests/persistent_transformations_tests.rs | 223 ++++++ 4 files changed, 877 insertions(+) create mode 100644 rust/changelog.d/20260829_171000_issue_100_rust_triggers.md create mode 100644 rust/src/persistent_transformations.rs create mode 100644 rust/tests/persistent_transformations_tests.rs diff --git a/rust/changelog.d/20260829_171000_issue_100_rust_triggers.md b/rust/changelog.d/20260829_171000_issue_100_rust_triggers.md new file mode 100644 index 0000000..c96c90b --- /dev/null +++ b/rust/changelog.d/20260829_171000_issue_100_rust_triggers.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Added a Rust `PersistentTransformationDecorator` that ports the C# persistent transformation triggers, including the `Once`/`Always` trigger schema, the `.triggers.links` sidecar store and the embedded store. Triggers written by either implementation are readable by the other. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 0d053da..dddf544 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -14,6 +14,7 @@ //! - `storage` - Reusable storage traits and the doublets-backed store //! - `changes_simplifier` - Changes simplification //! - `query_processor` - LiNo query processing +//! - `persistent_transformations` - Persistent transformation triggers mod changes_simplifier; pub mod cli; @@ -29,6 +30,7 @@ mod named_links; mod named_type_links; mod named_types; mod parser; +pub mod persistent_transformations; mod pinned_types; mod query_options; mod query_processor; @@ -59,6 +61,11 @@ pub use named_links::NamedLinks; pub use named_type_links::NamedTypeLinks; pub use named_types::{NamedTypes, NamedTypesDecorator}; pub use parser::Parser; +pub use persistent_transformations::{ + make_triggers_database_filename, PersistentTransformation, PersistentTransformationDecorator, + PersistentTransformationKind, PersistentTransformationQuery, TriggerStore, + INTERNAL_NAME_PREFIX, +}; pub use pinned_types::{PinnedTypes, PinnedTypesAccess, PinnedTypesDecorator}; pub use query_options::QueryOptions; pub use query_processor::QueryProcessor; diff --git a/rust/src/persistent_transformations.rs b/rust/src/persistent_transformations.rs new file mode 100644 index 0000000..a62951b --- /dev/null +++ b/rust/src/persistent_transformations.rs @@ -0,0 +1,642 @@ +//! Persistent transformation triggers. +//! +//! A *trigger* is a stored LiNo substitution query that the CLI replays after +//! every write, turning a one-off transformation into a standing rule. This is +//! the Rust port of +//! `Foundation.Data.Doublets.Cli.PersistentTransformationDecorator`, and it +//! keeps the same on-disk shape so the two implementations can read each +//! other's trigger databases: +//! +//! ```text +//! (Always ((Condition ) (Substitution ))) +//! (Once ((Condition ) (Substitution ))) +//! ``` +//! +//! `Condition`, `Substitution`, `Type`, `Trigger`, `Once` and `Always` are +//! named points; the condition and substitution texts are named points too, +//! whose names carry the [`INTERNAL_NAME_PREFIX`] so they cannot collide with +//! user-visible names. +//! +//! # Where the triggers live +//! +//! [`TriggerStore`] decides that: [`TriggerStore::Sidecar`] keeps them in a +//! companion database (`.triggers.links` by default, see +//! [`make_triggers_database_filename`]), while [`TriggerStore::Embedded`] +//! stores them in the decorated database itself — the `--embed-triggers` mode. +//! +//! # Extension points +//! +//! The decorator is generic over any [`NamedTypeLinks`], so it composes with +//! the plain store, the transactions layer and the version-control layer +//! alike, and an embedder can stack it wherever it wants in its own chain. The +//! parsing ( +//! [`PersistentTransformationQuery`]), the stored form +//! ([`PersistentTransformation`]) and the store selection ([`TriggerStore`]) +//! are all public so custom CLIs can inspect, migrate or generate triggers +//! without going through this decorator at all. + +use std::collections::HashMap; +use std::fmt; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Result}; + +use crate::link::Link; +use crate::lino_link::LinoLink; +use crate::named_type_links::{escape_lino_reference, NamedTypeLinks}; +use crate::named_types::NamedTypesDecorator; +use crate::parser::Parser; +use crate::query_processor::QueryProcessor; + +/// Prefix of the internal names used for stored condition and substitution +/// texts. It keeps trigger bookkeeping distinguishable from user names even in +/// [`TriggerStore::Embedded`] mode, where both share one namespace. +pub const INTERNAL_NAME_PREFIX: &str = "__persistent_transformation:"; + +const MISSING_PARTS: &str = + "Persistent transformation query must contain a condition and a substitution."; + +/// How long a stored trigger lives. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PersistentTransformationKind { + /// Applied once, then removed as soon as an application produced changes. + Once, + /// Applied after every write, indefinitely. + Always, +} + +impl fmt::Display for PersistentTransformationKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::Once => "Once", + Self::Always => "Always", + }; + formatter.write_str(text) + } +} + +/// A trigger as it is stored in a links database. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistentTransformation { + /// Address of the `(kind payload)` link that roots this trigger. + pub root: u32, + pub kind: PersistentTransformationKind, + /// The condition (left) half of the substitution query. + pub condition: String, + /// The substitution (right) half of the substitution query. + pub substitution: String, +} + +impl PersistentTransformation { + /// The query that gets replayed after every write. + pub fn query(&self) -> String { + format!("({} {})", self.condition, self.substitution) + } +} + +/// A trigger query split into its condition and substitution halves. +/// +/// Both halves are re-formatted from the parse tree rather than kept as raw +/// input, so two spellings of the same query (`((1: 1 1)) ((1: 1 2))` and +/// `(((1: 1 1)) ((1: 1 2)))`) normalise to the same stored text and therefore +/// to the same trigger. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistentTransformationQuery { + pub condition: String, + pub substitution: String, +} + +impl PersistentTransformationQuery { + /// Parses `query` into its two halves. + /// + /// Both the wrapped form `((condition) (substitution))` and the bare form + /// `(condition) (substitution)` are accepted, matching the C# parser. + pub fn parse(query: &str) -> Result { + let parsed = Parser::new().parse(query)?; + let outer = parsed.first().ok_or_else(|| anyhow!(MISSING_PARTS))?; + + let (condition, substitution) = match outer.values.as_deref() { + Some(values) if values.len() >= 2 => (&values[0], &values[1]), + _ if parsed.len() >= 2 => (&parsed[0], &parsed[1]), + _ => return Err(anyhow!(MISSING_PARTS)), + }; + + Ok(Self { + condition: format_lino(condition), + substitution: format_lino(substitution), + }) + } + + /// The normalised `(condition substitution)` query text. + pub fn query(&self) -> String { + format!("({} {})", self.condition, self.substitution) + } +} + +/// Renders a parsed LiNo link back to source text. +/// +/// Mirrors `PersistentTransformationQuery.Format` in C#: a link without values +/// is just its (escaped) identifier, a link without an identifier is +/// `(values)`, and a link with both is `(id: values)`. +fn format_lino(link: &LinoLink) -> String { + let values = link.values.as_deref().unwrap_or(&[]); + let id = link.id.as_deref().unwrap_or_default(); + + if values.is_empty() { + return if id.is_empty() { + "()".to_string() + } else { + escape_lino_reference(id) + }; + } + + let rendered = values.iter().map(format_lino).collect::>().join(" "); + + if id.is_empty() { + format!("({rendered})") + } else { + format!("({}: {})", escape_lino_reference(id), rendered) + } +} + +/// Conventional sidecar filename for the trigger store: `.triggers.links`. +pub fn make_triggers_database_filename>(database_filename: P) -> PathBuf { + let path = database_filename.as_ref(); + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + let name = format!("{stem}.triggers.links"); + match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent.join(name), + _ => PathBuf::from(name), + } +} + +/// Where a [`PersistentTransformationDecorator`] keeps its triggers. +pub enum TriggerStore { + /// In the decorated database itself (`--embed-triggers`). + Embedded, + /// In a separate companion database (the default). + Sidecar(Box), +} + +impl TriggerStore { + /// Opens a sidecar store at `path`. + pub fn sidecar>(path: P, trace: bool) -> Result { + Ok(Self::Sidecar(Box::new(NamedTypesDecorator::new( + path, trace, + )?))) + } +} + +/// The schema points a trigger is built from. +/// +/// `Type` and `Trigger` are part of the stored schema too, but they only +/// classify the other points and are never dereferenced while reading or +/// writing a trigger, so they are not carried here. +#[derive(Debug, Clone, Copy)] +struct TriggerSchema { + once: u32, + always: u32, + condition: u32, + substitution: u32, +} + +const SCHEMA_NAMES: [&str; 6] = [ + "Type", + "Trigger", + "Once", + "Always", + "Condition", + "Substitution", +]; + +/// Creates the schema points and the links that relate them, and returns them. +fn ensure_schema(links: &mut L) -> Result { + let r#type = links.get_or_create_named("Type")?; + let trigger = links.get_or_create_named("Trigger")?; + let once = links.get_or_create_named("Once")?; + let always = links.get_or_create_named("Always")?; + let condition = links.get_or_create_named("Condition")?; + let substitution = links.get_or_create_named("Substitution")?; + + links.get_or_create(r#type, trigger); + links.get_or_create(trigger, once); + links.get_or_create(trigger, always); + links.get_or_create(r#type, condition); + links.get_or_create(r#type, substitution); + + Ok(TriggerSchema { + once, + always, + condition, + substitution, + }) +} + +/// Reads the schema without creating anything; `None` when any of the six +/// schema points is missing, i.e. when no trigger has ever been stored in +/// `links`. +fn try_get_schema(links: &mut L) -> Result> { + let mut ids = [0u32; 6]; + for (slot, name) in ids.iter_mut().zip(SCHEMA_NAMES) { + match links.get_by_name(name)? { + Some(id) => *slot = id, + None => return Ok(None), + } + } + + Ok(Some(TriggerSchema { + once: ids[2], + always: ids[3], + condition: ids[4], + substitution: ids[5], + })) +} + +/// Every well-formed trigger in `links`, ordered by root address. +fn triggers_in(links: &mut L) -> Result> { + let Some(schema) = try_get_schema(links)? else { + return Ok(Vec::new()); + }; + + let mut all = links.all_links(); + all.sort_by_key(|link| link.index); + let by_index: HashMap = all.iter().map(|link| (link.index, *link)).collect(); + + let mut triggers = Vec::new(); + for link in &all { + let kind = if link.source == schema.always { + PersistentTransformationKind::Always + } else if link.source == schema.once { + PersistentTransformationKind::Once + } else { + continue; + }; + + let Some(payload) = by_index.get(&link.target) else { + continue; + }; + let (Some(condition_record), Some(substitution_record)) = + (by_index.get(&payload.source), by_index.get(&payload.target)) + else { + continue; + }; + if condition_record.source != schema.condition + || substitution_record.source != schema.substitution + { + continue; + } + + let condition = links.get_name(condition_record.target)?; + let substitution = links.get_name(substitution_record.target)?; + let (Some(condition), Some(substitution)) = ( + decode_text_name(condition.as_deref(), "condition"), + decode_text_name(substitution.as_deref(), "substitution"), + ) else { + continue; + }; + + triggers.push(PersistentTransformation { + root: link.index, + kind, + condition, + substitution, + }); + } + + Ok(triggers) +} + +/// Writes `parsed` into `links` as a trigger of `kind`, returning its root. +/// +/// Every part is created through `get_or_create`, so storing the same trigger +/// twice is idempotent and yields the same root. +fn store_trigger_in( + links: &mut L, + kind: PersistentTransformationKind, + parsed: &PersistentTransformationQuery, +) -> Result { + let schema = ensure_schema(links)?; + let condition_text = links.get_or_create_named(&condition_text_name(&parsed.condition))?; + let substitution_text = + links.get_or_create_named(&substitution_text_name(&parsed.substitution))?; + let condition_record = links.get_or_create(schema.condition, condition_text); + let substitution_record = links.get_or_create(schema.substitution, substitution_text); + let payload = links.get_or_create(condition_record, substitution_record); + let trigger_type = match kind { + PersistentTransformationKind::Always => schema.always, + PersistentTransformationKind::Once => schema.once, + }; + Ok(links.get_or_create(trigger_type, payload)) +} + +/// Deletes the `(kind payload)` root link, leaving the shared schema and text +/// points in place — exactly like `DeleteTriggerRoot` in C#. +fn delete_trigger_root(links: &mut L, root: u32) -> Result { + if !links.exists(root) { + return Ok(false); + } + links.delete(root)?; + Ok(true) +} + +fn condition_text_name(condition: &str) -> String { + format!("{INTERNAL_NAME_PREFIX}condition:{condition}") +} + +fn substitution_text_name(substitution: &str) -> String { + format!("{INTERNAL_NAME_PREFIX}substitution:{substitution}") +} + +fn decode_text_name(name: Option<&str>, part: &str) -> Option { + let prefix = format!("{INTERNAL_NAME_PREFIX}{part}:"); + name?.strip_prefix(&prefix).map(str::to_string) +} + +/// Runs `$call` against whichever store holds the triggers. +/// +/// [`NamedTypeLinks`] has generic default methods and is therefore not object +/// safe, so the two stores cannot be unified behind a trait object; the macro +/// picks the branch instead. The `Embedded` arm borrows `links` while the +/// scrutinee borrows `triggers` — disjoint fields, which the borrow checker +/// accepts. +macro_rules! on_trigger_links { + ($self:expr, $call:ident($($arg:expr),* $(,)?)) => { + match $self.triggers { + TriggerStore::Sidecar(ref mut store) => $call(store.as_mut() $(, $arg)*), + TriggerStore::Embedded => $call(&mut $self.links $(, $arg)*), + } + }; +} + +/// Replays stored triggers after every write that goes through it. +/// +/// Wrap it around any [`NamedTypeLinks`] — the bare store, the transactions +/// decorator, the version-control decorator, or a custom one. +pub struct PersistentTransformationDecorator { + links: L, + triggers: TriggerStore, + trace: bool, + applying_triggers: bool, + suppress_triggers: bool, + auto_create_missing_references: bool, + /// First failure raised while applying triggers from an infallible write. + /// + /// [`NamedTypeLinks::create`], `ensure_created` and `get_or_create` cannot + /// report an error, so a failing trigger is parked here and surfaced by the + /// next fallible operation — at the latest by + /// [`save`](NamedTypeLinks::save), which the CLI always calls. + pending_error: Option, +} + +impl PersistentTransformationDecorator { + pub fn new(links: L, triggers: TriggerStore, trace: bool) -> Self { + Self { + links, + triggers, + trace, + applying_triggers: false, + suppress_triggers: false, + auto_create_missing_references: false, + pending_error: None, + } + } + + /// Keeps the triggers in the decorated database itself. + pub fn embedded(links: L, trace: bool) -> Self { + Self::new(links, TriggerStore::Embedded, trace) + } + + /// Keeps the triggers in `trigger_links`. + pub fn with_sidecar(links: L, trigger_links: NamedTypesDecorator, trace: bool) -> Self { + Self::new(links, TriggerStore::Sidecar(Box::new(trigger_links)), trace) + } + + /// Whether replayed triggers may create missing references as points. + pub fn with_auto_create_missing_references(mut self, enabled: bool) -> Self { + self.auto_create_missing_references = enabled; + self + } + + pub fn auto_create_missing_references(&self) -> bool { + self.auto_create_missing_references + } + + pub fn set_auto_create_missing_references(&mut self, enabled: bool) { + self.auto_create_missing_references = enabled; + } + + pub fn inner(&self) -> &L { + &self.links + } + + pub fn inner_mut(&mut self) -> &mut L { + &mut self.links + } + + pub fn trigger_store(&self) -> &TriggerStore { + &self.triggers + } + + pub fn trigger_store_mut(&mut self) -> &mut TriggerStore { + &mut self.triggers + } + + /// Gives the decorated links and the trigger store back. + pub fn into_parts(self) -> (L, TriggerStore) { + (self.links, self.triggers) + } + + /// Stores `query` as a trigger of `kind` and returns its root address. + pub fn store_trigger( + &mut self, + kind: PersistentTransformationKind, + query: &str, + ) -> Result { + let parsed = PersistentTransformationQuery::parse(query)?; + let root = self.without_trigger_application(|this| { + on_trigger_links!(this, store_trigger_in(kind, &parsed)) + })?; + self.trace_msg(&format!( + "Stored {kind} trigger #{root}: {}", + parsed.query() + )); + Ok(root) + } + + /// Removes every stored trigger whose query equals `query`, and returns how + /// many were removed. + pub fn remove_triggers(&mut self, query: &str) -> Result { + let parsed = PersistentTransformationQuery::parse(query)?; + self.without_trigger_application(|this| { + let matching: Vec = this + .triggers()? + .into_iter() + .filter(|trigger| { + trigger.condition == parsed.condition + && trigger.substitution == parsed.substitution + }) + .map(|trigger| trigger.root) + .collect(); + + for root in &matching { + on_trigger_links!(this, delete_trigger_root(*root))?; + this.trace_msg(&format!("Deleted trigger #{root}")); + } + + Ok(matching.len()) + }) + } + + /// Every stored trigger, ordered by root address. + pub fn triggers(&mut self) -> Result> { + on_trigger_links!(self, triggers_in()) + } + + /// Runs `action` with trigger application suppressed, restoring the + /// previous setting afterwards. This is what keeps trigger bookkeeping from + /// triggering itself. + fn without_trigger_application(&mut self, action: impl FnOnce(&mut Self) -> R) -> R { + let previous = self.suppress_triggers; + self.suppress_triggers = true; + let result = action(self); + self.suppress_triggers = previous; + result + } + + /// Records a trigger failure raised by an infallible write. + fn after_write(&mut self) { + if let Err(error) = self.apply_triggers_after_operation() { + if self.pending_error.is_none() { + self.pending_error = Some(error); + } + } + } + + /// Surfaces (and clears) a failure parked by [`after_write`]. + fn take_pending_error(&mut self) -> Result<()> { + match self.pending_error.take() { + Some(error) => Err(error), + None => Ok(()), + } + } + + fn apply_triggers_after_operation(&mut self) -> Result<()> { + if self.suppress_triggers || self.applying_triggers { + return Ok(()); + } + + let triggers = self.triggers()?; + if triggers.is_empty() { + return Ok(()); + } + + self.applying_triggers = true; + let outcome = self.apply_triggers(&triggers); + self.applying_triggers = false; + outcome + } + + fn apply_triggers(&mut self, triggers: &[PersistentTransformation]) -> Result<()> { + let processor = QueryProcessor::new(self.trace) + .with_auto_create_missing_references(self.auto_create_missing_references); + + for trigger in triggers { + let changes = processor.process_query(self, &trigger.query())?; + if changes.is_empty() || trigger.kind != PersistentTransformationKind::Once { + continue; + } + + let root = trigger.root; + self.without_trigger_application(|this| { + on_trigger_links!(this, delete_trigger_root(root)) + })?; + self.trace_msg(&format!("Deleted trigger #{root}")); + } + + Ok(()) + } + + fn trace_msg(&self, message: &str) { + if self.trace { + println!("[PersistentTransformation] {message}"); + } + } +} + +impl NamedTypeLinks for PersistentTransformationDecorator { + fn create(&mut self, source: u32, target: u32) -> u32 { + let index = self.links.create(source, target); + self.after_write(); + index + } + + fn ensure_created(&mut self, id: u32) -> u32 { + let index = self.links.ensure_created(id); + self.after_write(); + index + } + + fn get_link(&mut self, id: u32) -> Option { + self.links.get_link(id) + } + + fn exists(&mut self, id: u32) -> bool { + self.links.exists(id) + } + + fn update(&mut self, id: u32, source: u32, target: u32) -> Result { + let link = self.links.update(id, source, target)?; + self.apply_triggers_after_operation()?; + self.take_pending_error()?; + Ok(link) + } + + fn delete(&mut self, id: u32) -> Result { + let link = self.links.delete(id)?; + self.apply_triggers_after_operation()?; + self.take_pending_error()?; + Ok(link) + } + + fn all_links(&mut self) -> Vec { + self.links.all_links() + } + + fn search(&mut self, source: u32, target: u32) -> Option { + self.links.search(source, target) + } + + fn get_or_create(&mut self, source: u32, target: u32) -> u32 { + let index = self.links.get_or_create(source, target); + self.after_write(); + index + } + + fn get_name(&mut self, id: u32) -> Result> { + self.links.get_name(id) + } + + fn set_name(&mut self, id: u32, name: &str) -> Result { + self.links.set_name(id, name) + } + + fn get_by_name(&mut self, name: &str) -> Result> { + self.links.get_by_name(name) + } + + fn remove_name(&mut self, id: u32) -> Result<()> { + self.links.remove_name(id) + } + + fn save(&mut self) -> Result<()> { + self.take_pending_error()?; + self.links.save()?; + if let TriggerStore::Sidecar(store) = &mut self.triggers { + store.save()?; + } + Ok(()) + } +} diff --git a/rust/tests/persistent_transformations_tests.rs b/rust/tests/persistent_transformations_tests.rs new file mode 100644 index 0000000..cdd6ea1 --- /dev/null +++ b/rust/tests/persistent_transformations_tests.rs @@ -0,0 +1,223 @@ +//! Rust counterpart of +//! `csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs`. +//! +//! The two implementations share the on-disk trigger schema, so these tests +//! assert the same observable behaviour as the C# ones: an `Always` trigger +//! keeps firing, a `Once` trigger removes itself after it first changed +//! something, and `--never` removes matching triggers. + +use anyhow::Result; +use link_cli::{ + make_triggers_database_filename, Link, NamedTypeLinks, NamedTypesDecorator, + PersistentTransformationDecorator, PersistentTransformationKind, PersistentTransformationQuery, + QueryProcessor, TriggerStore, +}; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +/// `(1: 1 1)` becomes `(1: 1 2)`. +const REWRITE_TARGET: &str = "(((1: 1 1)) ((1: 1 2)))"; +/// The inverse of [`REWRITE_TARGET`]. +const RESTORE_TARGET: &str = "(((1: 1 2)) ((1: 1 1)))"; +/// Creates `(1: 1 1)`. +const CREATE_SELF_LINK: &str = "(() ((1: 1 1)))"; + +fn database_path(directory: &TempDir) -> PathBuf { + directory.path().join("db.links") +} + +/// A decorator with its triggers in the conventional sidecar database. +fn sidecar_decorator( + database: &Path, +) -> Result> { + let links = NamedTypesDecorator::new(database, false)?; + let triggers = TriggerStore::sidecar(make_triggers_database_filename(database), false)?; + Ok( + PersistentTransformationDecorator::new(links, triggers, false) + .with_auto_create_missing_references(true), + ) +} + +fn process( + decorator: &mut PersistentTransformationDecorator, + query: &str, +) -> Result<()> { + QueryProcessor::new(false) + .with_auto_create_missing_references(true) + .process_query(decorator, query)?; + Ok(()) +} + +#[test] +fn always_trigger_is_stored_in_links_and_applied_after_write() -> Result<()> { + let directory = TempDir::new()?; + let mut decorator = sidecar_decorator(&database_path(&directory))?; + + let root = decorator.store_trigger(PersistentTransformationKind::Always, REWRITE_TARGET)?; + assert_ne!(0, root); + + let stored = decorator.triggers()?; + assert_eq!(1, stored.len()); + assert_eq!(PersistentTransformationKind::Always, stored[0].kind); + assert_eq!("((1: 1 1))", stored[0].condition); + assert_eq!("((1: 1 2))", stored[0].substitution); + + process(&mut decorator, CREATE_SELF_LINK)?; + + assert_eq!(Some(Link::new(1, 1, 2)), decorator.get_link(1)); + Ok(()) +} + +#[test] +fn once_trigger_deletes_itself_after_first_match() -> Result<()> { + let directory = TempDir::new()?; + let mut decorator = sidecar_decorator(&database_path(&directory))?; + + decorator.store_trigger(PersistentTransformationKind::Once, REWRITE_TARGET)?; + process(&mut decorator, CREATE_SELF_LINK)?; + + assert!( + decorator.triggers()?.is_empty(), + "a Once trigger must remove itself once it produced changes" + ); + + // The trigger is gone, so restoring `(1: 1 1)` is no longer undone. + process(&mut decorator, RESTORE_TARGET)?; + assert_eq!(Some(Link::new(1, 1, 1)), decorator.get_link(1)); + Ok(()) +} + +#[test] +fn always_trigger_keeps_firing() -> Result<()> { + let directory = TempDir::new()?; + let mut decorator = sidecar_decorator(&database_path(&directory))?; + + decorator.store_trigger(PersistentTransformationKind::Always, REWRITE_TARGET)?; + process(&mut decorator, CREATE_SELF_LINK)?; + assert_eq!(Some(Link::new(1, 1, 2)), decorator.get_link(1)); + + // Unlike a `Once` trigger, this one survives and undoes the restore. + process(&mut decorator, RESTORE_TARGET)?; + assert_eq!(Some(Link::new(1, 1, 2)), decorator.get_link(1)); + assert_eq!(1, decorator.triggers()?.len()); + Ok(()) +} + +#[test] +fn never_removes_matching_stored_trigger() -> Result<()> { + let directory = TempDir::new()?; + let mut decorator = sidecar_decorator(&database_path(&directory))?; + + decorator.store_trigger(PersistentTransformationKind::Always, REWRITE_TARGET)?; + assert_eq!(1, decorator.remove_triggers(REWRITE_TARGET)?); + assert!(decorator.triggers()?.is_empty()); + + // Removing again is a no-op rather than an error. + assert_eq!(0, decorator.remove_triggers(REWRITE_TARGET)?); + Ok(()) +} + +#[test] +fn storing_the_same_trigger_twice_is_idempotent() -> Result<()> { + let directory = TempDir::new()?; + let mut decorator = sidecar_decorator(&database_path(&directory))?; + + let first = decorator.store_trigger(PersistentTransformationKind::Always, REWRITE_TARGET)?; + // The bare spelling parses to the same condition and substitution. + let second = decorator.store_trigger( + PersistentTransformationKind::Always, + "((1: 1 1)) ((1: 1 2))", + )?; + + assert_eq!(first, second); + assert_eq!(1, decorator.triggers()?.len()); + Ok(()) +} + +#[test] +fn sidecar_store_keeps_the_main_database_free_of_trigger_bookkeeping() -> Result<()> { + let directory = TempDir::new()?; + let database = database_path(&directory); + let mut decorator = sidecar_decorator(&database)?; + + decorator.store_trigger(PersistentTransformationKind::Always, REWRITE_TARGET)?; + assert!( + decorator.all_links().is_empty(), + "trigger bookkeeping must not leak into the decorated database" + ); + + decorator.save()?; + assert!(make_triggers_database_filename(&database).exists()); + Ok(()) +} + +#[test] +fn embedded_store_keeps_triggers_in_the_decorated_database() -> Result<()> { + let directory = TempDir::new()?; + let database = database_path(&directory); + let links = NamedTypesDecorator::new(&database, false)?; + let mut decorator = PersistentTransformationDecorator::embedded(links, false) + .with_auto_create_missing_references(true); + + let root = decorator.store_trigger(PersistentTransformationKind::Always, REWRITE_TARGET)?; + assert!(decorator.exists(root)); + assert_eq!(1, decorator.triggers()?.len()); + + decorator.save()?; + assert!( + !make_triggers_database_filename(&database).exists(), + "--embed-triggers must not create a sidecar database" + ); + Ok(()) +} + +#[test] +fn triggers_survive_a_reopen() -> Result<()> { + let directory = TempDir::new()?; + let database = database_path(&directory); + + let mut decorator = sidecar_decorator(&database)?; + decorator.store_trigger(PersistentTransformationKind::Always, REWRITE_TARGET)?; + decorator.save()?; + drop(decorator); + + let mut reopened = sidecar_decorator(&database)?; + let stored = reopened.triggers()?; + assert_eq!(1, stored.len()); + assert_eq!(REWRITE_TARGET, stored[0].query()); + Ok(()) +} + +#[test] +fn query_parsing_accepts_the_wrapped_and_the_bare_form() -> Result<()> { + let wrapped = PersistentTransformationQuery::parse(REWRITE_TARGET)?; + let bare = PersistentTransformationQuery::parse("((1: 1 1)) ((1: 1 2))")?; + + assert_eq!(wrapped, bare); + assert_eq!("((1: 1 1))", wrapped.condition); + assert_eq!("((1: 1 2))", wrapped.substitution); + assert_eq!(REWRITE_TARGET, wrapped.query()); + Ok(()) +} + +#[test] +fn query_parsing_rejects_an_incomplete_query() { + let error = PersistentTransformationQuery::parse("((1: 1 1))") + .expect_err("a query without a substitution must be rejected"); + assert!( + format!("{error}").contains("condition and a substitution"), + "unexpected error: {error}" + ); +} + +#[test] +fn triggers_database_filename_follows_the_names_database_convention() { + assert_eq!( + PathBuf::from("/tmp/example.triggers.links"), + make_triggers_database_filename("/tmp/example.links") + ); + assert_eq!( + PathBuf::from("example.triggers.links"), + make_triggers_database_filename("example.links") + ); +} From dbdd1938740f72da2a1c810a1e82b7c25b4ea4b2 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 17:53:27 +0000 Subject: [PATCH 07/20] feat(rust): expose the persistent transformation triggers on the CLI The trigger decorator was ported in the previous commit but nothing reached it: `--always`, `--once`, `--never`, `--triggers`, `--triggers-file` and `--embed-triggers` were accepted by the C# tool only, so the two CLIs did not offer the same feature set. `--always`/`--once`/`--never` store or remove a trigger, `--triggers` lists them, and `--triggers-file`/`--embed-triggers` decide where they live, exactly as `Program.cs` resolves them: a sidecar `.triggers.links` by default, the main database when embedding is asked for. --- rust/src/cli.rs | 89 +++++++++++++++++++++++++++++++++ rust/src/main.rs | 127 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 206 insertions(+), 10 deletions(-) diff --git a/rust/src/cli.rs b/rust/src/cli.rs index fa27abe..ffa028f 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -19,6 +19,12 @@ pub struct Cli { pub after: bool, pub lino_input: Option, pub lino_output: Option, + pub always: bool, + pub once: bool, + pub never: bool, + pub triggers: bool, + pub triggers_file: Option, + pub embed_triggers: bool, pub transactions: bool, pub transactions_file: Option, pub commit_mode: Option, @@ -48,6 +54,12 @@ impl Default for Cli { after: false, lino_input: None, lino_output: None, + always: false, + once: false, + never: false, + triggers: false, + triggers_file: None, + embed_triggers: false, transactions: false, transactions_file: None, commit_mode: None, @@ -83,6 +95,26 @@ impl Cli { || self.vc_requested() } + /// True when a trigger command — `--always`, `--once` or `--never` — was + /// passed. Exactly one of them may be used at a time. + pub fn trigger_command_count(&self) -> usize { + usize::from(self.always) + usize::from(self.once) + usize::from(self.never) + } + + /// True when any flag in the persistent transformation family was passed. + /// + /// Mirrors `persistentTransformationsEnabled` in the C# tool, minus the + /// "the triggers file already exists" clause, which needs the resolved + /// path and therefore lives next to it in `main`. + pub fn persistent_transformations_requested(&self) -> bool { + self.always + || self.once + || self.never + || self.triggers + || self.embed_triggers + || self.triggers_file.is_some() + } + /// True when any flag in the version-control decorator family was passed. pub fn vc_requested(&self) -> bool { self.vc @@ -155,6 +187,30 @@ impl Cli { cli.lino_input = Some(value.to_string()); continue; } + if let Some(value) = inline_value(&arg, &["--always"]) { + cli.always = parse_bool("--always", value)?; + continue; + } + if let Some(value) = inline_value(&arg, &["--once"]) { + cli.once = parse_bool("--once", value)?; + continue; + } + if let Some(value) = inline_value(&arg, &["--never"]) { + cli.never = parse_bool("--never", value)?; + continue; + } + if let Some(value) = inline_value(&arg, &["--triggers"]) { + cli.triggers = parse_bool("--triggers", value)?; + continue; + } + if let Some(value) = inline_value(&arg, &["--triggers-file"]) { + cli.triggers_file = Some(value.to_string()); + continue; + } + if let Some(value) = inline_value(&arg, &["--embed-triggers"]) { + cli.embed_triggers = parse_bool("--embed-triggers", value)?; + continue; + } if let Some(value) = inline_value(&arg, &["--transactions"]) { cli.transactions = parse_bool("--transactions", value)?; continue; @@ -242,6 +298,24 @@ impl Cli { "--in" | "--lino-input" | "--import" => { cli.lino_input = Some(next_value(&mut args, &arg)?); } + "--always" => { + cli.always = next_bool_value(&mut args, true)?; + } + "--once" => { + cli.once = next_bool_value(&mut args, true)?; + } + "--never" => { + cli.never = next_bool_value(&mut args, true)?; + } + "--triggers" => { + cli.triggers = next_bool_value(&mut args, true)?; + } + "--triggers-file" => { + cli.triggers_file = Some(next_value(&mut args, &arg)?); + } + "--embed-triggers" => { + cli.embed_triggers = next_bool_value(&mut args, true)?; + } "--transactions" => { cli.transactions = next_bool_value(&mut args, true)?; } @@ -331,6 +405,21 @@ impl Cli { " Read and import a LiNo file into the database\n", " --out , --lino-output , --export \n", " Write the complete database as a LiNo file\n", + " --always\n", + " Store the query as an always-on persistent transformation trigger\n", + " --once\n", + " Store the query as a persistent transformation trigger that deletes\n", + " itself after it fires\n", + " --never\n", + " Remove stored persistent transformation triggers matching the query\n", + " --triggers\n", + " Enable persistent transformation triggers for this command\n", + " --triggers-file \n", + " Path to the persistent transformation trigger links database\n", + " (default: .triggers.links)\n", + " --embed-triggers\n", + " Store persistent transformation triggers directly in the main links\n", + " database\n", " --transactions\n", " Enable the transactions layer (default log path: .transitions.links)\n", " --transactions-file \n", diff --git a/rust/src/main.rs b/rust/src/main.rs index c99e8a6..d045bfc 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -7,9 +7,11 @@ use anyhow::{anyhow, bail, Result}; use link_cli::cli::{Cli, CliCommand}; use link_cli::import_lino_file; use link_cli::{ - CommitMode, LogRetentionPolicy, NamedTypeLinks, NamedTypesDecorator, QueryProcessor, - TransactionsDecorator, VersionControlDecorator, + make_triggers_database_filename, CommitMode, LogRetentionPolicy, NamedTypeLinks, + NamedTypesDecorator, PersistentTransformationDecorator, PersistentTransformationKind, + QueryProcessor, TransactionsDecorator, TriggerStore, VersionControlDecorator, }; +use std::path::PathBuf; fn main() -> Result<()> { let cli = match Cli::parse()? { @@ -24,6 +26,10 @@ fn main() -> Result<()> { } }; + if cli.trigger_command_count() > 1 { + bail!("Only one of --always, --once, or --never can be used at a time."); + } + let vc_requested = cli.vc_requested(); let transactions_requested = cli.transactions_requested(); @@ -57,12 +63,95 @@ fn parse_retention(raw: Option<&str>) -> Result { } fn run_bare(cli: &Cli) -> Result<()> { - let mut storage = NamedTypesDecorator::new(&cli.db, cli.trace)?; - run_query_pipeline(cli, &mut storage)?; + let storage = NamedTypesDecorator::new(&cli.db, cli.trace)?; + finish(cli, storage) +} + +/// Resolved path of the trigger sidecar store: `--triggers-file` when given, +/// `.triggers.links` otherwise. +fn triggers_path(cli: &Cli) -> PathBuf { + cli.triggers_file + .clone() + .map(PathBuf::from) + .unwrap_or_else(|| make_triggers_database_filename(&cli.db)) +} + +/// Mirrors `persistentTransformationsEnabled` in the C# tool: any explicit flag +/// turns the layer on, and so does an already existing trigger store, so that +/// triggers stored by an earlier invocation keep firing without repeating +/// `--triggers`. +fn persistent_transformations_enabled(cli: &Cli) -> bool { + cli.persistent_transformations_requested() || triggers_path(cli).exists() +} + +/// Runs the query pipeline over `storage`, wrapping it in a +/// [`PersistentTransformationDecorator`] first when triggers are enabled. +/// +/// The wrap happens here rather than in the three entry paths so that the +/// decorator always sits *outermost* — above the transactions and +/// version-control layers — exactly like in the C# tool, where every replayed +/// substitution is journalled like any other write. +fn finish(cli: &Cli, storage: S) -> Result<()> +where + S: NamedTypeLinks, +{ + if !persistent_transformations_enabled(cli) { + let mut storage = storage; + run_query_pipeline(cli, &mut storage)?; + storage.save()?; + return Ok(()); + } + + let trigger_store = if cli.embed_triggers { + TriggerStore::Embedded + } else { + TriggerStore::sidecar(triggers_path(cli), cli.trace)? + }; + let mut storage = PersistentTransformationDecorator::new(storage, trigger_store, cli.trace) + .with_auto_create_missing_references(cli.auto_create_missing_references); + + run_query_pipeline_with(cli, &mut storage, run_trigger_command)?; storage.save()?; Ok(()) } +/// Handles `--always`, `--once` and `--never`. +/// +/// Returns `true` when the command was terminal, i.e. the query was consumed as +/// a trigger definition and must not also be executed against the database. +fn run_trigger_command( + cli: &Cli, + storage: &mut PersistentTransformationDecorator, + query: Option<&str>, +) -> Result +where + S: NamedTypeLinks, +{ + if cli.trigger_command_count() == 0 { + return Ok(false); + } + + let query = query.filter(|query| !query.trim().is_empty()); + let Some(query) = query else { + bail!("--always, --once, and --never require a query."); + }; + + if cli.always || cli.once { + let kind = if cli.always { + PersistentTransformationKind::Always + } else { + PersistentTransformationKind::Once + }; + let root = storage.store_trigger(kind, query)?; + println!("{kind} persistent transformation trigger stored: {root}"); + } else { + let removed = storage.remove_triggers(query)?; + println!("Persistent transformation triggers removed: {removed}"); + } + + Ok(true) +} + fn run_with_transactions( cli: &Cli, commit_mode: CommitMode, @@ -103,9 +192,7 @@ fn run_with_transactions( return Ok(()); } - run_query_pipeline(cli, &mut tx)?; - tx.save()?; - Ok(()) + finish(cli, tx) } fn run_with_vc( @@ -225,9 +312,7 @@ fn run_with_vc( return Ok(()); } - run_query_pipeline(cli, &mut vc)?; - vc.save()?; - Ok(()) + finish(cli, vc) } fn resolve_sequence(vc: &VersionControlDecorator, point: &str) -> Option { @@ -244,6 +329,21 @@ fn resolve_sequence(vc: &VersionControlDecorator, point: &str) -> Option { fn run_query_pipeline(cli: &Cli, storage: &mut S) -> Result<()> where S: NamedTypeLinks, +{ + run_query_pipeline_with(cli, storage, |_, _, _| Ok(false)) +} + +/// The query pipeline, with a hook that may consume the query before it reaches +/// the [`QueryProcessor`]. +/// +/// `trigger_stage` runs where the C# tool runs its trigger commands: after +/// `--structure`, so `clink --structure` keeps working unchanged, and before +/// the query is processed, so `--always '…'` stores the query instead of +/// applying it. Returning `true` ends the run after the LiNo output is written. +fn run_query_pipeline_with(cli: &Cli, storage: &mut S, trigger_stage: F) -> Result<()> +where + S: NamedTypeLinks, + F: FnOnce(&Cli, &mut S, Option<&str>) -> Result, { if cli.before { storage.print_all_lino()?; @@ -264,6 +364,13 @@ where let effective_query = cli.query.as_deref().or(cli.query_arg.as_deref()); + if trigger_stage(cli, storage, effective_query)? { + if let Some(output_path) = &cli.lino_output { + storage.write_lino_output(output_path)?; + } + return Ok(()); + } + let mut changes_list = Vec::new(); if let Some(query) = effective_query { From 6039c942b92b29295f20874036851117ee37f895 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 17:58:24 +0000 Subject: [PATCH 08/20] fix(rust): hand out addresses in the same order as the C# store Which address a new link gets is observable -- it is the identifier the query answers with, and the one the next query has to use -- so the two implementations have to agree on it. They did not. `ResizableDirectMemoryLinks` keeps an allocation counter plus a free list threaded through the freed links, so it reuses a freed address before growing, most recently freed first, and shrinks when the link at the top is deleted. `LinkStorage` only ever incremented a counter, so after any deletion the two stores drifted apart, and `(() ((1 2)))` left a dangling `(3: 1 2)` behind where C# produced `(1: 1 2)`. `LinkStorage` now keeps the same allocation counter and free list, and `ensure_created` gives back the addresses it passed over on the way to the requested one, the way `ILinksExtensions.EnsureCreated` does. The free list is persisted as a `# unused:` comment line so that the order survives between the one-shot CLI invocations that a scenario is made of; a database written without that line still loads, with the list reconstructed from the addresses the file skips. --- .../issue-100/evidence/cli-parity/run.sh | 98 +++++++- rust/src/link_storage.rs | 214 +++++++++++++++--- rust/tests/link_storage_tests.rs | 127 +++++++++++ 3 files changed, 406 insertions(+), 33 deletions(-) diff --git a/docs/case-studies/issue-100/evidence/cli-parity/run.sh b/docs/case-studies/issue-100/evidence/cli-parity/run.sh index 5823620..ab44827 100755 --- a/docs/case-studies/issue-100/evidence/cli-parity/run.sh +++ b/docs/case-studies/issue-100/evidence/cli-parity/run.sh @@ -14,6 +14,13 @@ trap 'rm -rf "$WORK"' EXIT failures=0 +# Arguments prepended to every invocation of a scenario, and trigger commands +# run before its queries. Both are set by `trigger_scenario` (or by the caller, +# just before it) and cleared again by `scenario`, so the plain scenarios below +# keep running exactly as they did. +EXTRA=() +TRIGGER_SETUP=() + # Runs the query sequence through both CLIs and leaves, for each of them, the # final database dump in "$WORK//final" and one accepted/rejected verdict # per query in "$WORK//status". @@ -28,16 +35,46 @@ run_both() { rm -rf "$rs_dir" "$cs_dir"; mkdir -p "$rs_dir" "$cs_dir" : > "$rs_dir/status"; : > "$cs_dir/status" + # Trigger commands come in (flag, query) pairs and run before the queries. + # Their stdout is compared too: it carries the address the trigger was stored + # at, and how many triggers a --never removed. + local i + for ((i = 0; i < ${#TRIGGER_SETUP[@]}; i += 2)); do + run_command "$RS" "$rs_dir" "${TRIGGER_SETUP[i]}" "${TRIGGER_SETUP[i + 1]}" + run_command "$CS" "$cs_dir" "${TRIGGER_SETUP[i]}" "${TRIGGER_SETUP[i + 1]}" + done + local q for q in "$@"; do - "$RS" --db "$rs_dir/l.links" --query "$q" > "$rs_dir/out" 2>&1 + "$RS" --db "$rs_dir/l.links" ${EXTRA[@]+"${EXTRA[@]}"} --query "$q" > "$rs_dir/out" 2>&1 verdict "$?" "$q" >> "$rs_dir/status" - "$CS" --db "$cs_dir/l.links" --query "$q" > "$cs_dir/out" 2>&1 + "$CS" --db "$cs_dir/l.links" ${EXTRA[@]+"${EXTRA[@]}"} --query "$q" > "$cs_dir/out" 2>&1 verdict "$?" "$q" >> "$cs_dir/status" done - "$RS" --db "$rs_dir/l.links" --after > "$rs_dir/final" 2>&1 - "$CS" --db "$cs_dir/l.links" --after > "$cs_dir/final" 2>&1 + dump "$RS" "$rs_dir" + dump "$CS" "$cs_dir" +} + +# Runs one non-query command and records both its verdict and its stdout. +# stderr is dropped: the two implementations agree on *what* they reject, not on +# how they word it. +run_command() { + local bin="$1" dir="$2"; shift 2 + "$bin" --db "$dir/l.links" ${EXTRA[@]+"${EXTRA[@]}"} "$@" > "$dir/out" 2> /dev/null + verdict "$?" "$*" >> "$dir/status" + sed 's/^/ /' "$dir/out" >> "$dir/status" +} + +# The final database, plus the trigger sidecar when the scenario created one, so +# that how a trigger is *stored* is compared and not just what it did. +dump() { + local bin="$1" dir="$2" + "$bin" --db "$dir/l.links" --after > "$dir/final" 2>&1 + if [ -f "$dir/l.triggers.links" ]; then + echo "triggers:" >> "$dir/final" + "$bin" --db "$dir/l.triggers.links" --after >> "$dir/final" 2>&1 + fi } verdict() { @@ -65,6 +102,7 @@ report_divergence() { scenario() { local name="$1"; shift run_both "$@" + EXTRA=(); TRIGGER_SETUP=() if agree; then echo "PASS $name" @@ -84,6 +122,7 @@ scenario() { known_difference() { local name="$1" reason="$2"; shift 2 run_both "$@" + EXTRA=(); TRIGGER_SETUP=() if agree; then failures=$((failures + 1)) @@ -118,6 +157,57 @@ scenario "reverse update chain" '() ((1 1))' '() ((2 2))' '((1: 1 1)) ((1: scenario "point to non-point" '() ((1 1))' '((1: 1 1)) ((1: 0 0))' scenario "delete self referencing" '() ((1 1))' '() ((1 1) (1 1))' '((1: 1 1)) ()' +# Which address a new link gets is observable, so the two stores have to hand +# out addresses in the same order: a freed address is reused before the store +# grows, the most recently freed one first, and freeing the last link shrinks +# the store instead of leaving a hole. +scenario "reuse a freed address" '() ((1 1) (2 2) (3 3))' '((2: 2 2)) ()' '() ((1 3))' +scenario "reuse after a shrink" '() ((1 1) (2 2) (3 3))' '((3: 3 3)) ()' '() ((1 2))' +scenario "reuse the newest hole first" '() ((1 1) (2 2) (3 3) (4 4) (5 5))' \ + '((2: 2 2)) ()' '((4: 4 4)) ()' '() ((1 3))' '() ((3 1))' '() ((1 5))' + +# Reaching a requested address creates the addresses before it too, and those +# have to be given back -- they were never asked for. +EXTRA=(--auto-create-missing-references) +scenario "auto-create frees the addresses it passed over" \ + '() ((1 1) (2 2) (3 3))' '((2: 2 2)) ()' '((3: 3 3)) ()' '() ((1 4))' +EXTRA=(--auto-create-missing-references) +scenario "auto-create leaves the new link the first address" '(() ((1 2)))' + +# trigger_scenario [ ]... -- ... +# +# Stores (or removes) persistent transformation triggers, then runs the queries. +# Every invocation gets --auto-create-missing-references so that a substitution +# introducing a reference the database does not have yet can actually be +# applied; without it both CLIs would merely agree on refusing to fire. +trigger_scenario() { + local name="$1"; shift + TRIGGER_SETUP=() + while [ "$#" -gt 0 ] && [ "$1" != "--" ]; do + TRIGGER_SETUP+=("$1"); shift + done + shift + EXTRA+=(--auto-create-missing-references) + scenario "$name" "$@" +} + +trigger_scenario "always trigger fires" \ + --always '(((1: 1 1)) ((1: 1 2)))' -- '() ((1: 1 1))' +trigger_scenario "always trigger keeps firing" \ + --always '(((1: 1 1)) ((1: 1 2)))' -- '() ((1: 1 1))' '((1: 1 2)) ((1: 1 1))' +trigger_scenario "once trigger fires only once" \ + --once '(((1: 1 1)) ((1: 1 2)))' -- '() ((1: 1 1))' '((1: 1 2)) ((1: 1 1))' +trigger_scenario "never removes a stored trigger" \ + --always '(((1: 1 1)) ((1: 1 2)))' --never '(((1: 1 1)) ((1: 1 2)))' -- '() ((1: 1 1))' +trigger_scenario "never on an empty trigger store" \ + --never '(((1: 1 1)) ((1: 1 2)))' -- '() ((1: 1 1))' +trigger_scenario "trigger without a match stays dormant" \ + --always '(((7: 7 7)) ((7: 7 8)))' -- '() ((1: 1 1))' + +EXTRA=(--embed-triggers) +trigger_scenario "trigger embedded in the main database" \ + --always '(((1: 1 1)) ((1: 1 2)))' -- '() ((1: 1 1))' + known_difference "update into duplicate" \ "Platform.Data.Doublets 0.18.1 MergeUsages corrupts the usages it repoints (see ../csharp-merge-usages), so C# leaves (2: 2 0) where doublets-rs rebases the usage onto the surviving link and leaves (2: 2 2)." \ '() ((1 2) (2 1))' '((1: 1 2)) ((1: 2 1))' diff --git a/rust/src/link_storage.rs b/rust/src/link_storage.rs index ee48a99..6ba2d18 100644 --- a/rust/src/link_storage.rs +++ b/rust/src/link_storage.rs @@ -33,13 +33,30 @@ fn observe( doublets::data::Flow::Continue } +/// Prefix of the database line that records the freed addresses. +/// +/// It is a comment so that a database written by this version still loads in +/// one that predates it, and so that a database written before it still loads +/// here — [`LinkStorage::restore_unused`] reconstructs the list when the line +/// is absent. +const UNUSED_HEADER: &str = "# unused:"; + /// LinkStorage provides persistent storage for links /// Corresponds to the storage functionality in NamedLinksDecorator in C# pub struct LinkStorage { links: HashMap, names: HashMap, name_to_id: HashMap, - next_id: u32, + /// The highest address ever handed out and not given back, i.e. the + /// `AllocatedLinks` counter of the C# store. + allocated: u32, + /// Addresses below [`Self::allocated`] that were freed and can be handed + /// out again, most recently freed last. + /// + /// The C# store keeps the same set as a linked list threaded through the + /// freed links themselves, pushing and popping at its head; a stack is the + /// same structure without the threading. + unused: Vec, db_path: PathBuf, revision: StorageRevision, trace: bool, @@ -58,7 +75,8 @@ impl LinkStorage { links: HashMap::new(), names: HashMap::new(), name_to_id: HashMap::new(), - next_id: 1, + allocated: 0, + unused: Vec::new(), db_path, revision: StorageRevision::default(), trace, @@ -96,7 +114,8 @@ impl LinkStorage { self.links.clear(); self.names.clear(); self.name_to_id.clear(); - self.next_id = 1; + self.allocated = 0; + self.unused.clear(); if self.db_path.exists() { self.load()?; } @@ -110,11 +129,17 @@ impl LinkStorage { .with_context(|| format!("Failed to open database: {}", self.db_path.display()))?; let reader = BufReader::new(file); + let mut recorded_unused = None; for line in reader.lines() { let line = line?; let line = line.trim(); + if let Some(addresses) = line.strip_prefix(UNUSED_HEADER) { + recorded_unused = Some(Self::parse_unused_header(addresses)); + continue; + } + if line.is_empty() || line.starts_with('#') { continue; } @@ -122,8 +147,8 @@ impl LinkStorage { // Parse link format: (index source target) or (index source target "name") if let Some((link, name)) = self.parse_link_line(line) { self.links.insert(link.index, link); - if link.index >= self.next_id { - self.next_id = link.index + 1; + if link.index > self.allocated { + self.allocated = link.index; } if let Some(name) = name { self.names.insert(link.index, name.clone()); @@ -132,6 +157,8 @@ impl LinkStorage { } } + self.unused = self.restore_unused(recorded_unused); + if self.trace { eprintln!( "[TRACE] Loaded {} links from {}", @@ -164,6 +191,97 @@ impl LinkStorage { None } + /// Parses the addresses recorded by an [`UNUSED_HEADER`] line into the + /// stack order the allocator uses. + /// + /// The line lists them the way the C# store's free list reads — most + /// recently freed first — and the stack pops from its end, so the two are + /// reverses of each other. + fn parse_unused_header(addresses: &str) -> Vec { + let mut unused: Vec = addresses + .split_whitespace() + .filter_map(|address| address.parse().ok()) + .collect(); + unused.reverse(); + unused + } + + /// The freed-address stack to start from after a load. + /// + /// A database this version wrote records the stack, because the order + /// decides which address the next link gets and nothing in the list of + /// stored links implies it. A database written before this version (or by + /// hand) does not, so the addresses missing below the highest stored one + /// are recovered instead, lowest reused first — an order the file does at + /// least determine. + /// + /// Addresses that a hand-edited file records but that are in use, or that + /// sit above the highest stored link, are dropped: handing them out would + /// overwrite a link or leave a hole the allocator would hand out twice. + fn restore_unused(&self, recorded: Option>) -> Vec { + match recorded { + Some(recorded) => { + let mut seen = HashSet::new(); + recorded + .into_iter() + .filter(|address| { + *address > 0 + && *address < self.allocated + && !self.links.contains_key(address) + && seen.insert(*address) + }) + .collect() + } + None => (1..self.allocated) + .filter(|address| !self.links.contains_key(address)) + .rev() + .collect(), + } + } + + /// Hands out the address of the next link, reusing a freed one first. + /// + /// This is `ResizableDirectMemoryLinks.AllocateLink` in the C# + /// implementation: an address is only taken from beyond the end of the + /// store when no freed one is left. Reuse is observable — it decides the + /// address a query reports for a link it creates — so the two + /// implementations have to agree on it. + fn allocate(&mut self) -> u32 { + match self.unused.pop() { + Some(address) => address, + None => { + self.allocated += 1; + self.allocated + } + } + } + + /// Gives `address` back to the allocator. + /// + /// Freeing the highest allocated address shrinks the store rather than + /// growing the free list, and takes with it every freed address that has + /// become the new end — `ResizableDirectMemoryLinks.Delete` does exactly + /// this, which is why C# reuses the address of a link it just appended + /// before it reuses one freed earlier. + fn release(&mut self, address: u32) { + if address == 0 || address > self.allocated { + return; + } + if address < self.allocated { + self.unused.push(address); + return; + } + self.allocated = address - 1; + while let Some(position) = self + .unused + .iter() + .position(|&freed| freed == self.allocated) + { + self.unused.remove(position); + self.allocated -= 1; + } + } + /// Saves all links to the database file pub fn save(&self) -> Result<()> { let file = OpenOptions::new() @@ -175,6 +293,19 @@ impl LinkStorage { let mut writer = BufWriter::new(file); + // The freed addresses first: which of them the next link gets is not + // implied by the links that follow, and reloading has to resume the + // allocator exactly where it stopped. + if !self.unused.is_empty() { + let recorded: Vec = self + .unused + .iter() + .rev() + .map(|address| address.to_string()) + .collect(); + writeln!(writer, "{UNUSED_HEADER} {}", recorded.join(" "))?; + } + // Sort by index for consistent output let mut links: Vec<_> = self.links.values().collect(); links.sort_by_key(|l| l.index); @@ -205,9 +336,11 @@ impl LinkStorage { } /// Creates a new link and returns its ID + /// + /// The address is the one [`LinkStorage::allocate`] hands out: a freed one + /// when the store has any, and only otherwise a fresh one past the end. pub fn create(&mut self, source: u32, target: u32) -> u32 { - let id = self.next_id; - self.next_id += 1; + let id = self.allocate(); let link = Link::new(id, source, target); self.links.insert(id, link); @@ -219,33 +352,44 @@ impl LinkStorage { id } - /// Creates a link with a specific ID, ensuring all links up to that ID exist + /// Creates the link at `id`, as an empty `(id: 0 0)` link. + /// + /// Reaching a specific address means asking the allocator for links until + /// it hands that one out, and the ones it handed out on the way are freed + /// again — they were never asked for. This is `ILinksExtensions.EnsureCreated` + /// in the C# implementation: + /// + /// ```csharp + /// do { createdLink = creator(); createdLinks.Add(createdLink); } + /// while (createdLink != max); + /// for (var i = 0; i < createdLinks.Count; i++) + /// if (!nonExistentAddresses.Contains(createdLinks[i])) + /// links.Delete(createdLinks[i]); + /// ``` + /// + /// Freeing them in the order they were created is what leaves the last one + /// on top of the free list, so it is the address the next created link + /// gets. pub fn ensure_created(&mut self, id: u32) -> u32 { - if self.links.contains_key(&id) { + if id == 0 || self.links.contains_key(&id) { return id; } - if self.next_id > id { - let link = Link::new(id, 0, 0); - self.links.insert(id, link); - if self.trace { - eprintln!("[TRACE] Ensured link: ({} 0 0)", id); + let mut passed_over = Vec::new(); + loop { + let created = self.create(0, 0); + if created == id { + break; } - return id; + passed_over.push(created); } - // Create placeholder links up to the requested ID - while self.next_id <= id { - let placeholder_id = self.next_id; - self.next_id += 1; - if placeholder_id == id { - let link = Link::new(id, 0, 0); - self.links.insert(id, link); - if self.trace { - eprintln!("[TRACE] Ensured link: ({} 0 0)", id); - } - return id; - } + for address in passed_over { + let _ = self.delete_raw(address); + } + + if self.trace { + eprintln!("[TRACE] Ensured link: ({} 0 0)", id); } id @@ -297,6 +441,7 @@ impl LinkStorage { } if let Some(link) = self.links.remove(&id) { + self.release(id); if self.trace { eprintln!( "[TRACE] Deleted link: ({} {} {})", @@ -388,9 +533,20 @@ impl LinkStorage { Ok(before) } - /// Returns all links + /// Every stored link, ordered by address. + /// + /// The order is part of the contract, not an implementation detail: the + /// query processor enumerates links through this method, so an + /// unspecified order would make pattern matching — and with it the order + /// `--changes` reports and the order a cascading delete visits usages — + /// vary between runs of the very same query. `HashMap::values` is exactly + /// such an order, seeded randomly per process. Sorting reproduces what the + /// C# store does naturally: `UnitedMemoryLinks` walks allocated addresses + /// from `1` upwards. pub fn all(&self) -> Vec<&Link> { - self.links.values().collect() + let mut links: Vec<&Link> = self.links.values().collect(); + links.sort_unstable_by_key(|link| link.index); + links } /// Returns all links matching a query pattern diff --git a/rust/tests/link_storage_tests.rs b/rust/tests/link_storage_tests.rs index c5496eb..af80859 100644 --- a/rust/tests/link_storage_tests.rs +++ b/rust/tests/link_storage_tests.rs @@ -303,3 +303,130 @@ fn get_or_create_matches_service_constants_literally() -> Result<()> { Ok(()) } + +/// The address a new link gets is part of what a query reports, so the store +/// hands out addresses the way `ResizableDirectMemoryLinks` does: a freed one +/// before a fresh one. +#[test] +fn test_freed_address_is_reused_before_the_store_grows() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let mut storage = LinkStorage::new(temp_file.path(), false)?; + + let first = storage.create(1, 1); + let second = storage.create(2, 2); + let third = storage.create(3, 3); + assert_eq!((first, second, third), (1, 2, 3)); + + storage.delete_raw(second)?; + + assert_eq!(storage.create(1, 3), second); + assert_eq!(storage.create(3, 1), 4); + + Ok(()) +} + +/// Freed addresses come back in the reverse of the order they were freed: the +/// C# store pushes each one onto the head of its free list and allocates from +/// that same head. +#[test] +fn test_freed_addresses_are_reused_most_recently_freed_first() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let mut storage = LinkStorage::new(temp_file.path(), false)?; + + for part in 1..=5 { + storage.create(part, part); + } + storage.delete_raw(2)?; + storage.delete_raw(4)?; + + assert_eq!(storage.create(1, 3), 4); + assert_eq!(storage.create(3, 1), 2); + assert_eq!(storage.create(1, 5), 6); + + Ok(()) +} + +/// Freeing the highest address shrinks the store instead of leaving a hole, +/// and takes the freed addresses that have become the end with it. +#[test] +fn test_freeing_the_last_link_shrinks_the_store() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let mut storage = LinkStorage::new(temp_file.path(), false)?; + + for part in 1..=3 { + storage.create(part, part); + } + storage.delete_raw(2)?; + storage.delete_raw(3)?; + + // Both 2 and 3 are gone, but as a shrink rather than as two holes, so the + // next links get them in ascending order. + assert_eq!(storage.create(1, 2), 2); + assert_eq!(storage.create(2, 1), 3); + + Ok(()) +} + +/// The free list decides the next address and nothing in the stored links +/// implies it, so it has to survive a save and a load. +#[test] +fn test_free_list_survives_a_reload() -> Result<()> { + let temp_file = NamedTempFile::new()?; + + { + let mut storage = LinkStorage::new(temp_file.path(), false)?; + for part in 1..=5 { + storage.create(part, part); + } + storage.delete_raw(2)?; + storage.delete_raw(4)?; + storage.save()?; + } + + let mut storage = LinkStorage::new(temp_file.path(), false)?; + assert_eq!(storage.create(1, 3), 4); + assert_eq!(storage.create(3, 1), 2); + assert_eq!(storage.create(1, 5), 6); + + Ok(()) +} + +/// A database written before the free list was recorded — or by hand — still +/// loads, with the addresses missing below the highest stored link recovered +/// as freed ones, lowest reused first. +#[test] +fn test_missing_addresses_are_recovered_from_a_database_without_a_free_list() -> Result<()> { + let temp_file = NamedTempFile::new()?; + std::fs::write(temp_file.path(), "(1 1 1)\n(3 3 3)\n(5 5 5)\n")?; + + let mut storage = LinkStorage::new(temp_file.path(), false)?; + assert_eq!(storage.create(1, 3), 2); + assert_eq!(storage.create(3, 1), 4); + assert_eq!(storage.create(1, 5), 6); + + Ok(()) +} + +/// Reaching a requested address allocates the addresses before it, and those +/// are freed again — `EnsureCreated` deletes every link it did not ask for. +#[test] +fn test_ensure_created_frees_the_addresses_it_passed_over() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let mut storage = LinkStorage::new(temp_file.path(), false)?; + + storage.create(1, 1); + assert_eq!(storage.ensure_created(4), 4); + + assert!(!storage.exists(2)); + assert!(!storage.exists(3)); + assert_eq!( + storage.get(4).map(|link| (link.source, link.target)), + Some((0, 0)) + ); + + // 2 and 3 were freed in the order they were created, leaving 3 on top. + assert_eq!(storage.create(1, 4), 3); + assert_eq!(storage.create(4, 1), 2); + + Ok(()) +} From 3afa78fcbfb1e3717cb5e07c4c14992f213423ee Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 17:59:15 +0000 Subject: [PATCH 09/20] fix(rust): report the same changes as the C# CLI does `--changes` is a contract: it says what a query did, and callers diff those lines. The Rust CLI answered differently from the C# one in three ways. An auto-created reference was reported as `() ((id: id id))`, as if it had appeared from nothing. C# creates the placeholder and then updates it, so the update is reported against the placeholder it started from -- `((id: 0 0)) ((id: id id))`. `validate_links_exist_or_will_be_created` now returns the before/after pair of each reference it created rather than only the finished link. A delete was reported as the one link that was asked for, while C# reports the cascade: deleting a link deletes every link that still used it, and `AdvancedMixedQueryProcessor.RemoveLinks` passes a handler down so each of those removals is reported too. `NamedTypeLinks` gains `delete_observed`, the trait-level equivalent of that handler, which the query processor uses wherever it deletes. Finally, the order of the reported changes was not reproducible. `simplify_changes` enumerated `HashSet`s and a `HashMap`, whose order depends on a per-process random seed, where C# enumerates a `HashSet` and a `GroupBy` in insertion order. Both are now iterated in first- occurrence order, so the same query prints the same lines every run. --- rust/src/changes_simplifier.rs | 54 ++++- rust/src/link_reference_validator.rs | 34 ++- rust/src/named_type_links.rs | 40 +++- rust/src/persistent_transformations.rs | 26 ++- rust/src/query_processor.rs | 302 +++++++++++++++++++------ rust/src/transactions/mod.rs | 20 +- rust/src/version_control/mod.rs | 9 +- 7 files changed, 401 insertions(+), 84 deletions(-) diff --git a/rust/src/changes_simplifier.rs b/rust/src/changes_simplifier.rs index 0156b10..10c7e24 100644 --- a/rust/src/changes_simplifier.rs +++ b/rust/src/changes_simplifier.rs @@ -32,21 +32,31 @@ pub fn simplify_changes(changes: Vec<(Link, Link)>) -> Vec<(Link, Link)> { } } - // Gather all 'Before' links and all 'After' links from changed states - let before_links: HashSet = changed_states.iter().map(|(b, _)| *b).collect(); - let after_links: HashSet = changed_states.iter().map(|(_, a)| *a).collect(); + // Gather all 'Before' links and all 'After' links from changed states. + // + // C# builds these with `new HashSet>(...)`, which enumerates in + // insertion order; a Rust `HashSet` enumerates in an order derived from a + // per-process random seed. Since the order of `initialStates` decides the + // order of the simplified results whenever the final sort ties, that + // difference would make `--changes` non-reproducible. Keeping the first + // occurrences in a `Vec` (and using the set only for lookups) restores the + // C# ordering. + let before_links = distinct(changed_states.iter().map(|(b, _)| *b)); + let after_links = distinct(changed_states.iter().map(|(_, a)| *a)); + let before_set: HashSet = before_links.iter().copied().collect(); + let after_set: HashSet = after_links.iter().copied().collect(); // Identify initial states: appear as Before but never as After let initial_states: Vec = before_links .iter() - .filter(|b| !after_links.contains(b)) + .filter(|b| !after_set.contains(b)) .copied() .collect(); // Identify final states: appear as After but never as Before let final_states: HashSet = after_links .iter() - .filter(|a| !before_links.contains(a)) + .filter(|a| !before_set.contains(a)) .copied() .collect(); @@ -68,7 +78,8 @@ pub fn simplify_changes(changes: Vec<(Link, Link)>) -> Vec<(Link, Link)> { results.extend(unchanged_states); // Traverse each initial state with DFS - for initial in initial_states.iter() { + for initial in distinct(initial_states.iter().copied()) { + let initial = &initial; let mut stack = vec![*initial]; let mut visited: HashSet = HashSet::new(); @@ -118,18 +129,27 @@ pub fn simplify_changes(changes: Vec<(Link, Link)>) -> Vec<(Link, Link)> { /// and one of them is to a "null" state (0: 0 0), we should prefer the non-null transition /// as it represents the actual final transformation. fn remove_duplicate_before_states(changes: Vec<(Link, Link)>) -> Vec<(Link, Link)> { - // Group changes by their before state + // Group changes by their before state, keeping the groups in the order + // their first member appeared. C# uses `GroupBy`, which is documented to + // preserve that order; iterating a `HashMap` instead would shuffle the + // reported changes differently on every process start. + let mut order: Vec = Vec::new(); let mut grouped: HashMap> = HashMap::new(); for change in changes { - grouped.entry(change.0).or_default().push(change); + let group = grouped.entry(change.0).or_insert_with(|| { + order.push(change.0); + Vec::new() + }); + group.push(change); } let mut result = Vec::new(); - for (_before, changes_for_this_before) in grouped { + for before in &order { + let changes_for_this_before = &grouped[before]; if changes_for_this_before.len() == 1 { // No duplicates, keep as is - result.extend(changes_for_this_before); + result.extend(changes_for_this_before.iter().copied()); } else { // Multiple changes from the same before state // Check if any of them is to a null state (0: 0 0) @@ -150,10 +170,22 @@ fn remove_duplicate_before_states(changes: Vec<(Link, Link)>) -> Vec<(Link, Link } else { // No null transitions involved, this is a legitimate multiple-branch scenario // Keep all transitions - result.extend(changes_for_this_before); + result.extend(changes_for_this_before.iter().copied()); } } } result } + +/// The distinct elements of `items`, in the order they first appear. +/// +/// Stands in for enumerating a C# `HashSet`, which yields its elements in +/// insertion order. +fn distinct(items: impl IntoIterator) -> Vec { + let mut seen = HashSet::new(); + items + .into_iter() + .filter(|item| seen.insert(*item)) + .collect() +} diff --git a/rust/src/link_reference_validator.rs b/rust/src/link_reference_validator.rs index d552a46..2ee3a95 100644 --- a/rust/src/link_reference_validator.rs +++ b/rust/src/link_reference_validator.rs @@ -63,7 +63,7 @@ impl LinkReferenceValidator { storage: &mut impl NamedTypeLinks, restriction_patterns: &[LinoLink], substitution_patterns: &[LinoLink], - ) -> Result> { + ) -> Result> { self.trace_msg("[ValidateLinksExistOrWillBeCreated] Starting validation"); let mut plan = self.build_link_reference_plan(storage, substitution_patterns); @@ -307,11 +307,30 @@ impl LinkReferenceValidator { Ok(()) } + /// Creates every missing reference and reports the `(before, after)` state + /// of each one. + /// + /// The before state is the placeholder the reference is turned into a point + /// link *from*, never `null`: both branches of the C# original create the + /// link silently — `EnsureCreated`, and `CreateAndUpdate(Null, Null)` with + /// no handler — and only pass the changes handler to the `Update` that + /// makes it a point link: + /// + /// ```csharp + /// links.Update( + /// new DoubletLink(linkId, links.Constants.Null, links.Constants.Null), + /// new DoubletLink(linkId, linkId, linkId), + /// (beforeState, afterState) => + /// options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue + /// ); + /// ``` + /// + /// So `--changes` shows `((2: 0 0)) ((2: 2 2))`, not `() ((2: 2 2))`. fn auto_create_missing_references( &self, storage: &mut impl NamedTypeLinks, plan: &LinkReferencePlan, - ) -> Result> { + ) -> Result> { let missing_references = &plan.missing_references; let mut created = Vec::new(); let mut numeric_references = missing_references @@ -339,9 +358,12 @@ impl LinkReferenceValidator { )); continue; } + let before = storage + .get_link(link_id) + .unwrap_or_else(|| Link::new(link_id, 0, 0)); storage.update(link_id, link_id, link_id)?; - if let Some(link) = storage.get_link(link_id) { - created.push(link); + if let Some(after) = storage.get_link(link_id) { + created.push((before, after)); } } @@ -362,8 +384,8 @@ impl LinkReferenceValidator { "[ValidateLinksExistOrWillBeCreated] Auto-creating missing named reference '{name}' as point link." )); let link_id = storage.get_or_create_named(&name)?; - if let Some(link) = storage.get_link(link_id) { - created.push(link); + if let Some(after) = storage.get_link(link_id) { + created.push((Link::new(link_id, 0, 0), after)); } } diff --git a/rust/src/named_type_links.rs b/rust/src/named_type_links.rs index 40f6519..40119c3 100644 --- a/rust/src/named_type_links.rs +++ b/rust/src/named_type_links.rs @@ -6,7 +6,7 @@ use std::path::Path; use crate::error::LinkError; use crate::link::Link; -use crate::link_storage::LinkStorage; +use crate::link_storage::{ChangeObserver, LinkStorage}; use crate::named_types::{NamedTypes, NamedTypesDecorator}; pub trait NamedTypeLinks { @@ -26,6 +26,26 @@ pub trait NamedTypeLinks { fn exists(&mut self, id: u32) -> bool; fn update(&mut self, id: u32, source: u32, target: u32) -> Result; fn delete(&mut self, id: u32) -> Result; + /// [`Self::delete`], reporting every change the deletion caused. + /// + /// Deleting a link cascades into every link that still used it, and each of + /// those removals is a change in its own right. The C# CLI sees them + /// because `AdvancedMixedQueryProcessor.RemoveLinks` hands a handler to the + /// store: + /// + /// ```csharp + /// links.Delete(link, (before, after) => + /// options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue); + /// ``` + /// + /// The default implementation reports only the link that was asked for, + /// which is correct for stores that cannot cascade; every decorator over a + /// cascading store overrides it. + fn delete_observed(&mut self, id: u32, observer: ChangeObserver<'_>) -> Result { + let before = self.delete(id)?; + observer(before, Link::null()); + Ok(before) + } fn all_links(&mut self) -> Vec; fn search(&mut self, source: u32, target: u32) -> Option; fn get_or_create(&mut self, source: u32, target: u32) -> u32; @@ -162,6 +182,10 @@ impl NamedTypeLinks for LinkStorage { LinkStorage::delete(self, id) } + fn delete_observed(&mut self, id: u32, observer: ChangeObserver<'_>) -> Result { + LinkStorage::delete_observed(self, id, observer) + } + fn all_links(&mut self) -> Vec { self.all().into_iter().copied().collect() } @@ -226,6 +250,10 @@ impl NamedTypeLinks for NamedTypesDecorator { NamedTypesDecorator::delete(self, id) } + fn delete_observed(&mut self, id: u32, observer: ChangeObserver<'_>) -> Result { + NamedTypesDecorator::delete_observed(self, id, observer) + } + fn all_links(&mut self) -> Vec { self.all().into_iter().copied().collect() } @@ -290,6 +318,12 @@ impl NamedTypeLinks for crate::transactions::TransactionsDecorator { )?) } + fn delete_observed(&mut self, id: u32, observer: ChangeObserver<'_>) -> Result { + Ok(crate::transactions::TransactionsDecorator::delete_observed( + self, id, observer, + )?) + } + fn all_links(&mut self) -> Vec { self.all().into_iter().copied().collect() } @@ -350,6 +384,10 @@ impl NamedTypeLinks for crate::version_control::VersionControlDecorator { crate::version_control::VersionControlDecorator::delete(self, id) } + fn delete_observed(&mut self, id: u32, observer: ChangeObserver<'_>) -> Result { + crate::version_control::VersionControlDecorator::delete_observed(self, id, observer) + } + fn all_links(&mut self) -> Vec { self.all().into_iter().copied().collect() } diff --git a/rust/src/persistent_transformations.rs b/rust/src/persistent_transformations.rs index a62951b..24ffff7 100644 --- a/rust/src/persistent_transformations.rs +++ b/rust/src/persistent_transformations.rs @@ -42,6 +42,7 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Result}; use crate::link::Link; +use crate::link_storage::ChangeObserver; use crate::lino_link::LinoLink; use crate::named_type_links::{escape_lino_reference, NamedTypeLinks}; use crate::named_types::NamedTypesDecorator; @@ -573,9 +574,20 @@ impl NamedTypeLinks for PersistentTransformationDecorator index } + /// Only a call that really allocates a link counts as a write. + /// + /// C# hooks the raw `ILinks.Create`, which `EnsureCreated` reaches only for + /// addresses the store does not have yet; an `EnsureCreated` for an existing + /// address performs no operation and therefore fires no trigger. Checking + /// [`exists`](NamedTypeLinks::exists) first reproduces that: without it a + /// no-op query such as `() ((1: 1 1))` over an already existing point would + /// replay every trigger in Rust and none in C#. fn ensure_created(&mut self, id: u32) -> u32 { + let existed = self.links.exists(id); let index = self.links.ensure_created(id); - self.after_write(); + if !existed { + self.after_write(); + } index } @@ -601,6 +613,13 @@ impl NamedTypeLinks for PersistentTransformationDecorator Ok(link) } + fn delete_observed(&mut self, id: u32, observer: ChangeObserver<'_>) -> Result { + let link = self.links.delete_observed(id, observer)?; + self.apply_triggers_after_operation()?; + self.take_pending_error()?; + Ok(link) + } + fn all_links(&mut self) -> Vec { self.links.all_links() } @@ -609,7 +628,12 @@ impl NamedTypeLinks for PersistentTransformationDecorator self.links.search(source, target) } + /// Fires triggers only when the pair had to be created — see + /// [`ensure_created`](Self::ensure_created) for why. fn get_or_create(&mut self, source: u32, target: u32) -> u32 { + if let Some(index) = self.links.search(source, target) { + return index; + } let index = self.links.get_or_create(source, target); self.after_write(); index diff --git a/rust/src/query_processor.rs b/rust/src/query_processor.rs index fae78c5..7e30e1d 100644 --- a/rust/src/query_processor.rs +++ b/rust/src/query_processor.rs @@ -42,11 +42,27 @@ impl QueryProcessor { self } - /// Processes a LiNo query and returns the list of changes + /// Processes a LiNo query and returns the list of changes. + /// + /// Every scenario is simplified on the way out, matching the C# CLI, which + /// collects the raw handler calls and runs `SimplifyChanges` over them once + /// in `Program.cs` regardless of which branch of the processor produced + /// them. pub fn process_query( &self, storage: &mut impl NamedTypeLinks, query: &str, + ) -> Result, Option)>> { + let changes = self.process_query_raw(storage, query)?; + Ok(self.simplify_changes_list(&changes)) + } + + /// The processor proper: applies `query` and reports the raw + /// `(before, after)` states, in the order they happened. + fn process_query_raw( + &self, + storage: &mut impl NamedTypeLinks, + query: &str, ) -> Result, Option)>> { self.trace_msg(&format!("[ProcessQuery] Query: \"{}\"", query)); @@ -110,18 +126,16 @@ impl QueryProcessor { changes_list.extend( self.validate_links_exist_or_will_be_created(storage, &[], values)? .into_iter() - .map(|link| (None, Some(link))), + .map(|(before, after)| (Some(before), Some(after))), ); for link_to_create in values { - let created_id = self.ensure_link_created(storage, link_to_create)?; + let created_id = + self.ensure_link_created(storage, link_to_create, &mut changes_list)?; self.trace_msg(&format!( "[ProcessQuery] Created link ID #{} from substitution pattern.", created_id )); - if let Some(link) = storage.get_link(created_id) { - changes_list.push((None, Some(link))); - } } } storage.save()?; @@ -137,7 +151,7 @@ impl QueryProcessor { changes_list.extend( self.validate_links_exist_or_will_be_created(storage, restriction_values, &[])? .into_iter() - .map(|link| (None, Some(link))), + .map(|(before, after)| (Some(before), Some(after))), ); let restriction_patterns = self.patterns_from_lino(restriction_link); @@ -150,8 +164,7 @@ impl QueryProcessor { for link in links_to_delete { if storage.exists(link.index) { - let before = storage.delete(link.index)?; - changes_list.push((Some(before), None)); + self.delete_observed(storage, link.index, &mut changes_list)?; self.trace_msg(&format!("[ProcessQuery] Deleted link ID #{}.", link.index)); } } @@ -175,7 +188,7 @@ impl QueryProcessor { substitution_values, )? .into_iter() - .map(|link| (None, Some(link))), + .map(|(before, after)| (Some(before), Some(after))), ); let solutions = self.find_all_solutions(storage, &restriction_patterns)?; @@ -233,10 +246,35 @@ impl QueryProcessor { storage.save()?; - // Simplify changes - let simplified = self.simplify_changes_list(&changes_list); + Ok(changes_list) + } - Ok(simplified) + /// Deletes `id` and appends every resulting change to `changes`. + /// + /// A delete cascades into the links that still used the deleted one, and + /// each of those removals is reported too. It is the direct analogue of + /// C#'s `RemoveLinks`, which passes the changes handler straight to the + /// store so the decorator stack reports the cascade: + /// + /// ```csharp + /// links.Delete(link, (before, after) => + /// options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue); + /// ``` + fn delete_observed( + &self, + storage: &mut impl NamedTypeLinks, + id: u32, + changes: &mut Vec<(Option, Option)>, + ) -> Result { + let mut observed = Vec::new(); + let deleted = storage.delete_observed(id, &mut |before, after| { + observed.push(( + (!before.is_null()).then_some(before), + (!after.is_null()).then_some(after), + )); + })?; + changes.append(&mut observed); + Ok(deleted) } fn validate_links_exist_or_will_be_created( @@ -244,7 +282,7 @@ impl QueryProcessor { storage: &mut impl NamedTypeLinks, restriction_patterns: &[LinoLink], substitution_patterns: &[LinoLink], - ) -> Result> { + ) -> Result> { LinkReferenceValidator::new(self.trace, self.auto_create_missing_references) .validate_links_exist_or_will_be_created( storage, @@ -605,14 +643,13 @@ impl QueryProcessor { links.dedup_by_key(|link| link.index); for link in links { if storage.exists(link.index) { - let deleted = storage.delete(link.index)?; - changes.push((Some(deleted), None)); + self.delete_observed(storage, link.index, changes)?; } } } (None, Some(after)) => { - let created = self.create_or_update_resolved_link(storage, &after)?; - changes.push((None, Some(created))); + let (before, created) = self.create_or_update_resolved_link(storage, &after)?; + changes.push((before, Some(created))); } (Some(before), Some(after)) => { if before.index == after.index && storage.exists(before.index) { @@ -704,34 +741,86 @@ impl QueryProcessor { self.trace_msg(&format!( "[RestoreUnexpectedLinkDeletions] Recreating link {index} => was unexpected deletion." )); - let restored = self.create_or_update_resolved_link(storage, intended)?; - changes.push((None, Some(restored))); + let (before, restored) = self.create_or_update_resolved_link(storage, intended)?; + changes.push((before, Some(restored))); } Ok(()) } + /// Creates or updates the link a resolved definition asks for and reports + /// the states a `--changes` listener would see. + /// + /// The returned pair is `(before, after)`, where `before` is `None` only + /// when the link genuinely had to be allocated from nothing. Everything + /// else — an address that had to be filled in with + /// [`try_ensure_created`](NamedTypeLinks::try_ensure_created), a definition + /// that already matches its stored state, a duplicate of an existing + /// doublet — reports the state that was there before, mirroring + /// `CreateOrUpdateLink` in the C# processor: + /// + /// ```csharp + /// if (existingDoublet.Source != linkDefinition.Source || existingDoublet.Target != linkDefinition.Target) + /// { ... links.Update(...); } + /// else + /// { options.ChangesHandler?.Invoke(existingDoublet, existingDoublet); } + /// ``` + /// + /// Skipping the update when nothing changes is not only a reporting + /// detail: a redundant write shows up in the transitions log and — far + /// worse — counts as a write for the persistent transformation decorator, + /// which would replay every stored trigger for a query that changed + /// nothing. fn create_or_update_resolved_link( &self, storage: &mut impl NamedTypeLinks, definition: &ResolvedLink, - ) -> Result { - let id = if Self::is_normal_index(definition.index) { + ) -> Result<(Option, Link)> { + let (before, id) = if Self::is_normal_index(definition.index) { storage.try_ensure_created(definition.index)?; - storage.update(definition.index, definition.source, definition.target)?; - definition.index + let existing = storage + .get_link(definition.index) + .unwrap_or_else(|| Link::new(definition.index, 0, 0)); + if existing.source != definition.source || existing.target != definition.target { + self.trace_msg(&format!( + "[CreateOrUpdateLink] Updating link {}: {}->{}, {}->{}.", + definition.index, + existing.source, + definition.source, + existing.target, + definition.target + )); + storage.update(definition.index, definition.source, definition.target)?; + } else { + self.trace_msg(&format!( + "[CreateOrUpdateLink] Link {} is already S={}, T={} => no change.", + definition.index, definition.source, definition.target + )); + } + (Some(existing), definition.index) } else if let Some(existing_id) = storage.search(definition.source, definition.target) { - existing_id + self.trace_msg(&format!( + "[CreateOrUpdateLink] Link already found => ID={existing_id}, no changes." + )); + let existing = storage + .get_link(existing_id) + .unwrap_or_else(|| Link::new(existing_id, definition.source, definition.target)); + (Some(existing), existing_id) } else { - storage.create(definition.source, definition.target) + self.trace_msg(&format!( + "[CreateOrUpdateLink] Creating new link => (S={},T={}).", + definition.source, definition.target + )); + (None, storage.create(definition.source, definition.target)) }; if let Some(name) = &definition.name { storage.set_name(id, name)?; } - Ok(storage + let after = storage .get_link(id) - .unwrap_or_else(|| Link::new(id, definition.source, definition.target))) + .unwrap_or_else(|| Link::new(id, definition.source, definition.target)); + Ok((before, after)) } fn links_matching_definition( @@ -774,11 +863,19 @@ impl QueryProcessor { identifier == "*" || identifier.parse::().is_ok() } - /// Ensures a link is created from a LinoLink pattern + /// Ensures a link is created from a LiNo pattern, recursing into its parts. + /// + /// Port of `EnsureNestedLinkCreatedRecursively` in the C# processor. Every + /// doublet it touches — including the nested ones — appends its + /// `(before, after)` states to `changes`, exactly as the C# version reports + /// them through `options.ChangesHandler`, so `--changes` lists the same + /// records in both languages. Leaves report nothing: C#'s `ResolveLeaf` + /// passes the update handler that ignores the changes handler. fn ensure_link_created( &self, storage: &mut impl NamedTypeLinks, lino_link: &LinoLink, + changes: &mut Vec<(Option, Option)>, ) -> Result { // Handle leaf nodes (names or numbers) if !lino_link.has_values() { @@ -803,33 +900,34 @@ impl QueryProcessor { let values = lino_link.values.as_ref().unwrap(); // Recursively ensure source and target exist - let source_id = self.ensure_link_created(storage, &values[0])?; - let target_id = self.ensure_link_created(storage, &values[1])?; + let source_id = self.ensure_link_created(storage, &values[0], changes)?; + let target_id = self.ensure_link_created(storage, &values[1], changes)?; // Create or get the composite link let link_id = if let Some(ref id) = lino_link.id { if let Ok(num) = id.parse::() { - // Specific ID requested - storage.try_ensure_created(num)?; - storage.update(num, source_id, target_id)?; - num + // Specific ID requested. + self.ensure_indexed_link(storage, num, source_id, target_id, changes)? } else if id == "*" || Self::is_variable(id) { - storage.get_or_create(source_id, target_id) + self.ensure_doublet(storage, source_id, target_id, changes) } else { - // Named link + // Named link: this repository resolves the address through + // the name, where C# resolves it through `(source, target)` + // and names the result afterwards. The reported states are + // the same either way. let existing = storage.get_by_name(id)?; if let Some(id_num) = existing { - storage.update(id_num, source_id, target_id)?; - id_num + self.ensure_indexed_link(storage, id_num, source_id, target_id, changes)? } else { let new_id = storage.create(source_id, target_id); + changes.push((None, storage.get_link(new_id))); storage.set_name(new_id, id)?; new_id } } } else { // Anonymous link - storage.get_or_create(source_id, target_id) + self.ensure_doublet(storage, source_id, target_id, changes) }; return Ok(link_id); @@ -838,34 +936,112 @@ impl QueryProcessor { Err(LinkError::InvalidFormat("Invalid link structure".to_string()).into()) } - /// Simplifies the changes list - fn simplify_changes_list( + /// `EnsureLinkCreated` for a definition that names its own address. + /// + /// Fills the address in when the store does not have it yet, then writes + /// only if the stored doublet really differs — the `else` branch in C# + /// reports `(existing, existing)` without touching the store: + /// + /// ```csharp + /// TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} is already correct => no-op."); + /// options.ChangesHandler?.Invoke(storedD, storedD); + /// ``` + /// + /// The redundant write this avoids is not merely noise: it lands in the + /// transitions log and, with `--always`/`--once` triggers in play, replays + /// every stored transformation for a query that changed nothing. + fn ensure_indexed_link( &self, - changes: &[(Option, Option)], - ) -> Vec<(Option, Option)> { - // Convert to the format expected by simplify_changes - let mut to_simplify: Vec<(Link, Link)> = Vec::new(); - let mut non_simplifiable: Vec<(Option, Option)> = Vec::new(); - - for (before, after) in changes { - match (before, after) { - (Some(b), Some(a)) => { - to_simplify.push((*b, *a)); - } - _ => { - non_simplifiable.push((*before, *after)); - } - } + storage: &mut impl NamedTypeLinks, + index: u32, + source: u32, + target: u32, + changes: &mut Vec<(Option, Option)>, + ) -> Result { + storage.try_ensure_created(index)?; + let stored = storage + .get_link(index) + .unwrap_or_else(|| Link::new(index, 0, 0)); + if stored.source != source || stored.target != target { + self.trace_msg(&format!( + "[EnsureLinkCreated] Updating link {index} => {}->{source}, {}->{target}.", + stored.source, stored.target + )); + storage.update(index, source, target)?; + let after = storage + .get_link(index) + .unwrap_or_else(|| Link::new(index, source, target)); + changes.push((Some(stored), Some(after))); + } else { + self.trace_msg(&format!( + "[EnsureLinkCreated] Link {index} is already correct => no-op." + )); + changes.push((Some(stored), Some(stored))); } + Ok(index) + } - let simplified = simplify_changes(to_simplify); - - let mut result: Vec<(Option, Option)> = non_simplifiable; - for (b, a) in simplified { - result.push((Some(b), Some(a))); + /// `EnsureLinkCreated` for a definition with no address of its own: the + /// existing doublet is reused and reported as an unchanged pair, and only a + /// genuinely new one reports a creation. + fn ensure_doublet( + &self, + storage: &mut impl NamedTypeLinks, + source: u32, + target: u32, + changes: &mut Vec<(Option, Option)>, + ) -> u32 { + if let Some(existing_id) = storage.search(source, target) { + self.trace_msg(&format!( + "[EnsureLinkCreated] Link already found => ID={existing_id} => no-op." + )); + let existing = storage + .get_link(existing_id) + .unwrap_or_else(|| Link::new(existing_id, source, target)); + changes.push((Some(existing), Some(existing))); + existing_id + } else { + self.trace_msg(&format!( + "[EnsureLinkCreated] Creating link for (S={source}, T={target})." + )); + let created = storage.create(source, target); + changes.push((None, storage.get_link(created))); + created } + } - result + /// Simplifies the changes list. + /// + /// A missing side — the state before a creation, or the state after a + /// deletion — becomes the null link `(0: 0 0)` on the way in and turns back + /// into `None` on the way out. C# has no option type here and feeds the + /// simplifier `default(Link)` for both, so routing the null states + /// around the simplifier (as this used to) both reported creations and + /// deletions in a different order than C# and hid them from the chain + /// collapsing that is the whole point of the pass. + fn simplify_changes_list( + &self, + changes: &[(Option, Option)], + ) -> Vec<(Option, Option)> { + let to_simplify: Vec<(Link, Link)> = changes + .iter() + .map(|(before, after)| { + ( + before.unwrap_or_else(Link::null), + after.unwrap_or_else(Link::null), + ) + }) + .collect(); + + simplify_changes(to_simplify) + .into_iter() + .map(|(before, after)| { + ( + (!before.is_null()).then_some(before), + (!after.is_null()).then_some(after), + ) + }) + .collect() } /// Logs a trace message if tracing is enabled diff --git a/rust/src/transactions/mod.rs b/rust/src/transactions/mod.rs index 444578a..1dbcb08 100644 --- a/rust/src/transactions/mod.rs +++ b/rust/src/transactions/mod.rs @@ -359,13 +359,31 @@ where } pub fn delete(&mut self, id: T) -> Result, LinkError> { + self.delete_observed(id, &mut |_, _| {}) + } + + /// [`Self::delete`], reporting every change the underlying store made. + /// + /// Deleting a link cascades into every link that still used it, and those + /// deletions are changes of their own: the C# CLI hands + /// `AdvancedMixedQueryProcessor.RemoveLinks` a handler that `links.Delete` + /// calls once per removed link, so `--changes` lists the usages too. The + /// observer is the same seam, threaded through the decorator stack. + pub fn delete_observed( + &mut self, + id: T, + observer: &mut dyn FnMut(GenericLink, GenericLink), + ) -> Result, LinkError> { if self.replaying { - return self.inner.delete_link(id); + let deleted = self.inner.delete_link(id)?; + observer(deleted, GenericLink::null()); + return Ok(deleted); } let before = self.snapshot(id); let owns = self.ensure_open_transaction(); let mut observed: Vec> = Vec::new(); let outcome = self.inner.delete_link_observed(id, &mut |before, after| { + observer(before, after); record_observed(&mut observed, before, after) }); let deleted = match outcome { diff --git a/rust/src/version_control/mod.rs b/rust/src/version_control/mod.rs index 68a70fa..88b70c9 100644 --- a/rust/src/version_control/mod.rs +++ b/rust/src/version_control/mod.rs @@ -17,6 +17,7 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Result}; use crate::link::Link; +use crate::link_storage::ChangeObserver; use crate::named_types::{NamedTypes, NamedTypesDecorator}; use crate::transactions::{TransactionHandle, TransactionsDecorator, Transition}; @@ -211,8 +212,14 @@ impl VersionControlDecorator { } pub fn delete(&mut self, id: u32) -> Result { + self.delete_observed(id, &mut |_, _| {}) + } + + /// [`Self::delete`], reporting every change the decorator stack made — + /// cascaded deletions of usages included. + pub fn delete_observed(&mut self, id: u32, observer: ChangeObserver<'_>) -> Result { let before_seq = self.transactions.last_logged_sequence(); - let result = self.transactions.delete(id)?; + let result = self.transactions.delete_observed(id, observer)?; if self.active_transaction.is_none() { let branch = self.current_branch.clone(); self.attribute_new_transitions_for_branch(before_seq, &branch)?; From 1cec228217692b46f7a3f9f3f09dd69d464ceeea Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 17:59:36 +0000 Subject: [PATCH 10/20] docs(rust): add changelog fragments for the CLI triggers and the parity fixes --- rust/changelog.d/20260829_180000_issue_100_trigger_cli.md | 5 +++++ .../20260829_181000_issue_100_address_allocation.md | 5 +++++ rust/changelog.d/20260829_182000_issue_100_changes_parity.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 rust/changelog.d/20260829_180000_issue_100_trigger_cli.md create mode 100644 rust/changelog.d/20260829_181000_issue_100_address_allocation.md create mode 100644 rust/changelog.d/20260829_182000_issue_100_changes_parity.md diff --git a/rust/changelog.d/20260829_180000_issue_100_trigger_cli.md b/rust/changelog.d/20260829_180000_issue_100_trigger_cli.md new file mode 100644 index 0000000..a8dfc4d --- /dev/null +++ b/rust/changelog.d/20260829_180000_issue_100_trigger_cli.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Exposed the persistent transformation triggers on the Rust CLI: `--always`, `--once` and `--never` store or remove a trigger, `--triggers`/`--triggers-file` point at the trigger store and `--embed-triggers` keeps the triggers in the main database, matching the C# CLI. diff --git a/rust/changelog.d/20260829_181000_issue_100_address_allocation.md b/rust/changelog.d/20260829_181000_issue_100_address_allocation.md new file mode 100644 index 0000000..ae82d75 --- /dev/null +++ b/rust/changelog.d/20260829_181000_issue_100_address_allocation.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Fixed address allocation in the Rust storage so that it matches the C# store: a freed address is reused before the store grows, the most recently freed one first, deleting the last link shrinks the store, and ensuring an address gives back the addresses passed over on the way to it. The free list is persisted so the order survives between CLI invocations. diff --git a/rust/changelog.d/20260829_182000_issue_100_changes_parity.md b/rust/changelog.d/20260829_182000_issue_100_changes_parity.md new file mode 100644 index 0000000..e90fa50 --- /dev/null +++ b/rust/changelog.d/20260829_182000_issue_100_changes_parity.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Fixed `--changes` reporting in the Rust CLI: an auto-created reference is now reported as an update of the placeholder it started from, a delete reports the whole cascade of removed usages, and the reported changes are emitted in a reproducible order instead of one derived from the hash seed of the process. From 84560a27e0fe1a12160c2fab06895ab00a5abdc5 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 18:02:42 +0000 Subject: [PATCH 11/20] test(rust): cover the trigger CLI flags end to end --- rust/tests/cli_triggers_tests.rs | 267 +++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 rust/tests/cli_triggers_tests.rs diff --git a/rust/tests/cli_triggers_tests.rs b/rust/tests/cli_triggers_tests.rs new file mode 100644 index 0000000..ca8a8fa --- /dev/null +++ b/rust/tests/cli_triggers_tests.rs @@ -0,0 +1,267 @@ +//! End-to-end CLI tests for the persistent transformation triggers wired up in +//! main.rs: where a trigger is stored, when it fires, and when it stops firing. +//! +//! The expected outputs are the ones the C# CLI produces for the same +//! invocations; see docs/case-studies/issue-100/evidence/cli-parity. + +use anyhow::{ensure, Result}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use tempfile::tempdir; + +fn clink() -> Command { + Command::new(env!("CARGO_BIN_EXE_clink")) +} + +fn run(db: &Path, args: &[&str]) -> Result { + let mut command = clink(); + command.arg("--db").arg(db); + for arg in args { + command.arg(arg); + } + Ok(command.output()?) +} + +fn run_ok(db: &Path, args: &[&str]) -> Result { + let output = run(db, args)?; + ensure!( + output.status.success(), + "clink {args:?} failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// The database as `--after` prints it, which is what a scenario compares. +fn dump(db: &Path) -> Result { + run_ok(db, &["--after"]) +} + +fn triggers_sidecar_for(db: &Path) -> PathBuf { + let stem = db.file_stem().unwrap().to_string_lossy().into_owned(); + db.parent().unwrap().join(format!("{stem}.triggers.links")) +} + +/// A database holding the two point links the trigger scenarios start from. +fn seeded_database(db: &Path) -> Result<()> { + run_ok(db, &["--query", "() ((1 1) (2 2))"])?; + Ok(()) +} + +/// The trigger every scenario stores: whenever link 1 is the point `(1 1)`, +/// repoint it at link 2. +const TRIGGER: &str = "(((1: 1 1)) ((1: 1 2)))"; + +#[test] +fn always_trigger_fires_on_a_later_write() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + seeded_database(&db)?; + + let stored = run_ok(&db, &["--always", TRIGGER])?; + assert!( + stored.starts_with("Always persistent transformation trigger stored: "), + "storing a trigger should report the address it was stored at; got:\n{stored}" + ); + + // The trigger is not asked to fire here; the write that follows is what + // gives it the chance to. + assert_eq!(dump(&db)?, "(1: 1 1)\n(2: 2 2)\n"); + + run_ok(&db, &["--query", "() ((2 1))"])?; + assert_eq!(dump(&db)?, "(1: 1 2)\n(2: 2 2)\n(3: 2 1)\n"); + Ok(()) +} + +#[test] +fn always_trigger_keeps_firing() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + seeded_database(&db)?; + run_ok(&db, &["--always", TRIGGER])?; + run_ok(&db, &["--query", "() ((2 1))"])?; + + // Undo what the trigger did, then write again: it fires a second time. + run_ok(&db, &["--query", "((1: 1 2)) ((1: 1 1))"])?; + run_ok(&db, &["--query", "() ((2 2))"])?; + assert_eq!(dump(&db)?, "(1: 1 2)\n(2: 2 2)\n(3: 2 1)\n"); + Ok(()) +} + +#[test] +fn once_trigger_fires_only_once() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + seeded_database(&db)?; + + let stored = run_ok(&db, &["--once", TRIGGER])?; + assert!( + stored.starts_with("Once persistent transformation trigger stored: "), + "storing a trigger should report the address it was stored at; got:\n{stored}" + ); + + run_ok(&db, &["--query", "() ((2 1))"])?; + assert_eq!(dump(&db)?, "(1: 1 2)\n(2: 2 2)\n(3: 2 1)\n"); + + // Same undo and write as in the --always scenario; this time nothing + // repoints link 1, because firing consumed the trigger. + run_ok(&db, &["--query", "((1: 1 2)) ((1: 1 1))"])?; + run_ok(&db, &["--query", "() ((2 2))"])?; + assert_eq!(dump(&db)?, "(1: 1 1)\n(2: 2 2)\n(3: 2 1)\n"); + Ok(()) +} + +#[test] +fn never_removes_a_stored_trigger() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + seeded_database(&db)?; + run_ok(&db, &["--always", TRIGGER])?; + + let removed = run_ok(&db, &["--never", TRIGGER])?; + assert_eq!(removed, "Persistent transformation triggers removed: 1\n"); + + run_ok(&db, &["--query", "() ((2 1))"])?; + assert_eq!(dump(&db)?, "(1: 1 1)\n(2: 2 2)\n(3: 2 1)\n"); + Ok(()) +} + +#[test] +fn never_on_an_empty_trigger_store_removes_nothing() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + seeded_database(&db)?; + + let removed = run_ok(&db, &["--never", TRIGGER])?; + assert_eq!(removed, "Persistent transformation triggers removed: 0\n"); + Ok(()) +} + +#[test] +fn triggers_are_stored_in_the_sidecar_next_to_the_database() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + seeded_database(&db)?; + run_ok(&db, &["--always", TRIGGER])?; + + assert!( + triggers_sidecar_for(&db).exists(), + "the default trigger store belongs next to the database" + ); + // The trigger lives there and not in the database it guards. + assert_eq!(dump(&db)?, "(1: 1 1)\n(2: 2 2)\n"); + Ok(()) +} + +#[test] +fn triggers_file_puts_the_store_where_it_is_asked_to() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + let triggers = dir.path().join("elsewhere.links"); + seeded_database(&db)?; + + run_ok( + &db, + &[ + "--triggers-file", + triggers.to_str().unwrap(), + "--always", + TRIGGER, + ], + )?; + assert!(triggers.exists(), "--triggers-file should be honoured"); + assert!( + !triggers_sidecar_for(&db).exists(), + "the default sidecar should not be created as well" + ); + + // A store somewhere else has to be pointed at again to keep firing. + run_ok(&db, &["--query", "() ((2 1))"])?; + assert_eq!(dump(&db)?, "(1: 1 1)\n(2: 2 2)\n(3: 2 1)\n"); + + run_ok( + &db, + &[ + "--triggers-file", + triggers.to_str().unwrap(), + "--query", + "() ((2 3))", + ], + )?; + assert_eq!(dump(&db)?, "(1: 1 2)\n(2: 2 2)\n(3: 2 1)\n(4: 2 3)\n"); + Ok(()) +} + +#[test] +fn embedded_triggers_stay_in_the_main_database() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + seeded_database(&db)?; + + run_ok(&db, &["--embed-triggers", "--always", TRIGGER])?; + assert!( + !triggers_sidecar_for(&db).exists(), + "--embed-triggers should not create a sidecar" + ); + + let dumped = dump(&db)?; + assert!( + dumped.contains("(Always: Always Always)"), + "the trigger schema belongs in the database itself; got:\n{dumped}" + ); + + run_ok(&db, &["--embed-triggers", "--query", "() ((2 1))"])?; + assert!( + dump(&db)?.contains("(1: 1 2)\n"), + "an embedded trigger fires like a sidecar one" + ); + Ok(()) +} + +#[test] +fn an_existing_trigger_store_keeps_firing_without_repeating_the_flag() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + seeded_database(&db)?; + run_ok(&db, &["--always", TRIGGER])?; + + // No --triggers here: the store exists, which is enough. + run_ok(&db, &["--query", "() ((2 1))"])?; + assert_eq!(dump(&db)?, "(1: 1 2)\n(2: 2 2)\n(3: 2 1)\n"); + Ok(()) +} + +#[test] +fn only_one_trigger_command_at_a_time() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + + let output = run(&db, &["--always", "--once", TRIGGER])?; + assert!(!output.status.success(), "two trigger commands is an error"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Only one of --always, --once, or --never"), + "the error should name the flags that conflict; got:\n{stderr}" + ); + Ok(()) +} + +#[test] +fn a_trigger_command_needs_a_query() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + + let output = run(&db, &["--always"])?; + assert!( + !output.status.success(), + "a trigger without a query is an error" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("require a query"), + "the error should say a query is missing; got:\n{stderr}" + ); + Ok(()) +} From 85fa64d0c00c1ffa5d9b1523879df7109f99c6cd Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 18:03:27 +0000 Subject: [PATCH 12/20] docs: drop the stale claim that triggers are C#-only --- README.md | 14 ++++++-------- docs/ARCHITECTURE.md | 8 ++++---- docs/REQUIREMENTS.md | 2 +- rust/README.md | 5 +++-- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 49e5807..055c588 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,11 @@ cargo install link-cli cargo add link-cli ``` -The NuGet CLI tool is the C# implementation and exposes the complete production -command surface, including persistent transformation triggers. The Rust crate -mirrors the core query engine, named references, LiNo import/export, structure -formatting, and the WebAssembly workbench API. Persistent transformation -trigger CLI options currently exist only in the C# tool. +Both implementations expose the same command surface, so a scenario written +against one runs unchanged against the other: the query engine, named +references, LiNo import/export, structure formatting, persistent +transformation triggers, transactions and version control. The Rust crate adds +the WebAssembly workbench API on top. This tool provides all CRUD operations for links using single [substitution operation](https://en.wikipedia.org/wiki/Markov_algorithm) ([ru](https://ru.wikipedia.org/wiki/Нормальный_алгоритм)) which is turing complete. @@ -565,9 +565,7 @@ clink '((1: 2 1) (2: 1 2)) ()' --changes --after ## All options and arguments -The C# NuGet tool supports every option below. The Rust CLI currently supports -the core query, storage, output, import/export, and structure options; trigger -options are C#-only for now. +Both the C# NuGet tool and the Rust CLI support every option below. | Parameter | Type | Default Value | Aliases | Description | |-------------------------|---------|----------------|-------------------------------------|----------------------------------------------------------------------------| diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9d23b16..29c3611 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -82,14 +82,14 @@ Key files: Main Rust dependencies: -- `doublets = "0.4.0"` for links storage foundations. +- `doublets = "0.5.0"` for links storage foundations. - `links-notation = "0.16.1"` for LiNo parsing. - `lino-arguments = "0.3.0"` for argument initialization compatibility. - `anyhow` and `thiserror` for error handling. -The Rust CLI currently supports the core query, storage, import/export, -structure, output, named-reference, and auto-create options. Persistent -transformation trigger CLI options are implemented in C# only. +The Rust CLI supports the same options as the C# one: query, storage, +import/export, structure, output, named-reference, auto-create, persistent +transformation trigger, transaction and version-control options. ## WebAssembly Workbench diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 8879339..cf389bd 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -90,7 +90,7 @@ The README should remain the quick-start document and show: - Equivalent named-reference examples. - Variables, wildcard/deep patterns, and deduplication behavior. - Import, export, structure formatting, output flags, and storage files. -- Trigger options and their C#-only status. +- Trigger options, which both implementations support. - Links to deeper architecture and behavior documentation. The deeper docs should explain: diff --git a/rust/README.md b/rust/README.md index 62af0f8..5a06446 100644 --- a/rust/README.md +++ b/rust/README.md @@ -7,8 +7,9 @@ This directory contains the Rust `link-cli` crate, which publishes both a reusable `[lib]` (`link_cli`) and the `clink` `[[bin]]` from the same -package. It mirrors the core query processor, named references, LiNo -import/export, and structure formatting used by the production C# tool. +package. It mirrors the production C# tool: the query processor, named +references, LiNo import/export, structure formatting, persistent +transformation triggers, transactions and version control. The WebAssembly wrapper crate lives in `rust/wasm/` and depends on this package. From f180a634abb6d5bf624b0a94da81616c59b33008 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 18:05:59 +0000 Subject: [PATCH 13/20] feat(rust): make the whole library reachable from outside the crate --- .../20260829_190000_issue_100_public_api.md | 5 ++ rust/src/lib.rs | 56 ++++++++++++------- rust/src/link_reference_validator.rs | 13 ++++- rust/src/link_storage.rs | 2 +- rust/src/lino_link.rs | 2 +- rust/src/named_links.rs | 2 +- rust/src/named_type_links.rs | 9 ++- rust/src/query_options.rs | 2 + rust/src/query_processor_substitution.rs | 5 +- rust/src/query_types.rs | 30 +++++----- rust/src/transactions/mod.rs | 2 +- rust/src/transactions/types.rs | 14 ++--- rust/src/version_control/mod.rs | 2 +- 13 files changed, 95 insertions(+), 49 deletions(-) create mode 100644 rust/changelog.d/20260829_190000_issue_100_public_api.md diff --git a/rust/changelog.d/20260829_190000_issue_100_public_api.md b/rust/changelog.d/20260829_190000_issue_100_public_api.md new file mode 100644 index 0000000..743cbc0 --- /dev/null +++ b/rust/changelog.d/20260829_190000_issue_100_public_api.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Made every module of the `link_cli` library public, along with the query patterns, the resolved links, the link reference validator and the transition wire-format constants, so an alternative CLI or an embedding application can reuse and replace the same pieces `clink` is built from instead of reimplementing them. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index dddf544..2f867a7 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -6,6 +6,10 @@ //! //! # Modules //! +//! Every module is public: `clink` is one front end over this library, and an +//! application that needs a different one should be able to reach the same +//! pieces rather than reimplement them. +//! //! - `link` - The core Link data structure //! - `error` - Error types for link operations //! - `lino_link` - LiNo link representation @@ -14,32 +18,46 @@ //! - `storage` - Reusable storage traits and the doublets-backed store //! - `changes_simplifier` - Changes simplification //! - `query_processor` - LiNo query processing +//! - `query_types` - Patterns and resolved links, the shapes a query passes through +//! - `link_reference_validator` - Checking, and optionally creating, referenced links +//! - `named_type_links` - The storage interface every layer is written against +//! - `named_types`, `pinned_types` - Named and pinned type decorators //! - `persistent_transformations` - Persistent transformation triggers +//! - `transactions`, `version_control` - Reversible transitions, branches and tags +//! - `cli` - Argument parsing, so a custom tool can accept the same options +//! +//! # Extending +//! +//! [`NamedTypeLinks`] is the seam: every layer of the stack is written against +//! it, and every decorator both implements it and wraps another implementation +//! of it. A layer of your own -- a cache, an access check, a remote store -- +//! implements the trait and slots in anywhere, including under +//! [`QueryProcessor`], which never learns what is beneath it. -mod changes_simplifier; +pub mod changes_simplifier; pub mod cli; -mod error; -mod hybrid_reference; -mod link; -mod link_reference_validator; -mod link_storage; -mod link_storage_doublets; -mod lino_database_input; -mod lino_link; -mod named_links; -mod named_type_links; -mod named_types; -mod parser; +pub mod error; +pub mod hybrid_reference; +pub mod link; +pub mod link_reference_validator; +pub mod link_storage; +pub mod link_storage_doublets; +pub mod lino_database_input; +pub mod lino_link; +pub mod named_links; +pub mod named_type_links; +pub mod named_types; +pub mod parser; pub mod persistent_transformations; -mod pinned_types; -mod query_options; -mod query_processor; -mod query_processor_substitution; -mod query_types; +pub mod pinned_types; +pub mod query_options; +pub mod query_processor; +pub mod query_processor_substitution; +pub mod query_types; pub mod sequences; pub mod storage; pub mod transactions; -mod unicode_string_storage; +pub mod unicode_string_storage; pub mod version_control; /// The `doublets` crate this library is built on, re-exported so diff --git a/rust/src/link_reference_validator.rs b/rust/src/link_reference_validator.rs index 2ee3a95..3317b53 100644 --- a/rust/src/link_reference_validator.rs +++ b/rust/src/link_reference_validator.rs @@ -1,3 +1,10 @@ +//! Checking that every link a query refers to exists, and creating the ones +//! that do not when the caller asked for that. +//! +//! Ported from the C# `LinkReferenceValidator`, and public for the same reason +//! the query processor is: a custom front end that resolves references its own +//! way needs to be able to reuse, or replace, exactly this step. + use anyhow::Result; use std::collections::HashSet; @@ -6,7 +13,7 @@ use crate::link::Link; use crate::lino_link::LinoLink; use crate::named_type_links::NamedTypeLinks; -pub(crate) struct LinkReferenceValidator { +pub struct LinkReferenceValidator { trace: bool, auto_create_missing_references: bool, } @@ -51,14 +58,14 @@ impl MissingLinkReference { } impl LinkReferenceValidator { - pub(crate) fn new(trace: bool, auto_create_missing_references: bool) -> Self { + pub fn new(trace: bool, auto_create_missing_references: bool) -> Self { Self { trace, auto_create_missing_references, } } - pub(crate) fn validate_links_exist_or_will_be_created( + pub fn validate_links_exist_or_will_be_created( &self, storage: &mut impl NamedTypeLinks, restriction_patterns: &[LinoLink], diff --git a/rust/src/link_storage.rs b/rust/src/link_storage.rs index 6ba2d18..0930007 100644 --- a/rust/src/link_storage.rs +++ b/rust/src/link_storage.rs @@ -337,7 +337,7 @@ impl LinkStorage { /// Creates a new link and returns its ID /// - /// The address is the one [`LinkStorage::allocate`] hands out: a freed one + /// The address is the one `allocate` hands out: a freed one /// when the store has any, and only otherwise a fresh one past the end. pub fn create(&mut self, source: u32, target: u32) -> u32 { let id = self.allocate(); diff --git a/rust/src/lino_link.rs b/rust/src/lino_link.rs index 6265abb..fa98f1a 100644 --- a/rust/src/lino_link.rs +++ b/rust/src/lino_link.rs @@ -4,7 +4,7 @@ //! a parsed link from LiNo notation. /// LinoLink represents a parsed link from LiNo notation -/// Corresponds to Link.Foundation.Links.Notation.Link in C# +/// Corresponds to `Link.Foundation.Links.Notation.Link` in C# #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct LinoLink { /// The ID/name of this link (can be a number, variable, or name) diff --git a/rust/src/named_links.rs b/rust/src/named_links.rs index 4e7de18..b9f0f3c 100644 --- a/rust/src/named_links.rs +++ b/rust/src/named_links.rs @@ -14,7 +14,7 @@ impl<'a> NamedLinks<'a> { Ok(UnicodeStringStorage::new(links)?.into_named_links()) } - pub(crate) fn from_storage(storage: UnicodeStringStorage<'a>) -> Self { + pub fn from_storage(storage: UnicodeStringStorage<'a>) -> Self { Self { storage } } diff --git a/rust/src/named_type_links.rs b/rust/src/named_type_links.rs index 40119c3..e68f70d 100644 --- a/rust/src/named_type_links.rs +++ b/rust/src/named_type_links.rs @@ -1,3 +1,10 @@ +//! [`NamedTypeLinks`], the storage interface every layer of the CLI is written +//! against, and its implementations for the plain store and for each decorator. +//! +//! A store that implements this trait can be dropped anywhere in the stack, so +//! an application can add its own layer -- a cache, a permission check, a +//! remote store -- without the query processor knowing about it. + use anyhow::{Context, Result}; use std::collections::HashSet; use std::fs::OpenOptions; @@ -422,7 +429,7 @@ impl NamedTypeLinks for crate::version_control::VersionControlDecorator { } } -pub(crate) fn escape_lino_reference(reference: &str) -> String { +pub fn escape_lino_reference(reference: &str) -> String { if reference.is_empty() || reference.trim().is_empty() { return String::new(); } diff --git a/rust/src/query_options.rs b/rust/src/query_options.rs index 7d2ea11..189e07f 100644 --- a/rust/src/query_options.rs +++ b/rust/src/query_options.rs @@ -1,3 +1,5 @@ +//! The options one query is processed with. + /// Options for query processing pub struct QueryOptions { pub query: String, diff --git a/rust/src/query_processor_substitution.rs b/rust/src/query_processor_substitution.rs index 275fd10..806dc33 100644 --- a/rust/src/query_processor_substitution.rs +++ b/rust/src/query_processor_substitution.rs @@ -1,3 +1,6 @@ +//! The substitution half of [`QueryProcessor`]: turning a matched restriction +//! plus a substitution pattern into the links to write. + use anyhow::Result; use std::collections::{HashMap, HashSet}; @@ -8,7 +11,7 @@ use crate::query_processor::QueryProcessor; use crate::query_types::Pattern; impl QueryProcessor { - pub(crate) fn preserve_existing_substitution_parts( + pub fn preserve_existing_substitution_parts( storage: &mut impl NamedTypeLinks, pattern: &Pattern, solution: &mut HashMap, diff --git a/rust/src/query_types.rs b/rust/src/query_types.rs index 8b3cb54..e334163 100644 --- a/rust/src/query_types.rs +++ b/rust/src/query_types.rs @@ -1,14 +1,18 @@ +//! The intermediate shapes a query passes through: the [`Pattern`] a +//! restriction or substitution parses into, and the [`ResolvedLink`] a pattern +//! becomes once its variables are bound. + use crate::link::Link; #[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct Pattern { - pub(crate) index: String, - pub(crate) source: Option>, - pub(crate) target: Option>, +pub struct Pattern { + pub index: String, + pub source: Option>, + pub target: Option>, } impl Pattern { - pub(crate) fn new(index: String, source: Option, target: Option) -> Self { + pub fn new(index: String, source: Option, target: Option) -> Self { Self { index, source: source.map(Box::new), @@ -16,21 +20,21 @@ impl Pattern { } } - pub(crate) fn is_leaf(&self) -> bool { + pub fn is_leaf(&self) -> bool { self.source.is_none() && self.target.is_none() } } #[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ResolvedLink { - pub(crate) index: u32, - pub(crate) source: u32, - pub(crate) target: u32, - pub(crate) name: Option, +pub struct ResolvedLink { + pub index: u32, + pub source: u32, + pub target: u32, + pub name: Option, } impl ResolvedLink { - pub(crate) fn new(index: u32, source: u32, target: u32, name: Option) -> Self { + pub fn new(index: u32, source: u32, target: u32, name: Option) -> Self { Self { index, source, @@ -39,7 +43,7 @@ impl ResolvedLink { } } - pub(crate) fn to_link(&self) -> Link { + pub fn to_link(&self) -> Link { Link::new(self.index, self.source, self.target) } } diff --git a/rust/src/transactions/mod.rs b/rust/src/transactions/mod.rs index 1dbcb08..0315215 100644 --- a/rust/src/transactions/mod.rs +++ b/rust/src/transactions/mod.rs @@ -9,7 +9,7 @@ //! crash recovery (R1-R7, R10). //! //! Optional — when not opted in, the bare -//! [`NamedTypesDecorator`](crate::NamedTypesDecorator) behaves +//! [`NamedTypesDecorator`] behaves //! identically (R8, R9, R17). //! //! # Reuse outside the CLI diff --git a/rust/src/transactions/types.rs b/rust/src/transactions/types.rs index 5c3df0c..3fc025d 100644 --- a/rust/src/transactions/types.rs +++ b/rust/src/transactions/types.rs @@ -22,7 +22,7 @@ pub enum TransitionKind { } impl TransitionKind { - pub(crate) fn as_u8(self) -> u8 { + pub fn as_u8(self) -> u8 { match self { TransitionKind::Create => 0, TransitionKind::Update => 1, @@ -30,7 +30,7 @@ impl TransitionKind { } } - pub(crate) fn from_u8(value: u8) -> Option { + pub fn from_u8(value: u8) -> Option { match value { 0 => Some(TransitionKind::Create), 1 => Some(TransitionKind::Update), @@ -196,7 +196,7 @@ pub struct GenericTransition { pub type Transition = GenericTransition; impl GenericTransition { - pub(crate) const SCHEMA_VERSION: &'static str = "v1"; + pub const SCHEMA_VERSION: &'static str = "v1"; /// Encodes the transition as a single line stored as one entry of /// the transitions log. @@ -278,7 +278,7 @@ fn parse_address(text: &str) -> Result { } /// Sidecar-store name prefixes used by the recovery protocol. -pub(crate) const COMMIT_MARKER_PREFIX: &str = "__transactions:commit:"; -pub(crate) const ROLLBACK_MARKER_PREFIX: &str = "__transactions:rollback:"; -pub(crate) const APPLIED_MARKER_PREFIX: &str = "__transactions:applied:"; -pub(crate) const TRANSITION_NAME_PREFIX: &str = "__transactions:transition:"; +pub const COMMIT_MARKER_PREFIX: &str = "__transactions:commit:"; +pub const ROLLBACK_MARKER_PREFIX: &str = "__transactions:rollback:"; +pub const APPLIED_MARKER_PREFIX: &str = "__transactions:applied:"; +pub const TRANSITION_NAME_PREFIX: &str = "__transactions:transition:"; diff --git a/rust/src/version_control/mod.rs b/rust/src/version_control/mod.rs index 88b70c9..26fd9df 100644 --- a/rust/src/version_control/mod.rs +++ b/rust/src/version_control/mod.rs @@ -3,7 +3,7 @@ //! Mirrors the C# `VersionControlDecorator` in //! `csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs`. //! -//! Sits above the [`TransactionsDecorator`](crate::transactions::TransactionsDecorator) +//! Sits above the [`TransactionsDecorator`] //! and adds *time travel* ([`checkout`](VersionControlDecorator::checkout)), //! *branching* ([`branch`](VersionControlDecorator::branch), //! [`switch_branch`](VersionControlDecorator::switch_branch)), and From db4ce7ece58ae58d9007444c8204715e8044dc60 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 18:18:12 +0000 Subject: [PATCH 14/20] feat(csharp): keep every decorator open for extension Unseal the decorators, mark their public members virtual, and give the disposable ones the protected virtual Dispose(bool) pattern so a subclass can release resources of its own. Publish the trigger query record and the internal name prefix, which the Rust library already exposes. ExtensibilityTests subclasses four of the decorators and asserts the seam reflectively, so re-sealing a class or dropping a virtual fails the suite. --- .../issue-100-open-for-extension.md | 5 + .../NamedLinks.cs | 16 +- .../NamedLinksDecorator.cs | 22 +- .../NamedTypesDecorator.cs | 26 +- .../PersistentTransformationDecorator.cs | 27 +- .../PinnedTypes.cs | 4 +- .../PinnedTypesDecorator.cs | 4 +- .../SimpleLinksDecorator.cs | 14 +- .../TransactionsDecorator.cs | 22 +- .../UnicodeStringStorage.cs | 8 +- .../VersionControlDecorator.cs | 46 +-- .../ExtensibilityTests.cs | 267 ++++++++++++++++++ 12 files changed, 394 insertions(+), 67 deletions(-) create mode 100644 csharp/.changeset/issue-100-open-for-extension.md create mode 100644 csharp/Foundation.Data.Doublets.Cli.Tests/ExtensibilityTests.cs diff --git a/csharp/.changeset/issue-100-open-for-extension.md b/csharp/.changeset/issue-100-open-for-extension.md new file mode 100644 index 0000000..b84814b --- /dev/null +++ b/csharp/.changeset/issue-100-open-for-extension.md @@ -0,0 +1,5 @@ +--- +'Foundation.Data.Doublets.Cli': minor +--- + +Opened the library up for extension: every decorator (`NamedTypesDecorator`, `NamedLinksDecorator`, `SimpleLinksDecorator`, `PinnedTypesDecorator`, `TransactionsDecorator`, `VersionControlDecorator`, `PersistentTransformationDecorator`) is now unsealed with overridable members, disposable ones follow the `protected virtual void Dispose(bool)` pattern so a subclass can release resources of its own, and `PersistentTransformationDecorator.PersistentTransformationQuery` and `InternalNamePrefix` are public. A custom CLI can now subclass any layer of the stack instead of forking it. diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinks.cs b/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinks.cs index 6761581..260137a 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinks.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinks.cs @@ -27,25 +27,25 @@ public NamedLinks( _getString = getString; } - public TLinkAddress SetNameForExternalReference(TLinkAddress link, string name) + public virtual TLinkAddress SetNameForExternalReference(TLinkAddress link, string name) { var reference = new Hybrid(link, isExternal: true); return SetName(reference, name); } - public TLinkAddress SetName(TLinkAddress link, string name) + public virtual TLinkAddress SetName(TLinkAddress link, string name) { var nameSequence = _createString(name); return _links.GetOrCreate(link, _links.GetOrCreate(_nameType, nameSequence)); } - public string? GetNameByExternalReference(TLinkAddress link) + public virtual string? GetNameByExternalReference(TLinkAddress link) { var reference = new Hybrid(link, isExternal: true); return GetName(reference); } - public string? GetName(TLinkAddress link) + public virtual string? GetName(TLinkAddress link) { var any = _links.Constants.Any; var query = new Link(any, link, any); @@ -62,7 +62,7 @@ public TLinkAddress SetName(TLinkAddress link, string name) return null; } - public TLinkAddress GetByName(string name) + public virtual TLinkAddress GetByName(string name) { var nameSequence = _createString(name); var nameLink = _links.SearchOrDefault(_nameType, nameSequence); @@ -80,7 +80,7 @@ public TLinkAddress GetByName(string name) return _links.GetSource(link); } - public TLinkAddress GetExternalReferenceByName(string name) + public virtual TLinkAddress GetExternalReferenceByName(string name) { var nameSequence = _createString(name); var nameLink = _links.SearchOrDefault(_nameType, nameSequence); @@ -103,7 +103,7 @@ public TLinkAddress GetExternalReferenceByName(string name) return _links.Constants.Null; } - public void RemoveName(TLinkAddress link) + public virtual void RemoveName(TLinkAddress link) { var any = _links.Constants.Any; var query = new Link(any, link, any); @@ -124,7 +124,7 @@ public void RemoveName(TLinkAddress link) } } - public void RemoveNameByExternalReference(TLinkAddress externalReference) + public virtual void RemoveNameByExternalReference(TLinkAddress externalReference) { var reference = new Hybrid(externalReference, isExternal: true); RemoveName(reference); diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs index c7e7e17..61a978b 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs @@ -14,7 +14,7 @@ namespace Foundation.Data.Doublets.Cli { - public sealed class NamedLinksDecorator : LinksDecoratorBase, INamedTypesLinks, IDisposable + public class NamedLinksDecorator : LinksDecoratorBase, INamedTypesLinks, IDisposable where TLinkAddress : struct, IUnsignedNumber, IComparisonOperators, @@ -69,9 +69,21 @@ public NamedLinksDecorator(string databaseFilename, bool tracingEnabled = false) /// Releases the memory-mapped file handles of both the data and the names databases. /// public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases the databases this decorator owns. Derived decorators that + /// own extra resources override this and call + /// base.Dispose(disposing). + /// + protected virtual void Dispose(bool disposing) { if (_disposed) return; _disposed = true; + if (!disposing) return; LinksFacadeDisposer.Dispose(_namedLinksFacade); LinksFacadeDisposer.Dispose(_links); } @@ -81,7 +93,7 @@ public void Dispose() /// /// The link address to get the name for. /// The name associated with the link, or null if no name is set. - public string? GetName(TLinkAddress link) + public virtual string? GetName(TLinkAddress link) { if (_tracingEnabled) Console.WriteLine($"[Trace] GetName called for link: {link}"); var result = NamedLinks.GetNameByExternalReference(link); @@ -95,7 +107,7 @@ public void Dispose() /// The link address to name. /// The name to assign to the link. /// The link address representing the name assignment. - public TLinkAddress SetName(TLinkAddress link, string name) + public virtual TLinkAddress SetName(TLinkAddress link, string name) { if (_tracingEnabled) Console.WriteLine($"[Trace] SetName called for link: {link} with name: '{name}'"); // Remove any existing name mapping before setting the new one @@ -110,7 +122,7 @@ public TLinkAddress SetName(TLinkAddress link, string name) /// /// The name to look up. /// The link address associated with the name, or Null if not found. - public TLinkAddress GetByName(string name) + public virtual TLinkAddress GetByName(string name) { if (_tracingEnabled) Console.WriteLine($"[Trace] GetByName called for name: '{name}'"); var result = NamedLinks.GetExternalReferenceByName(name); @@ -122,7 +134,7 @@ public TLinkAddress GetByName(string name) /// Removes the name association for the specified link address. /// /// The link address whose name should be removed. - public void RemoveName(TLinkAddress link) + public virtual void RemoveName(TLinkAddress link) { if (_tracingEnabled) Console.WriteLine($"[Trace] RemoveName called for link: {link}"); NamedLinks.RemoveNameByExternalReference(link); diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs index 8619b8a..5c03369 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs @@ -15,7 +15,7 @@ namespace Foundation.Data.Doublets.Cli { - public sealed class NamedTypesDecorator : LinksDecoratorBase, INamedTypesLinks, IPinnedTypes, IDisposable + public class NamedTypesDecorator : LinksDecoratorBase, INamedTypesLinks, IPinnedTypes, IDisposable where TLinkAddress : struct, IUnsignedNumber, IComparisonOperators, @@ -73,14 +73,26 @@ public NamedTypesDecorator(string databaseFilename, bool tracingEnabled = false) } public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases the names database this decorator owns. Derived decorators + /// that own extra resources override this and call + /// base.Dispose(disposing). + /// + protected virtual void Dispose(bool disposing) { if (_disposed) return; _disposed = true; + if (!disposing) return; LinksFacadeDisposer.Dispose(_namedLinksFacade); LinksFacadeDisposer.Dispose(PinnedTypesDecorator); } - public IEnumerator GetEnumerator() + public virtual IEnumerator GetEnumerator() { return PinnedTypesDecorator.GetEnumerator(); } @@ -90,12 +102,12 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } - public void Deconstruct(out TLinkAddress type1, out TLinkAddress type2, out TLinkAddress type3) + public virtual void Deconstruct(out TLinkAddress type1, out TLinkAddress type2, out TLinkAddress type3) { PinnedTypesDecorator.Deconstruct(out type1, out type2, out type3); } - public string? GetName(TLinkAddress link) + public virtual string? GetName(TLinkAddress link) { if (_tracingEnabled) Console.WriteLine($"[Trace] GetName called for link: {link}"); var result = NamedLinks.GetNameByExternalReference(link); @@ -103,7 +115,7 @@ public void Deconstruct(out TLinkAddress type1, out TLinkAddress type2, out TLin return result; } - public TLinkAddress SetName(TLinkAddress link, string name) + public virtual TLinkAddress SetName(TLinkAddress link, string name) { if (_tracingEnabled) Console.WriteLine($"[Trace] SetName called for link: {link} with name: '{name}'"); var existingLinkWithName = NamedLinks.GetExternalReferenceByName(name); @@ -117,7 +129,7 @@ public TLinkAddress SetName(TLinkAddress link, string name) return result; } - public TLinkAddress GetByName(string name) + public virtual TLinkAddress GetByName(string name) { if (_tracingEnabled) Console.WriteLine($"[Trace] GetByName called for name: '{name}'"); var result = NamedLinks.GetExternalReferenceByName(name); @@ -125,7 +137,7 @@ public TLinkAddress GetByName(string name) return result; } - public void RemoveName(TLinkAddress link) + public virtual void RemoveName(TLinkAddress link) { if (_tracingEnabled) Console.WriteLine($"[Trace] RemoveName called for link: {link}"); NamedLinks.RemoveNameByExternalReference(link); diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs index f5a947d..85e6f45 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs @@ -25,9 +25,13 @@ public sealed record PersistentTransformation( public string Query => $"({Condition} {Substitution})"; } -public sealed class PersistentTransformationDecorator : LinksDecoratorBase, INamedTypesLinks +public class PersistentTransformationDecorator : LinksDecoratorBase, INamedTypesLinks { - private const string InternalNamePrefix = "__persistent_transformation:"; + /// + /// The prefix every name this decorator stores carries, so a custom + /// front end can recognise (and skip) the trigger schema. + /// + public const string InternalNamePrefix = "__persistent_transformation:"; private readonly INamedTypesLinks _namedLinks; private readonly INamedTypesLinks _triggerLinks; @@ -55,7 +59,7 @@ public static string MakeTriggersDatabaseFilename(string databaseFilename) return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.triggers.links"); } - public uint StoreTrigger(PersistentTransformationKind kind, string query) + public virtual uint StoreTrigger(PersistentTransformationKind kind, string query) { var parsed = PersistentTransformationQuery.Parse(query); return WithoutTriggerApplication(() => @@ -73,7 +77,7 @@ public uint StoreTrigger(PersistentTransformationKind kind, string query) }); } - public int RemoveTriggers(string query) + public virtual int RemoveTriggers(string query) { var parsed = PersistentTransformationQuery.Parse(query); return WithoutTriggerApplication(() => @@ -91,7 +95,7 @@ public int RemoveTriggers(string query) }); } - public IReadOnlyList GetTriggers() + public virtual IReadOnlyList GetTriggers() { if (!TryGetSchema(out var schema)) { @@ -155,22 +159,22 @@ public override uint Each(IList? restriction, ReadHandler? handler) return _links.Each(restriction, handler); } - public string? GetName(uint link) + public virtual string? GetName(uint link) { return _namedLinks.GetName(link); } - public uint SetName(uint link, string name) + public virtual uint SetName(uint link, string name) { return _namedLinks.SetName(link, name); } - public uint GetByName(string name) + public virtual uint GetByName(string name) { return _namedLinks.GetByName(name); } - public void RemoveName(uint link) + public virtual void RemoveName(uint link) { _namedLinks.RemoveName(link); } @@ -340,7 +344,10 @@ private void Trace(string message) private readonly record struct TriggerSchema(uint Type, uint Trigger, uint Once, uint Always, uint Condition, uint Substitution); - private sealed record PersistentTransformationQuery(string Condition, string Substitution) + /// + /// The two halves a trigger query parses into. + /// + public sealed record PersistentTransformationQuery(string Condition, string Substitution) { public string Query => $"({Condition} {Substitution})"; diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/PinnedTypes.cs b/csharp/Foundation.Data.Doublets.Cli.Library/PinnedTypes.cs index 6397cb6..7d396b6 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/PinnedTypes.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/PinnedTypes.cs @@ -22,7 +22,7 @@ public PinnedTypes(ILinks links) _links = links; } - public IEnumerator GetEnumerator() + public virtual IEnumerator GetEnumerator() { return new PinnedTypesEnumerator(_links); } @@ -101,7 +101,7 @@ public void Dispose() } } - public void Deconstruct(out TLinkAddress type1, out TLinkAddress type2, out TLinkAddress type3) + public virtual void Deconstruct(out TLinkAddress type1, out TLinkAddress type2, out TLinkAddress type3) { using var enumerator = GetEnumerator(); diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/PinnedTypesDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/PinnedTypesDecorator.cs index e279e52..627e789 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/PinnedTypesDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/PinnedTypesDecorator.cs @@ -22,7 +22,7 @@ public PinnedTypesDecorator(ILinks links) : base(links) _pinnedTypes = new PinnedTypes(links); } - public IEnumerator GetEnumerator() + public virtual IEnumerator GetEnumerator() { return _pinnedTypes.GetEnumerator(); } @@ -32,7 +32,7 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } - public void Deconstruct(out TLinkAddress type1, out TLinkAddress type2, out TLinkAddress type3) + public virtual void Deconstruct(out TLinkAddress type1, out TLinkAddress type2, out TLinkAddress type3) { _pinnedTypes.Deconstruct(out type1, out type2, out type3); } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs index acd4cc3..a6b72e8 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs @@ -12,7 +12,7 @@ namespace Foundation.Data.Doublets.Cli { - public sealed class SimpleLinksDecorator : LinksDecoratorBase, IDisposable + public class SimpleLinksDecorator : LinksDecoratorBase, IDisposable where TLinkAddress : struct, IUnsignedNumber, IComparisonOperators, @@ -67,9 +67,21 @@ public SimpleLinksDecorator(string databaseFilename, bool tracingEnabled = false /// Releases the memory-mapped file handles of both the data and the names databases. /// public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases the databases this decorator owns. Derived decorators that + /// own extra resources override this and call + /// base.Dispose(disposing). + /// + protected virtual void Dispose(bool disposing) { if (_disposed) return; _disposed = true; + if (!disposing) return; LinksFacadeDisposer.Dispose(_namedLinksFacade); LinksFacadeDisposer.Dispose(_links); } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs index f76a5b5..93bb26d 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs @@ -274,7 +274,7 @@ public IReadOnlyList> Log public long AppliedSequence { get { lock (_lock) return _appliedSequence; } } public long LastLoggedSequence { get { lock (_lock) return _sequenceCounter; } } - public ITransaction BeginTransaction() + public virtual ITransaction BeginTransaction() { lock (_lock) { @@ -288,7 +288,7 @@ public ITransaction BeginTransaction() } } - public Task> BeginTransactionAsync(CancellationToken cancellationToken = default) + public virtual Task> BeginTransactionAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(BeginTransaction()); @@ -428,14 +428,14 @@ private void RecordTransition(Transaction transaction, TransitionKind kind, Link // INamedTypes forwarding ------------------------------------------------ - public string? GetName(TLinkAddress link) => _inner.GetName(link); - public TLinkAddress SetName(TLinkAddress link, string name) => _inner.SetName(link, name); - public TLinkAddress GetByName(string name) => _inner.GetByName(name); - public void RemoveName(TLinkAddress link) => _inner.RemoveName(link); + public virtual string? GetName(TLinkAddress link) => _inner.GetName(link); + public virtual TLinkAddress SetName(TLinkAddress link, string name) => _inner.SetName(link, name); + public virtual TLinkAddress GetByName(string name) => _inner.GetByName(name); + public virtual void RemoveName(TLinkAddress link) => _inner.RemoveName(link); // Recovery -------------------------------------------------------------- - public void Recover() + public virtual void Recover() { lock (_lock) { @@ -561,7 +561,7 @@ protected virtual void Dispose(bool disposing) /// Stops the background worker. Kept as a named method for backwards /// compatibility; delegates to it. /// - public void Shutdown() + public virtual void Shutdown() { if (_disposed) return; _disposed = true; @@ -665,7 +665,7 @@ private void TryRevertTransition(Transition transition) /// decorators (e.g. version control) that need to drive replay/rewind /// without producing additional transitions. /// - public void RevertTransition(Transition transition) + public virtual void RevertTransition(Transition transition) { lock (_lock) { @@ -687,7 +687,7 @@ public void RevertTransition(Transition transition) /// decorators (e.g. version control) that need to drive replay/rewind /// without producing additional transitions. /// - public void ApplyTransition(Transition transition) + public virtual void ApplyTransition(Transition transition) { lock (_lock) { @@ -972,7 +972,7 @@ public void Dispose() /// (or any other ) /// use the generic form directly. /// -public sealed class TransactionsDecorator : TransactionsDecorator +public class TransactionsDecorator : TransactionsDecorator { /// public TransactionsDecorator( diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/UnicodeStringStorage.cs b/csharp/Foundation.Data.Doublets.Cli.Library/UnicodeStringStorage.cs index 4b22103..b0b5b8a 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/UnicodeStringStorage.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/UnicodeStringStorage.cs @@ -95,25 +95,25 @@ public UnicodeStringStorage(ILinks links) NamedLinks.SetName(NameType, "Name"); } - public TLinkAddress CreateString(string content) + public virtual TLinkAddress CreateString(string content) { var stringSequence = GetStringSequence(content); return Links.GetOrCreate(StringType, stringSequence); } - public IList?> GetTypes() + public virtual IList?> GetTypes() { var any = Links.Constants.Any; var query = new Link(any, Type, any); return Links.All(query); } - public bool IsType(TLinkAddress address) + public virtual bool IsType(TLinkAddress address) { return Links.GetSource(address) == Type; } - public TLinkAddress GetOrCreateType(string name) + public virtual TLinkAddress GetOrCreateType(string name) { var type = NamedLinks.GetByName(name); if (type == Links.Constants.Null) diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs index 3c431d8..b5400f9 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs @@ -38,7 +38,7 @@ public interface IVersionControlLinks : INamedTypesLinks /// () over the transitions log. Optional — when not /// instantiated the underlying transactions decorator behaves identically. /// -public sealed class VersionControlDecorator : LinksDecoratorBase, IVersionControlLinks, IDisposable +public class VersionControlDecorator : LinksDecoratorBase, IVersionControlLinks, IDisposable { /// Default name of the initial branch (analogous to git's main). public const string DefaultBranchName = "main"; @@ -71,6 +71,18 @@ public sealed class VersionControlDecorator : LinksDecoratorBase, IVersion /// public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Rolls back and releases the open transaction. Derived decorators that + /// own extra resources override this and call + /// base.Dispose(disposing). + /// + protected virtual void Dispose(bool disposing) + { + if (!disposing) return; VersionControlTransaction? active; lock (_lock) { @@ -93,25 +105,25 @@ public VersionControlDecorator( EnsureDefaultBranch(); } - public string CurrentBranch { get { lock (_lock) return _currentBranch; } } - public long CurrentSequence { get { lock (_lock) return _currentApplied; } } + public virtual string CurrentBranch { get { lock (_lock) return _currentBranch; } } + public virtual long CurrentSequence { get { lock (_lock) return _currentApplied; } } - public IReadOnlyList ListBranches() + public virtual IReadOnlyList ListBranches() { lock (_lock) return _branches.Values.OrderBy(b => b.Name, StringComparer.Ordinal).ToArray(); } - public IReadOnlyDictionary ListTags() + public virtual IReadOnlyDictionary ListTags() { lock (_lock) return new Dictionary(_tags, StringComparer.Ordinal); } - public bool TryGetTag(string name, out long sequence) + public virtual bool TryGetTag(string name, out long sequence) { lock (_lock) return _tags.TryGetValue(name, out sequence); } - public ITransaction BeginTransaction() + public virtual ITransaction BeginTransaction() { lock (_lock) { @@ -128,7 +140,7 @@ public ITransaction BeginTransaction() } } - public Task> BeginTransactionAsync(CancellationToken cancellationToken = default) + public virtual Task> BeginTransactionAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(BeginTransaction()); @@ -190,7 +202,7 @@ private void AttributeNewTransitionsLocked(long beforeSeq, string branchName) // -- Branching --------------------------------------------------------- - public void Branch(string name, long? from = null) + public virtual void Branch(string name, long? from = null) { if (string.IsNullOrWhiteSpace(name)) { @@ -222,7 +234,7 @@ public void Branch(string name, long? from = null) } } - public void SwitchBranch(string name) + public virtual void SwitchBranch(string name) { lock (_lock) { @@ -237,7 +249,7 @@ public void SwitchBranch(string name) } } - public void Checkout(long sequence) + public virtual void Checkout(long sequence) { lock (_lock) { @@ -256,7 +268,7 @@ public void Checkout(long sequence) } } - public void Tag(string name, long? sequence = null) + public virtual void Tag(string name, long? sequence = null) { if (string.IsNullOrWhiteSpace(name)) { @@ -503,7 +515,7 @@ private static bool TryDecodeBranchMarker(string text, out BranchInfo info) return true; } - public void Recover() + public virtual void Recover() { lock (_lock) { @@ -578,10 +590,10 @@ public void Recover() // -- INamedTypes forwarding ------------------------------------------- - public string? GetName(uint link) => _transactions.GetName(link); - public uint SetName(uint link, string name) => _transactions.SetName(link, name); - public uint GetByName(string name) => _transactions.GetByName(name); - public void RemoveName(uint link) => _transactions.RemoveName(link); + public virtual string? GetName(uint link) => _transactions.GetName(link); + public virtual uint SetName(uint link, string name) => _transactions.SetName(link, name); + public virtual uint GetByName(string name) => _transactions.GetByName(name); + public virtual void RemoveName(uint link) => _transactions.RemoveName(link); // -- Convenience ------------------------------------------------------ diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/ExtensibilityTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/ExtensibilityTests.cs new file mode 100644 index 0000000..fef6b83 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/ExtensibilityTests.cs @@ -0,0 +1,267 @@ +using System.Reflection; + +using Platform.Data.Doublets; + +namespace Foundation.Data.Doublets.Cli.Tests.Tests +{ + /// + /// Every decorator the library ships is a seam: an application that needs + /// behaviour of its own subclasses one instead of forking the stack. These + /// tests take that seam for a ride, so re-sealing a class or dropping a + /// virtual breaks the build rather than silently narrowing the API. + /// + public class ExtensibilityTests + { + [Fact] + public void NamedTypesDecoratorCanBeSubclassed() + { + RunWithFiles(dataFile => + { + CountingNamedTypes subclass; + using (subclass = new CountingNamedTypes(dataFile)) + { + var link = subclass.CreateAndUpdate(subclass.Constants.Null, subclass.Constants.Null); + subclass.SetName(link, "answer"); + + Assert.Equal("answer", subclass.GetName(link)); + Assert.Equal(link, subclass.GetByName("answer")); + } + + Assert.Equal(1, subclass.NamesSet); + Assert.Equal(1, subclass.NamesRead); + Assert.Equal(1, subclass.Lookups); + Assert.True(subclass.Disposed, "Dispose() must reach the derived Dispose(bool) override."); + }); + } + + [Fact] + public void TransactionsDecoratorCanBeSubclassed() + { + RunWithFiles((dataFile, logFile) => + { + using var dataLinks = new NamedTypesDecorator(dataFile); + using var logLinks = new NamedTypesDecorator(logFile); + using var subclass = new CountingTransactions(dataLinks, logLinks); + + using (var transaction = subclass.BeginTransaction()) + { + subclass.CreateAndUpdate(subclass.Constants.Null, subclass.Constants.Null); + transaction.Commit(); + } + + Assert.Equal(1, subclass.TransactionsBegun); + }); + } + + [Fact] + public void PersistentTransformationDecoratorCanBeSubclassed() + { + RunWithFiles((dataFile, triggerFile) => + { + using var dataLinks = new NamedTypesDecorator(dataFile); + using var triggerLinks = new NamedTypesDecorator(triggerFile); + var subclass = new CountingTransformations(dataLinks, triggerLinks); + + subclass.StoreTrigger(PersistentTransformationKind.Always, "(((1: 1 1)) ((1: 1 2)))"); + + Assert.Equal(1, subclass.TriggersStored); + Assert.Single(subclass.GetTriggers()); + }); + } + + [Fact] + public void VersionControlDecoratorCanBeSubclassed() + { + RunWithFiles((dataFile, logFile) => + { + var branchesFile = Path.GetTempFileName(); + try + { + using var dataLinks = new NamedTypesDecorator(dataFile); + using var logLinks = new NamedTypesDecorator(logFile); + using var branchLinks = new NamedTypesDecorator(branchesFile); + using var transactions = new TransactionsDecorator(dataLinks, logLinks); + using var subclass = new CountingVersionControl(transactions, branchLinks); + + subclass.Branch("feature"); + + Assert.Equal(1, subclass.BranchesMade); + Assert.Contains(subclass.ListBranches(), branch => branch.Name == "feature"); + } + finally + { + Cleanup(branchesFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(branchesFile)); + } + }); + } + + /// + /// The composition seams stay open even where no test above happens to + /// subclass them, so a custom front end can replace any layer. + /// + [Theory] + [InlineData(typeof(NamedTypesDecorator))] + [InlineData(typeof(NamedLinksDecorator))] + [InlineData(typeof(SimpleLinksDecorator))] + [InlineData(typeof(PinnedTypesDecorator))] + [InlineData(typeof(TransactionsDecorator))] + [InlineData(typeof(TransactionsDecorator))] + [InlineData(typeof(VersionControlDecorator))] + [InlineData(typeof(PersistentTransformationDecorator))] + [InlineData(typeof(NamedLinks))] + [InlineData(typeof(PinnedTypes))] + [InlineData(typeof(UnicodeStringStorage))] + public void PublicTypesStayOpenForExtension(Type type) + { + Assert.True(type.IsPublic, $"{type.Name} must be public."); + Assert.False(type.IsSealed, $"{type.Name} must stay unsealed so it can be subclassed."); + + var library = typeof(NamedTypesDecorator).Assembly; + var overridable = type + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(method => method.IsVirtual && !method.IsFinal) + .Where(method => method.DeclaringType?.Assembly == library); + Assert.NotEmpty(overridable); + } + + /// + /// Disposable seams follow the Dispose(bool) pattern, which is + /// what lets a subclass release resources of its own. + /// + [Theory] + [InlineData(typeof(NamedTypesDecorator))] + [InlineData(typeof(NamedLinksDecorator))] + [InlineData(typeof(SimpleLinksDecorator))] + [InlineData(typeof(TransactionsDecorator))] + [InlineData(typeof(VersionControlDecorator))] + public void DisposableTypesExposeTheProtectedDisposePattern(Type type) + { + var dispose = type.GetMethod( + "Dispose", + BindingFlags.NonPublic | BindingFlags.Instance, + [typeof(bool)]); + + Assert.NotNull(dispose); + Assert.True(dispose!.IsFamily, "Dispose(bool) must be protected."); + Assert.True(dispose.IsVirtual && !dispose.IsFinal, "Dispose(bool) must be overridable."); + } + + private sealed class CountingNamedTypes : NamedTypesDecorator + { + public CountingNamedTypes(string databaseFilename) : base(databaseFilename) + { + } + + public int NamesSet { get; private set; } + public int NamesRead { get; private set; } + public int Lookups { get; private set; } + public bool Disposed { get; private set; } + + public override uint SetName(uint link, string name) + { + NamesSet++; + return base.SetName(link, name); + } + + public override string? GetName(uint link) + { + NamesRead++; + return base.GetName(link); + } + + public override uint GetByName(string name) + { + Lookups++; + return base.GetByName(name); + } + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } + + private sealed class CountingTransactions : TransactionsDecorator + { + public CountingTransactions(INamedTypesLinks inner, INamedTypesLinks logStore) + : base(inner, logStore) + { + } + + public int TransactionsBegun { get; private set; } + + public override ITransaction BeginTransaction() + { + TransactionsBegun++; + return base.BeginTransaction(); + } + } + + private sealed class CountingTransformations : PersistentTransformationDecorator + { + public CountingTransformations(INamedTypesLinks links, INamedTypesLinks triggerLinks) + : base(links, triggerLinks) + { + } + + public int TriggersStored { get; private set; } + + public override uint StoreTrigger(PersistentTransformationKind kind, string query) + { + TriggersStored++; + return base.StoreTrigger(kind, query); + } + } + + private sealed class CountingVersionControl : VersionControlDecorator + { + public CountingVersionControl(TransactionsDecorator transactions, INamedTypesLinks branchesStore) + : base(transactions, branchesStore) + { + } + + public int BranchesMade { get; private set; } + + public override void Branch(string name, long? from = null) + { + BranchesMade++; + base.Branch(name, from); + } + } + + private static void RunWithFiles(Action action) + { + var dataFile = Path.GetTempFileName(); + try + { + action(dataFile); + } + finally + { + Cleanup(dataFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + } + } + + private static void RunWithFiles(Action action) + { + var secondFile = Path.GetTempFileName(); + try + { + RunWithFiles(dataFile => action(dataFile, secondFile)); + } + finally + { + Cleanup(secondFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(secondFile)); + } + } + + private static void Cleanup(string path) + { + if (File.Exists(path)) File.Delete(path); + } + } +} From 3d53362125be47de595b0cad7a24fe4c3aeefd3d Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 18:22:37 +0000 Subject: [PATCH 15/20] docs(issue-100): capture the platform-data external-range overlap Two small programs, one per language, print the constants a store with external-reference support reports. platform-data 2.0.0 starts the external range on `continue`, so `is_external(continue)` is true; the C# original starts it one address later and answers False. The Rust program exits non-zero once upstream fixes the overlap. --- .../evidence/external-range/csharp/Program.cs | 21 ++ .../csharp/external-range.csproj | 12 + .../evidence/external-range/csharp/run.sh | 5 + .../evidence/external-range/rust/Cargo.lock | 274 ++++++++++++++++++ .../evidence/external-range/rust/Cargo.toml | 8 + .../evidence/external-range/rust/run.sh | 6 + .../evidence/external-range/rust/src/main.rs | 49 ++++ 7 files changed, 375 insertions(+) create mode 100644 docs/case-studies/issue-100/evidence/external-range/csharp/Program.cs create mode 100644 docs/case-studies/issue-100/evidence/external-range/csharp/external-range.csproj create mode 100755 docs/case-studies/issue-100/evidence/external-range/csharp/run.sh create mode 100644 docs/case-studies/issue-100/evidence/external-range/rust/Cargo.lock create mode 100644 docs/case-studies/issue-100/evidence/external-range/rust/Cargo.toml create mode 100755 docs/case-studies/issue-100/evidence/external-range/rust/run.sh create mode 100644 docs/case-studies/issue-100/evidence/external-range/rust/src/main.rs diff --git a/docs/case-studies/issue-100/evidence/external-range/csharp/Program.cs b/docs/case-studies/issue-100/evidence/external-range/csharp/Program.cs new file mode 100644 index 0000000..d67ad5b --- /dev/null +++ b/docs/case-studies/issue-100/evidence/external-range/csharp/Program.cs @@ -0,0 +1,21 @@ +// The reference behaviour the Rust program next door is compared against: +// `LinksConstants` with external-reference support, from Platform.Data +// (a transitive dependency of Platform.Data.Doublets 0.18.1). +// +// The six service constants sit at the top of the internal range, exactly as +// in `platform-data`, but the external range starts *one past* the half, so +// `IsExternalReference(Continue)` is False. +using Platform.Data; + +var c = new LinksConstants(enableExternalReferencesSupport: true); +Console.WriteLine($"null = {c.Null}"); +Console.WriteLine($"continue = {c.Continue}"); +Console.WriteLine($"break = {c.Break}"); +Console.WriteLine($"skip = {c.Skip}"); +Console.WriteLine($"any = {c.Any}"); +Console.WriteLine($"itself = {c.Itself}"); +Console.WriteLine($"error = {c.Error}"); +Console.WriteLine($"internal = {c.InternalReferencesRange}"); +Console.WriteLine($"external = {c.ExternalReferencesRange}"); +Console.WriteLine(); +Console.WriteLine($"IsExternalReference(continue) = {c.IsExternalReference(c.Continue)}"); diff --git a/docs/case-studies/issue-100/evidence/external-range/csharp/external-range.csproj b/docs/case-studies/issue-100/evidence/external-range/csharp/external-range.csproj new file mode 100644 index 0000000..1d383ea --- /dev/null +++ b/docs/case-studies/issue-100/evidence/external-range/csharp/external-range.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + external-range + + + + + diff --git a/docs/case-studies/issue-100/evidence/external-range/csharp/run.sh b/docs/case-studies/issue-100/evidence/external-range/csharp/run.sh new file mode 100755 index 0000000..18219db --- /dev/null +++ b/docs/case-studies/issue-100/evidence/external-range/csharp/run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Prints the C# reference constants the Rust overlap is measured against. +set -u +cd "$(dirname "$0")" +dotnet run -v q --nologo diff --git a/docs/case-studies/issue-100/evidence/external-range/rust/Cargo.lock b/docs/case-studies/issue-100/evidence/external-range/rust/Cargo.lock new file mode 100644 index 0000000..4fede71 --- /dev/null +++ b/docs/case-studies/issue-100/evidence/external-range/rust/Cargo.lock @@ -0,0 +1,274 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "doublets" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677be8ee593349204e8e7cbc2398d4b98fb95fd1386f3a7eaf286860a5788c8f" +dependencies = [ + "cfg-if", + "leak_slice", + "platform-data", + "platform-mem", + "platform-num", + "platform-trees", + "tap", + "thiserror", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "external-range" +version = "0.0.0" +dependencies = [ + "doublets", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[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 = "leak_slice" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecf3387da9fb41906394e1306ddd3cd26dd9b7177af11c19b45b364b743aed26" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[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 = "platform-data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6782bc71345465116de96d250a36dcf49336010a2320d958d12a5d4390186c90" +dependencies = [ + "beef", + "platform-num", + "thiserror", +] + +[[package]] +name = "platform-mem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27cff7c92440ac926c8c91ea3151db6e52a262f602d0c157f254e422fc15b12" +dependencies = [ + "allocator-api2", + "memmap2", + "tempfile", + "thiserror", +] + +[[package]] +name = "platform-num" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ca8e18138b1c90ad802aff931f946a0e6bd760c35af30f1ff2489489ab54a" +dependencies = [ + "num-traits", +] + +[[package]] +name = "platform-trees" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40e25a531617fa762c8505826c930f6c1cfcc226f63dea09882b56ae0b8ed078" +dependencies = [ + "platform-num", +] + +[[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 = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/docs/case-studies/issue-100/evidence/external-range/rust/Cargo.toml b/docs/case-studies/issue-100/evidence/external-range/rust/Cargo.toml new file mode 100644 index 0000000..94c9419 --- /dev/null +++ b/docs/case-studies/issue-100/evidence/external-range/rust/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "external-range" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +doublets = "0.5.0" diff --git a/docs/case-studies/issue-100/evidence/external-range/rust/run.sh b/docs/case-studies/issue-100/evidence/external-range/rust/run.sh new file mode 100755 index 0000000..f4f6833 --- /dev/null +++ b/docs/case-studies/issue-100/evidence/external-range/rust/run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Reproduces the platform-data 2.0.0 external-range overlap. +# Exits 0 while the overlap reproduces, and non-zero once upstream fixes it. +set -u +cd "$(dirname "$0")" +cargo run --quiet diff --git a/docs/case-studies/issue-100/evidence/external-range/rust/src/main.rs b/docs/case-studies/issue-100/evidence/external-range/rust/src/main.rs new file mode 100644 index 0000000..0079410 --- /dev/null +++ b/docs/case-studies/issue-100/evidence/external-range/rust/src/main.rs @@ -0,0 +1,49 @@ +// Isolates the external-reference range `platform-data` 2.0.0 builds, which +// `doublets` 0.5.0 re-exports as `doublets::data::LinksConstants`. +// +// `LinksConstants::full_new` reserves six service values at the top of the +// internal range and then takes the external range verbatim: +// +// r#continue: *internal.end(), +// r#break: *internal.end() - 1, +// ... +// internal_range: *internal.start()..=*internal.end() - 6, +// external_range: external, +// +// With external references enabled the defaults are +// `internal = 1..=half` and `external = half..=MAX`, so `external_range` +// *starts on* `r#continue` -- the two overlap by one address. +// +// The C# `LinksConstants` this mirrors starts the external range +// one past the half instead (see ../csharp), so no service constant is ever +// reported as an external reference. +// +// Exits 0 while the overlap reproduces, and non-zero once upstream fixes it. +use doublets::data::LinksConstants; + +fn main() { + let c = LinksConstants::::external(); + println!("null = {}", c.null); + println!("continue = {}", c.r#continue); + println!("break = {}", c.r#break); + println!("skip = {}", c.skip); + println!("any = {}", c.any); + println!("itself = {}", c.itself); + println!("error = {}", c.error); + println!("internal = {:?}", c.internal_range); + println!("external = {:?}", c.external_range); + + let overlaps = c.is_external(c.r#continue); + println!(); + println!("is_external(continue) = {overlaps}"); + println!( + "expected = false (C# reports False for the same query)" + ); + + if overlaps { + println!("\nReproduced: the external range still starts on `continue`."); + } else { + println!("\nFixed upstream: the ranges no longer overlap."); + std::process::exit(1); + } +} From 3ff7f28cafeb1d2dbf081bbfdc97110ee675a944 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 18:56:26 +0000 Subject: [PATCH 16/20] fix(rust): resolve unspecified substitution halves like C# resolves Any An unbound substitution variable -- or a `*` in a substitution -- is unspecified, not an address. C# marks it with `links.Constants.Any`, a value the store understands: a create writes null there, a lookup runs through `Each` and reads it as a wildcard, and an update keeps the half already stored. The Rust processor marked the same thing with `u32::MAX`, which the store underneath does not recognise (its `any` is `2147483644`), so `() (($a $a))` stored the literal `4294967295` in both halves where C# stores `(1: 0 0)`. Resolve at the write boundary instead, so `u32::MAX` stays this crate's single internal marker and restriction matching is untouched. Five of six probe shapes diverged before the fix; all six agree after it. Covered by six new parity tests and six new harness scenarios. --- .../issue-100/evidence/cli-parity/run.sh | 11 +++ ...829_195000_issue_100_unspecified_halves.md | 5 ++ rust/src/query_processor.rs | 88 +++++++++++++++---- .../query_processor_csharp_parity_tests.rs | 76 ++++++++++++++++ 4 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 rust/changelog.d/20260829_195000_issue_100_unspecified_halves.md diff --git a/docs/case-studies/issue-100/evidence/cli-parity/run.sh b/docs/case-studies/issue-100/evidence/cli-parity/run.sh index ab44827..54526ba 100755 --- a/docs/case-studies/issue-100/evidence/cli-parity/run.sh +++ b/docs/case-studies/issue-100/evidence/cli-parity/run.sh @@ -157,6 +157,17 @@ scenario "reverse update chain" '() ((1 1))' '() ((2 2))' '((1: 1 1)) ((1: scenario "point to non-point" '() ((1 1))' '((1: 1 1)) ((1: 0 0))' scenario "delete self referencing" '() ((1 1))' '() ((1 1) (1 1))' '((1: 1 1)) ()' +# A substitution half that no restriction bound -- a never-bound variable, or a +# `*` -- is *unspecified*, not an address. Creating from one writes null there, +# looking one up treats it as a wildcard, and updating through one keeps the +# half already stored. +scenario "unbound variable point" '() (($a $a))' +scenario "unbound variable twice" '() (($a $a))' '() (($a $a))' +scenario "unbound variable at an index" '() ((5: $a $a))' +scenario "unbound variable one half" '() ((1 1))' '() ((1 $a))' +scenario "star in a substitution" '() ((* *))' +scenario "unbound variable in an update" '() ((1 1))' '((1: 1 1)) ((1: $x $y))' + # Which address a new link gets is observable, so the two stores have to hand # out addresses in the same order: a freed address is reused before the store # grows, the most recently freed one first, and freeing the last link shrinks diff --git a/rust/changelog.d/20260829_195000_issue_100_unspecified_halves.md b/rust/changelog.d/20260829_195000_issue_100_unspecified_halves.md new file mode 100644 index 0000000..b2bf3a2 --- /dev/null +++ b/rust/changelog.d/20260829_195000_issue_100_unspecified_halves.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Fixed unbound substitution variables and `*` in a substitution: they are now resolved at the write boundary the way C# resolves `Constants.Any`, so a created half becomes null, a lookup treats the half as a wildcard, and an update keeps the half already stored, instead of writing the literal `4294967295`. diff --git a/rust/src/query_processor.rs b/rust/src/query_processor.rs index 7e30e1d..84091c2 100644 --- a/rust/src/query_processor.rs +++ b/rust/src/query_processor.rs @@ -780,24 +780,24 @@ impl QueryProcessor { let existing = storage .get_link(definition.index) .unwrap_or_else(|| Link::new(definition.index, 0, 0)); - if existing.source != definition.source || existing.target != definition.target { + let source = Self::resolve_unspecified(definition.source, existing.source); + let target = Self::resolve_unspecified(definition.target, existing.target); + if existing.source != source || existing.target != target { self.trace_msg(&format!( - "[CreateOrUpdateLink] Updating link {}: {}->{}, {}->{}.", - definition.index, - existing.source, - definition.source, - existing.target, - definition.target + "[CreateOrUpdateLink] Updating link {}: {}->{source}, {}->{target}.", + definition.index, existing.source, existing.target )); - storage.update(definition.index, definition.source, definition.target)?; + storage.update(definition.index, source, target)?; } else { self.trace_msg(&format!( - "[CreateOrUpdateLink] Link {} is already S={}, T={} => no change.", - definition.index, definition.source, definition.target + "[CreateOrUpdateLink] Link {} is already S={source}, T={target} => no change.", + definition.index )); } (Some(existing), definition.index) - } else if let Some(existing_id) = storage.search(definition.source, definition.target) { + } else if let Some(existing_id) = + Self::search_unspecified(storage, definition.source, definition.target) + { self.trace_msg(&format!( "[CreateOrUpdateLink] Link already found => ID={existing_id}, no changes." )); @@ -806,11 +806,12 @@ impl QueryProcessor { .unwrap_or_else(|| Link::new(existing_id, definition.source, definition.target)); (Some(existing), existing_id) } else { + let source = Self::resolve_unspecified(definition.source, 0); + let target = Self::resolve_unspecified(definition.target, 0); self.trace_msg(&format!( - "[CreateOrUpdateLink] Creating new link => (S={},T={}).", - definition.source, definition.target + "[CreateOrUpdateLink] Creating new link => (S={source},T={target})." )); - (None, storage.create(definition.source, definition.target)) + (None, storage.create(source, target)) }; if let Some(name) = &definition.name { @@ -855,6 +856,54 @@ impl QueryProcessor { value == u32::MAX } + /// Resolves a half a query left unspecified against the half already + /// stored, the way C# resolves its `any` constant on the way into the + /// store. + /// + /// The C# processor marks an unbound substitution variable — and a `*` — with + /// `links.Constants.Any`, which is a value the *store* understands: + /// `Update` leaves a half substituted with `any` exactly as it was, and a + /// link created from one gets `null` there. This processor marks the same + /// thing with [`u32::MAX`], which the store underneath does not recognise + /// (its `any` is `2147483644`, the hybrid-aware constant), so `() (($a $a))` + /// used to store the literal `4294967295` in both halves where C# stores + /// `(1: 0 0)`. Resolving at the write boundary keeps [`u32::MAX`] as this + /// crate's single internal marker while writing what C# writes. + fn resolve_unspecified(value: u32, existing: u32) -> u32 { + if Self::is_any(value) { + existing + } else { + value + } + } + + /// Looks a doublet up with unspecified halves treated as wildcards, the way + /// C#'s `SearchOrDefault` does. + /// + /// `SearchOrDefault` runs through `Each`, which reads `any` in a query as + /// "every value" rather than as a literal address, so `() ((1 $a))` finds a + /// stored `(1: 1 1)` instead of creating a second link beside it. + /// [`NamedTypeLinks::search`] matches literally on purpose — it backs + /// uniqueness resolution — so the wildcard pass belongs here. + fn search_unspecified( + storage: &mut impl NamedTypeLinks, + source: u32, + target: u32, + ) -> Option { + if !Self::is_any(source) && !Self::is_any(target) { + return storage.search(source, target); + } + storage + .all_links() + .into_iter() + .filter(|link| { + (Self::is_any(source) || link.source == source) + && (Self::is_any(target) || link.target == target) + }) + .map(|link| link.index) + .min() + } + fn is_normal_index(value: u32) -> bool { value != 0 && !Self::is_any(value) } @@ -919,7 +968,10 @@ impl QueryProcessor { if let Some(id_num) = existing { self.ensure_indexed_link(storage, id_num, source_id, target_id, changes)? } else { - let new_id = storage.create(source_id, target_id); + let new_id = storage.create( + Self::resolve_unspecified(source_id, 0), + Self::resolve_unspecified(target_id, 0), + ); changes.push((None, storage.get_link(new_id))); storage.set_name(new_id, id)?; new_id @@ -962,6 +1014,8 @@ impl QueryProcessor { let stored = storage .get_link(index) .unwrap_or_else(|| Link::new(index, 0, 0)); + let source = Self::resolve_unspecified(source, stored.source); + let target = Self::resolve_unspecified(target, stored.target); if stored.source != source || stored.target != target { self.trace_msg(&format!( "[EnsureLinkCreated] Updating link {index} => {}->{source}, {}->{target}.", @@ -991,7 +1045,7 @@ impl QueryProcessor { target: u32, changes: &mut Vec<(Option, Option)>, ) -> u32 { - if let Some(existing_id) = storage.search(source, target) { + if let Some(existing_id) = Self::search_unspecified(storage, source, target) { self.trace_msg(&format!( "[EnsureLinkCreated] Link already found => ID={existing_id} => no-op." )); @@ -1001,6 +1055,8 @@ impl QueryProcessor { changes.push((Some(existing), Some(existing))); existing_id } else { + let source = Self::resolve_unspecified(source, 0); + let target = Self::resolve_unspecified(target, 0); self.trace_msg(&format!( "[EnsureLinkCreated] Creating link for (S={source}, T={target})." )); diff --git a/rust/tests/query_processor_csharp_parity_tests.rs b/rust/tests/query_processor_csharp_parity_tests.rs index 1f8541a..cf47832 100644 --- a/rust/tests/query_processor_csharp_parity_tests.rs +++ b/rust/tests/query_processor_csharp_parity_tests.rs @@ -379,3 +379,79 @@ fn test_swap_one_link_keeps_its_address_matches_csharp() -> Result<()> { Ok(()) }) } + +/// A substitution variable that no restriction ever bound is *unspecified*, not +/// a literal address: C# marks it with `links.Constants.Any` and the store turns +/// that into null on the way in, so `() (($a $a))` creates the point `(1: 0 0)`. +#[test] +fn test_unbound_substitution_variable_creates_null_point_matches_csharp() -> Result<()> { + with_storage(|storage, processor| { + processor.process_query(storage, "() (($a $a))")?; + + assert_eq!(sorted_links(storage), vec![Link::new(1, 0, 0)]); + Ok(()) + }) +} + +/// The same query twice finds the link it created the first time rather than +/// creating a second one, because C#'s `SearchOrDefault` reads `any` in a lookup +/// as a wildcard. +#[test] +fn test_unbound_substitution_variable_twice_is_idempotent_matches_csharp() -> Result<()> { + with_storage(|storage, processor| { + processor.process_query(storage, "() (($a $a))")?; + processor.process_query(storage, "() (($a $a))")?; + + assert_eq!(sorted_links(storage), vec![Link::new(1, 0, 0)]); + Ok(()) + }) +} + +/// An explicit index pins the address; the unspecified halves still land as null. +#[test] +fn test_unbound_substitution_variable_at_an_index_matches_csharp() -> Result<()> { + with_storage(|storage, processor| { + processor.process_query(storage, "() ((5: $a $a))")?; + + assert_eq!(sorted_links(storage), vec![Link::new(5, 0, 0)]); + Ok(()) + }) +} + +/// One specified half plus one unspecified half is a wildcard lookup, so it +/// finds the existing `(1: 1 1)` instead of creating `(2: 1 null)` beside it. +#[test] +fn test_unbound_substitution_variable_one_half_finds_existing_matches_csharp() -> Result<()> { + with_storage(|storage, processor| { + processor.process_query(storage, "() ((1 1))")?; + processor.process_query(storage, "() ((1 $a))")?; + + assert_eq!(sorted_links(storage), vec![Link::new(1, 1, 1)]); + Ok(()) + }) +} + +/// `*` in a substitution is unspecified in exactly the same way a never-bound +/// variable is. +#[test] +fn test_star_in_a_substitution_creates_null_point_matches_csharp() -> Result<()> { + with_storage(|storage, processor| { + processor.process_query(storage, "() ((* *))")?; + + assert_eq!(sorted_links(storage), vec![Link::new(1, 0, 0)]); + Ok(()) + }) +} + +/// An unspecified half of an *update* keeps the half already stored, so +/// rewriting `(1: 1 1)` through `(1: $x $y)` leaves it exactly as it was. +#[test] +fn test_unbound_substitution_variable_in_an_update_keeps_existing_matches_csharp() -> Result<()> { + with_storage(|storage, processor| { + processor.process_query(storage, "() ((1 1))")?; + processor.process_query(storage, "((1: 1 1)) ((1: $x $y))")?; + + assert_eq!(sorted_links(storage), vec![Link::new(1, 1, 1)]); + Ok(()) + }) +} From 6fde7fc5609c8c5dbbf8901e776062ec1c719929 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 19:03:50 +0000 Subject: [PATCH 17/20] docs(issue-100): write the case study and ship the verification logs Restates the six asks in the issue, records what each one found, and attributes the one remaining C#/Rust divergence to the upstream MergeUsages defect rather than to this repository. Also removes the root .gitkeep placeholder, which the repository layout test rejects, and moves the verification logs under dev/log per the convention issue 96 established. --- .gitkeep | 1 - .../pulls/101/issue/issue-100-comments.json | 1 + .../issues/100/pulls/101/issue/issue-100.json | 1 + .../issues/100/pulls/101/issue/pr-101.json | 1 + .../100/pulls/101/verification/README.md | 20 + .../100/pulls/101/verification/cli-parity.txt | 50 +++ .../verification/csharp-format-build-test.txt | 23 + .../100/pulls/101/verification/js-tests.txt | 55 +++ .../100/pulls/101/verification/rust-tests.txt | 395 ++++++++++++++++++ docs/case-studies/issue-100/README.md | 367 ++++++++++++++++ 10 files changed, 913 insertions(+), 1 deletion(-) delete mode 100644 .gitkeep create mode 100644 dev/log/issues/100/pulls/101/issue/issue-100-comments.json create mode 100644 dev/log/issues/100/pulls/101/issue/issue-100.json create mode 100644 dev/log/issues/100/pulls/101/issue/pr-101.json create mode 100644 dev/log/issues/100/pulls/101/verification/README.md create mode 100644 dev/log/issues/100/pulls/101/verification/cli-parity.txt create mode 100644 dev/log/issues/100/pulls/101/verification/csharp-format-build-test.txt create mode 100644 dev/log/issues/100/pulls/101/verification/js-tests.txt create mode 100644 dev/log/issues/100/pulls/101/verification/rust-tests.txt create mode 100644 docs/case-studies/issue-100/README.md diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index b159e90..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-08-29T15:18:50.089Z for PR creation at branch issue-100-f2e0ccb162ad for issue https://github.com/link-foundation/link-cli/issues/100 \ No newline at end of file diff --git a/dev/log/issues/100/pulls/101/issue/issue-100-comments.json b/dev/log/issues/100/pulls/101/issue/issue-100-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/dev/log/issues/100/pulls/101/issue/issue-100-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/dev/log/issues/100/pulls/101/issue/issue-100.json b/dev/log/issues/100/pulls/101/issue/issue-100.json new file mode 100644 index 0000000..9b5892f --- /dev/null +++ b/dev/log/issues/100/pulls/101/issue/issue-100.json @@ -0,0 +1 @@ +{"body":"All remaining issues or missing features, that are good to have in doublets-rs should be reported there.\n\nWe update exactly all dependencies in all languages, not just doublets-rs.\n\nBut we should focus on latest release of doublets to reuse much of new features, so less code is duplicated in this repository.\n\nWe also must double check that in all languages we provide all abstractions with all trust for extension, as much public members as possible, and so on. So everything is easy to reconfigure, reuse, swap and so on.\n\nSo all programming languages we support should provide not only CLI (and other surfaces), but also a library itself to simplify alternative/custom CLIs construction and much more.\n\nWe also must double check that all programming languages we support have all the same features, nothing is missing in any of languages.","createdAt":"2026-08-29T15:15:04Z","number":100,"state":"OPEN","title":"Update all dependencies, including new doublets-rs, and use all new features to highest potential, refactoring, quality assurance ","url":"https://github.com/link-foundation/link-cli/issues/100"} diff --git a/dev/log/issues/100/pulls/101/issue/pr-101.json b/dev/log/issues/100/pulls/101/issue/pr-101.json new file mode 100644 index 0000000..a0d1a80 --- /dev/null +++ b/dev/log/issues/100/pulls/101/issue/pr-101.json @@ -0,0 +1 @@ +{"body":"## 🤖 AI-Powered Solution Draft\n\nThis pull request is being automatically generated to solve issue #100.\n\n### 📋 Issue Reference\nFixes #100\n\n### 🚧 Status\n**Work in Progress** - The AI assistant is currently analyzing and implementing the solution draft.\n\n### 📝 Implementation Details\n_Details will be added as the solution draft is developed..._\n\n---\n*This PR was created automatically by the AI issue solver*","headRefName":"issue-100-f2e0ccb162ad","isDraft":true,"number":101,"state":"OPEN","title":"[WIP] Update all dependencies, including new doublets-rs, and use all new features to highest potential, refactoring, quality assurance","url":"https://github.com/link-foundation/link-cli/pull/101"} diff --git a/dev/log/issues/100/pulls/101/verification/README.md b/dev/log/issues/100/pulls/101/verification/README.md new file mode 100644 index 0000000..ee97e7b --- /dev/null +++ b/dev/log/issues/100/pulls/101/verification/README.md @@ -0,0 +1,20 @@ +# Issue 100 / PR 101 verification logs + +Captured on the final commit of `issue-100-f2e0ccb162ad`, so the numbers quoted +in [the case study](../../../../../../docs/case-studies/issue-100/README.md#8-verification) +can be checked against the runs that produced them. + +| File | Command | Result | +|------|---------|--------| +| `rust-tests.txt` | `cargo test` (in `rust/`) | 239 passed, 0 failed, 1 ignored | +| `csharp-format-build-test.txt` | `dotnet format --verify-no-changes`, `dotnet build -c Release`, `dotnet test` (in `csharp/`) | format clean, build 0 warnings / 0 errors, 254 passed | +| `cli-parity.txt` | `docs/case-studies/issue-100/evidence/cli-parity/run.sh` | 39 scenarios agree, 1 known upstream difference | +| `js-tests.txt` | `node --test test/*.mjs` (in `js/`) | 9 passed | + +The `FORMAT=`, `BUILD=` and `TEST=` lines in the C# log are the exit statuses of +the three commands, in order. + +`cli-parity.txt` ends in `KNOWN update into duplicate`, which is the +`Platform.Data.Doublets` 0.18.1 `MergeUsages` defect +([Data.Doublets#515](https://github.com/linksplatform/Data.Doublets/issues/515)), +not a failure — see the case study, §5.5. diff --git a/dev/log/issues/100/pulls/101/verification/cli-parity.txt b/dev/log/issues/100/pulls/101/verification/cli-parity.txt new file mode 100644 index 0000000..2bb0f8b --- /dev/null +++ b/dev/log/issues/100/pulls/101/verification/cli-parity.txt @@ -0,0 +1,50 @@ +PASS create +PASS duplicate create +PASS update target +PASS delete point +PASS cascade delete of usage +PASS cascade delete chain +PASS uniqueness on update +PASS delete with contents +PASS named create +PASS named cascade delete +PASS swap one link +PASS swap all links +PASS no-op variable query +PASS delete by wildcard +PASS delete everything +PASS rename named link +PASS nested composite create +PASS explicit index after gap +PASS reverse update chain +PASS point to non-point +PASS delete self referencing +PASS unbound variable point +PASS unbound variable twice +PASS unbound variable at an index +PASS unbound variable one half +PASS star in a substitution +PASS unbound variable in an update +PASS reuse a freed address +PASS reuse after a shrink +PASS reuse the newest hole first +PASS auto-create frees the addresses it passed over +PASS auto-create leaves the new link the first address +PASS always trigger fires +PASS always trigger keeps firing +PASS once trigger fires only once +PASS never removes a stored trigger +PASS never on an empty trigger store +PASS trigger without a match stays dormant +PASS trigger embedded in the main database +KNOWN update into duplicate + Platform.Data.Doublets 0.18.1 MergeUsages corrupts the usages it repoints (see ../csharp-merge-usages), so C# leaves (2: 2 0) where doublets-rs rebases the usage onto the surviving link and leaves (2: 2 2). + queries: () ((1 2) (2 1)) ((1: 1 2)) ((1: 2 1)) + rust: + (1: 2 1) + (2: 2 2) + c#: + (1: 2 1) + (2: 2 0) + +All scenarios match, except the known upstream differences listed above. diff --git a/dev/log/issues/100/pulls/101/verification/csharp-format-build-test.txt b/dev/log/issues/100/pulls/101/verification/csharp-format-build-test.txt new file mode 100644 index 0000000..f9d9a1a --- /dev/null +++ b/dev/log/issues/100/pulls/101/verification/csharp-format-build-test.txt @@ -0,0 +1,23 @@ +FORMAT=0 + Determining projects to restore... + All projects are up-to-date for restore. + Foundation.Data.Doublets.Cli.Library -> /tmp/gh-issue-solver-1788016727075/csharp/Foundation.Data.Doublets.Cli.Library/bin/Release/net10.0/Foundation.Data.Doublets.Cli.dll + Foundation.Data.Doublets.Cli -> /tmp/gh-issue-solver-1788016727075/csharp/Foundation.Data.Doublets.Cli/bin/Release/net10.0/clink.dll + Foundation.Data.Doublets.Cli.Tests -> /tmp/gh-issue-solver-1788016727075/csharp/Foundation.Data.Doublets.Cli.Tests/bin/Release/net10.0/Foundation.Data.Doublets.Cli.Tests.dll + +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:27.68 +BUILD=0 + Determining projects to restore... + All projects are up-to-date for restore. + Foundation.Data.Doublets.Cli.Library -> /tmp/gh-issue-solver-1788016727075/csharp/Foundation.Data.Doublets.Cli.Library/bin/Debug/net10.0/Foundation.Data.Doublets.Cli.dll + Foundation.Data.Doublets.Cli -> /tmp/gh-issue-solver-1788016727075/csharp/Foundation.Data.Doublets.Cli/bin/Debug/net10.0/clink.dll + Foundation.Data.Doublets.Cli.Tests -> /tmp/gh-issue-solver-1788016727075/csharp/Foundation.Data.Doublets.Cli.Tests/bin/Debug/net10.0/Foundation.Data.Doublets.Cli.Tests.dll +Test run for /tmp/gh-issue-solver-1788016727075/csharp/Foundation.Data.Doublets.Cli.Tests/bin/Debug/net10.0/Foundation.Data.Doublets.Cli.Tests.dll (.NETCoreApp,Version=v10.0) +A total of 1 test files matched the specified pattern. + +Passed! - Failed: 0, Passed: 254, Skipped: 0, Total: 254, Duration: 1 m 19 s - Foundation.Data.Doublets.Cli.Tests.dll (net10.0) +TEST=0 diff --git a/dev/log/issues/100/pulls/101/verification/js-tests.txt b/dev/log/issues/100/pulls/101/verification/js-tests.txt new file mode 100644 index 0000000..7e766be --- /dev/null +++ b/dev/log/issues/100/pulls/101/verification/js-tests.txt @@ -0,0 +1,55 @@ +TAP version 13 +# Subtest: buildGraph reflects create, delete, and recreate snapshots without stale nodes +ok 1 - buildGraph reflects create, delete, and recreate snapshots without stale nodes + --- + duration_ms: 7.768293 + ... +# Subtest: language code package manifests and generated evidence stay out of the root folder +ok 2 - language code package manifests and generated evidence stay out of the root folder + --- + duration_ms: 3.686759 + ... +# Subtest: JavaScript package scripts target the relocated WebAssembly crate and split script trees +ok 3 - JavaScript package scripts target the relocated WebAssembly crate and split script trees + --- + duration_ms: 1.199465 + ... +# Subtest: WebAssembly workflow uses the JavaScript package lockfile from js +ok 4 - WebAssembly workflow uses the JavaScript package lockfile from js + --- + duration_ms: 2.872743 + ... +# Subtest: the documentation workflow deploys GitHub Pages automatically on push to main +ok 5 - the documentation workflow deploys GitHub Pages automatically on push to main + --- + duration_ms: 1.05715 + ... +# Subtest: CSharp release workflow attaches NuGet packages to GitHub Releases +ok 6 - CSharp release workflow attaches NuGet packages to GitHub Releases + --- + duration_ms: 1.486218 + ... +# Subtest: CSharp release workflow includes self-healing release gates +ok 7 - CSharp release workflow includes self-healing release gates + --- + duration_ms: 0.530488 + ... +# Subtest: CSharp and Rust release workflows are scheduled on every push to main +ok 8 - CSharp and Rust release workflows are scheduled on every push to main + --- + duration_ms: 2.726308 + ... +# Subtest: GitHub workflows avoid Node 20 action major versions that are being retired +ok 9 - GitHub workflows avoid Node 20 action major versions that are being retired + --- + duration_ms: 1.007937 + ... +1..9 +# tests 9 +# suites 0 +# pass 9 +# fail 0 +# cancelled 0 +# skipped 0 +# todo 0 +# duration_ms 194.87026 diff --git a/dev/log/issues/100/pulls/101/verification/rust-tests.txt b/dev/log/issues/100/pulls/101/verification/rust-tests.txt new file mode 100644 index 0000000..f85a361 --- /dev/null +++ b/dev/log/issues/100/pulls/101/verification/rust-tests.txt @@ -0,0 +1,395 @@ + +running 6 tests +test version_control::tests::encode_round_trips_through_decode ... ok +test transactions::tests::wide_transition_is_rejected_by_a_narrow_address_type ... ok +test transactions::tests::retention_policy_parses_specs ... ok +test version_control::tests::make_version_control_database_filename_returns_sibling_path ... ok +test transactions::tests::transition_round_trips_through_serialize ... ok +test version_control::tests::decode_branch_marker_rejects_invalid_input ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +running 9 tests +test test_simplify_multiple_branches_from_same_initial ... ok +test test_simplify_issue26_alternative_scenario ... ok +test test_simplify_keeps_unchanged_states ... ok +test test_simplify_chain ... ok +test test_simplify_with_unchanged ... ok +test test_simplify_specific_example_removes_intermediate_states ... ok +test test_simplify_no_op ... ok +test test_simplify_issue26_update_operation ... ok +test test_simplify_empty ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + +running 13 tests +test parses_inline_export_alias_as_lino_output_path ... ok +test defaults_have_no_transactions_or_vc_requested ... ok +test parses_inline_version_control_flag_values ... ok +test parses_transactions_flag_family ... ok +test parses_version_control_flag_family ... ok +test query_option_takes_precedence_over_positional_query ... ok +test parses_inline_alias_values_and_boolean_values ... ok +test rejects_extra_positional_queries ... ok +test returns_help_and_version_commands ... ok +test rejects_invalid_branch_from_value ... ok +test parses_export_alias_as_lino_output_path ... ok +test parses_inline_transactions_flag_values ... ok +test parses_csharp_option_aliases_without_direct_clap_dependency ... ok + +test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + +running 3 tests +test export_alias_writes_numbered_references ... ok +test export_alias_writes_named_references ... ok +test structure_option_renders_left_branch_with_indexes ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + + +running 1 test +test import_option_reads_numbered_lino_file ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + +running 1 test +test cli_stores_string_aliases_in_separate_names_database ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + +running 10 tests +test invalid_commit_mode_value_is_rejected ... ok +test no_flags_does_not_create_transactions_sidecar ... ok +test invalid_retention_value_is_rejected ... ok +test explicit_transactions_file_is_honored ... ok +test vc_flag_creates_version_control_sidecar ... ok +test transactions_flag_creates_transitions_sidecar ... ok +test transactions_log_flag_prints_recorded_transitions ... ok +test vc_list_branches_shows_default_branch ... ok +test vc_tag_then_list_tags_round_trip ... ok +test vc_branch_then_switch_back_creates_new_branch ... ok + +test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s + + +running 11 tests +test a_trigger_command_needs_a_query ... ok +test never_on_an_empty_trigger_store_removes_nothing ... ok +test always_trigger_fires_on_a_later_write ... ok +test only_one_trigger_command_at_a_time ... ok +test an_existing_trigger_store_keeps_firing_without_repeating_the_flag ... ok +test never_removes_a_stored_trigger ... ok +test always_trigger_keeps_firing ... ok +test once_trigger_fires_only_once ... ok +test triggers_are_stored_in_the_sidecar_next_to_the_database ... ok +test triggers_file_puts_the_store_where_it_is_asked_to ... ok +test embedded_triggers_stay_in_the_main_database ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.44s + + +running 2 tests +test rust_manifest_declares_required_basis_crates ... ok +test rust_manifest_uses_lino_arguments_without_direct_clap_dependency ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +running 6 tests +test map_store_accepts_any_upstream_decorator ... ok +test resolved_storage_repoints_usages_of_the_redundant_link ... ok +test resolved_storage_turns_duplicate_creation_into_get_or_create ... ok +test resolved_storage_cascades_deletion_to_usages ... ok +test undecorated_storage_still_allows_duplicate_pairs ... ok +test map_store_preserves_the_backing_path_and_durability ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.63s + + +running 2 tests +test update_into_an_existing_pair_rebases_usages_onto_the_survivor ... ok +test deleting_a_link_cascades_to_its_usages ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s + + +running 15 tests +test cross_process_lock_probe ... ignored, helper process spawned by exclusive_lock_is_honoured_across_processes +test doublets_storage_creates_updates_and_deletes_links ... ok +test doublets_storage_reserves_specific_addresses ... ok +test doublets_storage_queries_by_pattern ... ok +test doublets_storage_mutates_the_database_file_in_place ... ok +test doublets_storage_detects_external_writes ... ok +test doublets_storage_survives_reopen ... ok + +running 1 test + +running 1 test +test doublets_storage_supports_u64_addresses ... ok +test exclusive_lock_is_honoured_across_processes ... ok +test shared_locks_allow_concurrent_readers_but_block_writers ... ok +test persistent_file_mapped_preserves_existing_contents ... ok +test doublets_storage_supports_usize_addresses ... ok +test opened_storage_holds_its_lock_for_its_lifetime ... ok +test exclusive_lock_excludes_other_holders ... ok +test doublets_storage_wraps_an_externally_owned_store ... ok + +test result: ok. 14 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 2.25s + + +running 4 tests +test unsupported_any_reference_is_rejected_without_placeholder_creation ... ok +test pinned_types_take_types_is_finite_and_deterministic ... ok +test named_link_create_delete_recreate_clears_stale_name_mapping ... ok +test explicit_numeric_id_update_can_be_reversed_with_another_update ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + +running 21 tests +test test_format_structure_renders_repeated_source_and_target_as_reference_on_right ... ok +test test_ensure_created_frees_the_addresses_it_passed_over ... ok +test test_format_structure_renders_left_branch_with_link_indexes ... ok +test test_freed_addresses_are_reused_most_recently_freed_first ... ok +test test_freed_address_is_reused_before_the_store_grows ... ok +test get_or_create_matches_service_constants_literally ... ok +test test_free_list_survives_a_reload ... ok +test test_freeing_the_last_link_shrinks_the_store ... ok +test test_lino_lines_escape_names_that_need_quoting ... ok +test test_lino_lines_use_names_for_indexes_sources_and_targets ... ok +test test_lino_lines_use_numbered_references_without_names ... ok +test test_storage_delete ... ok +test test_storage_create ... ok +test test_lino_lines_select_quote_style_for_names_containing_quotes ... ok +test test_missing_addresses_are_recovered_from_a_database_without_a_free_list ... ok +test test_storage_search ... ok +test test_storage_get_or_create ... ok +test test_storage_update ... ok +test test_storage_persistence ... ok +test test_storage_named_links ... ok +test test_write_lino_output_writes_complete_database ... ok + +test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + +running 5 tests +test test_link_creation ... ok +test test_link_format ... ok +test test_link_is_full_point ... ok +test test_link_is_null ... ok +test test_link_round_trips_through_doublets_link ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +running 3 tests +test import_lino_text_creates_named_references_as_point_links ... ok +test import_lino_text_reproduces_numbered_links_at_explicit_indexes ... ok +test import_lino_text_treats_out_of_range_numbers_as_names ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +running 5 tests +test test_lino_link_is_wildcard ... ok +test test_lino_link_new ... ok +test test_lino_link_is_variable ... ok +test test_lino_link_is_numeric ... ok +test test_lino_link_with_values ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +running 8 tests +test default_names_database_path_matches_csharp_convention ... ok +test decorator_includes_pinned_types_decorator ... ok +test delete_removes_associated_name_from_names_database ... ok +test decorator_can_be_built_from_existing_link_storages ... ok +test decorator_exposes_link_storage_operations_and_named_types ... ok +test reassigning_existing_name_moves_name_to_new_link ... ok +test setting_second_name_replaces_first_name ... ok +test reserved_pinned_type_names_can_still_be_used_for_user_links ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + + +running 8 tests +test test_parse_empty ... ok +test test_parse_link_with_id ... ok +test test_parse_links_notation_backtick_unicode_identifier ... ok +test test_parse_simple_link ... ok +test test_parse_nested_link ... ok +test test_parse_query_format ... ok +test test_parse_wildcard ... ok +test test_parse_variable ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +running 11 tests +test query_parsing_accepts_the_wrapped_and_the_bare_form ... ok +test query_parsing_rejects_an_incomplete_query ... ok +test sidecar_store_keeps_the_main_database_free_of_trigger_bookkeeping ... ok +test embedded_store_keeps_triggers_in_the_decorated_database ... ok +test triggers_database_filename_follows_the_names_database_convention ... ok +test never_removes_matching_stored_trigger ... ok +test always_trigger_is_stored_in_links_and_applied_after_write ... ok +test once_trigger_deletes_itself_after_first_match ... ok +test always_trigger_keeps_firing ... ok +test triggers_survive_a_reopen ... ok +test storing_the_same_trigger_twice_is_idempotent ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s + + +running 3 tests +test decorator_supports_triplet_deconstruction_parity ... ok +test decorator_rejects_unexpected_link_shape_at_reserved_address ... ok +test decorator_exposes_link_storage_operations_and_pinned_types ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + +running 26 tests +test test_create_explicit_index_after_gap_matches_csharp ... ok +test test_create_deep_nested_numeric_links_matches_csharp ... ok +test test_delete_all_by_index_wildcard_matches_csharp ... ok +test test_delete_by_wildcard_target_matches_csharp ... ok +test test_delete_by_source_target_pattern_matches_csharp ... ok +test test_issue_20_substitute_matched_link_and_outgoing_link_matches_csharp ... ok +test test_issue_20_substitute_full_point_with_unbound_parts_matches_csharp ... ok +test test_delete_cascades_to_usages_matches_csharp ... ok +test test_delete_cascade_chain_matches_csharp ... ok +test test_no_op_variable_query_returns_matched_changes ... ok +test test_star_in_a_substitution_creates_null_point_matches_csharp ... ok +test test_delete_by_names_keeps_leaf_names_matches_csharp ... ok +test test_swap_one_link_keeps_its_address_matches_csharp ... ok +test test_swap_all_links_using_variables_matches_csharp ... ok +test test_unbound_substitution_variable_at_an_index_matches_csharp ... ok +test test_unbound_substitution_variable_creates_null_point_matches_csharp ... ok +test test_unbound_substitution_variable_in_an_update_keeps_existing_matches_csharp ... ok +test test_unbound_substitution_variable_one_half_finds_existing_matches_csharp ... ok +test test_string_composite_left_child_does_not_create_extra_leaf ... ok +test test_unwrapped_create_query_matches_csharp ... ok +test test_unbound_substitution_variable_twice_is_idempotent_matches_csharp ... ok +test test_named_delete_cascades_to_usages_matches_csharp ... ok +test test_update_into_existing_pair_merges_matches_csharp ... ok +test test_unknown_named_restriction_fails_without_auto_create ... ok +test test_named_link_rename_matches_csharp ... ok +test test_string_aliases_in_variable_restriction_constrain_matches_to_named_links_matches_csharp ... ok + +test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s + + +running 15 tests +test test_auto_create_missing_numeric_reference_creates_point_link ... ok +test test_deduplicate_duplicate_pair_with_numeric_links ... ok +test test_auto_create_missing_named_references_creates_point_links ... ok +test test_auto_create_missing_numeric_reference_fills_existing_gap ... ok +test test_deduplicate_mixed_named_and_numeric ... ok +test test_deduplicate_duplicate_pair_with_named_links ... ok +test test_missing_named_reference_fails_without_auto_create ... ok +test test_deduplicate_nested_duplicates ... ok +test test_future_numeric_references_succeed_without_auto_create ... ok +test test_deduplicate_triple_duplicate_pair ... ok +test test_deduplicate_with_different_pairs ... ok +test test_query_processor_empty ... ok +test test_missing_numeric_reference_fails_without_auto_create ... ok +test test_query_processor_create ... ok +test test_deduplicate_named_links_multiple_queries ... ok + +test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s + + +running 6 tests +test uncommitted_writes_are_rolled_back_after_a_crash ... ok +test committed_writes_survive_a_crash_without_save ... ok +test a_log_written_by_a_wider_address_type_is_rejected_not_dropped ... ok +test committed_but_unapplied_transitions_are_reapplied ... ok +test a_torn_final_log_entry_is_ignored_during_recovery ... ok +test the_data_store_is_mutated_in_place_across_transactions ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.01s + + +running 14 tests +test make_transitions_database_filename_returns_sibling_path ... ok +test no_behaviour_change_when_not_opted_in ... ok +test nested_transactions_are_rejected ... ok +test retention_policy_parses_specs ... ok +test commit_persists_create ... ok +test async_commit_marks_applied ... ok +test auto_transaction_records_create_and_update ... ok +test recovery_reapplies_committed_transitions ... ok +test transition_round_trips_through_serialize ... ok +test rollback_undoes_create ... ok +test rollback_undoes_delete ... ok +test rollback_undoes_update ... ok +test chunked_retention_archives_oldest ... ok +test sized_retention_drops_oldest_after_applied ... ok + +test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.62s + + +running 5 tests +test balanced_variant_and_right_sequence_walker_preserve_symbol_order ... ok +test raw_number_converters_match_hybrid_external_reference_encoding ... ok +test caching_converter_decorator_reuses_cached_values ... ok +test target_and_char_symbol_converters_create_and_decode_symbols ... ok +test string_and_unicode_sequence_converters_round_trip_utf16_text ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + +running 11 tests +test create_and_retrieve_empty_string ... ok +test create_and_retrieve_simple_string ... ok +test create_and_retrieve_unicode_string_as_utf16_sequence ... ok +test create_and_retrieve_user_defined_type ... ok +test create_and_retrieve_multiple_strings ... ok +test deleting_non_named_link_does_not_affect_other_names ... ok +test named_links_facade_matches_csharp_named_links_role ... ok +test name_external_reference_matches_csharp_hybrid_encoding ... ok +test name_is_removed_when_external_reference_is_deleted ... ok +test pinned_types_are_created_and_named ... ok +test name_is_removed_when_link_is_deleted ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + +running 13 tests +test default_branch_exists_on_first_open ... ok +test duplicate_branch_throws ... ok +test checkout_out_of_range_throws ... ok +test branch_forks_from_current_head ... ok +test new_transitions_are_attributed_to_current_branch ... ok +test full_stack_acid_rollback_is_atomic_and_isolated ... ok +test branch_from_explicit_seq_uses_given_point ... ok +test checkout_and_forward_replay_restores_state ... ok +test checkout_to_zero_rewinds_everything ... ok +test recover_rebuilds_state_from_branches_store ... ok +test tag_points_to_current_head ... ok +test switch_branch_applies_and_rewinds_transitions ... ok +test full_stack_acid_commit_is_consistent_and_durable_across_reopen ... ok + +test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.62s + + +running 4 tests +test src/link_storage_doublets.rs - link_storage_doublets::by_pattern (line 83) ... ignored +test src/storage/doublets_storage.rs - storage::doublets_storage::DoubletsStorage::map_store (line 167) - compile ... ok +test src/storage/doublets_storage.rs - storage::doublets_storage::DoubletsStorage::with_automatic_uniqueness_and_usages_resolution (line 199) - compile ... ok +test src/transactions/mod.rs - transactions (line 28) - compile ... ok + +test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.19s + diff --git a/docs/case-studies/issue-100/README.md b/docs/case-studies/issue-100/README.md new file mode 100644 index 0000000..642cc50 --- /dev/null +++ b/docs/case-studies/issue-100/README.md @@ -0,0 +1,367 @@ +# Issue 100 Case Study: Dependency Refresh, Upstream Reuse and Cross-Language Parity + +Issue: + +Prepared PR: [#101](https://github.com/link-foundation/link-cli/pull/101) + +> Scope of this case study: this folder captures the restated requirements, the +> evidence, the root-cause analysis of every behaviour gap found, the +> implemented design, and the verification evidence for updating every +> dependency in every language, leaning on the new `doublets` 0.5.0 features so +> less is duplicated here, opening every layer up for extension, and proving +> that the languages we support actually have the same features. + +## 1. Issue summary + +The issue is six sentences, each of which is a separate ask: + +| # | Ask (verbatim) | +|---|----------------| +| 1 | "All remaining issues or missing features, that are good to have in doublets-rs should be reported there." | +| 2 | "We update exactly all dependencies in all languages, not just doublets-rs." | +| 3 | "But we should focus on latest release of doublets to reuse much of new features, so less code is duplicated in this repository." | +| 4 | "We also must double check that in all languages we provide all abstractions with all trust for extension, as much public members as possible, and so on. So everything is easy to reconfigure, reuse, swap and so on." | +| 5 | "So all programming languages we support should provide not only CLI (and other surfaces), but also a library itself to simplify alternative/custom CLIs construction and much more." | +| 6 | "We also must double check that all programming languages we support have all the same features, nothing is missing in any of languages." | + +There are no comments on the issue; nothing narrowed or widened it after it +was filed. + +Ask 6 is the one that generated most of the work. "Nothing is missing in any +of languages" cannot be answered by reading code — the two implementations +have different stores underneath them — so it was answered by *running* both +CLIs over the same query sequences and diffing the resulting databases. That +harness is [`evidence/cli-parity/run.sh`](evidence/cli-parity/run.sh), and it +found five real divergences that this PR fixes and one that belongs upstream. + +## 2. Restated requirements + +| ID | Requirement | +|-----|-------------| +| R1 | Every gap in `doublets-rs` that this repository has to work around is reported upstream, with a reproduction. | +| R2 | Every dependency in Rust, C# and JS is at its newest published stable version, or the reason it is not is recorded. | +| R3 | `doublets` is on its latest release and its new capabilities are *used*, replacing code duplicated here. | +| R4 | Every layer in every language is open for extension: unsealed/public types, overridable members, and a seam a replacement can be slotted into. | +| R5 | Every language ships a library, not only a CLI, so an alternative CLI can be built from the same pieces. | +| R6 | The languages have the same features: same flags, same query semantics, same observable database after the same queries. | +| R7 | Any remaining cross-language difference is either fixed here, or attributed to a dependency defect with a reproduction and an upstream issue. | +| R8 | The CLI's existing observable behaviour is not regressed: same output for existing invocations, no new files. | + +## 3. Evidence captured in this folder + +``` +docs/case-studies/issue-100/ +├── README.md # This document. +└── evidence/ + ├── cli-parity/run.sh # 39 scenarios run through both CLIs and diffed (§6). + ├── csharp-merge-usages/ # Reproduction of the Data.Doublets MergeUsages defect (§5.5). + │ ├── Program.cs + │ ├── csharp-merge-usages.csproj + │ └── run.sh + └── external-range/ # Side-by-side of the C# and Rust LinksConstants ranges (§5.6). + ├── csharp/{Program.cs,external-range.csproj,run.sh} + └── rust/{Cargo.toml,Cargo.lock,src/main.rs,run.sh} +``` + +Both upstream reproductions follow the same convention: **`run.sh` exits 0 +while the defect reproduces and non-zero once it is fixed**, so the day the +upstream release lands, the harness tells us instead of the defect quietly +outliving its workaround. `cli-parity/run.sh` applies the same rule to its one +`known_difference` scenario: if the two languages ever agree there, the +scenario turns *red* so the exemption gets removed. + +## 4. Reporting upstream (R1) + +Four defects and gaps were confirmed with runnable reproductions and filed: + +| Issue | What | +|-------|------| +| [doublets-rs#60](https://github.com/linksplatform/doublets-rs/issues/60) | No Rust counterpart for `Platform.Data.Doublets.Sequences` — sequences, Unicode strings, walkers. This repository ports the Unicode string path by hand (`rust/src/unicode_string_storage.rs`, `rust/src/sequences.rs`); that port is exactly the duplication ask 3 asks us to remove, and it cannot be removed until upstream has the layer. | +| [doublets-rs#61](https://github.com/linksplatform/doublets-rs/issues/61) | No transactions layer: C# has `UInt64LinksTransactionsLayer`, Rust has nothing. `link-cli` maintains its own (`rust/src/transactions/`) for the same reason. | +| [Data.Doublets#515](https://github.com/linksplatform/Data.Doublets/issues/515) | `MergeUsages` writes null targets and wrong sources — the root cause of the one remaining C#/Rust divergence (§5.5). | +| [data-rs#18](https://github.com/linksplatform/data-rs/issues/18) | `LinksConstants::external()` overlaps the external range with the `continue` constant, so `is_external(continue)` is `true` in Rust and `False` in C# (§5.6). | + +A fifth candidate was investigated and **not** filed: the address allocator. +`doublets` 0.5.0's `src/mem/unit/store.rs` already implements the exact +contract this repository reverse-engineered from C# — `UnusedLinks` plus +`header.first_free`, `attach_as_first` for LIFO reuse, and a tail shrink on +delete. There was nothing to ask for; the bug was on our side (§5.2). + +## 5. Root-cause analysis + +### 5.1 `doublets` 0.5.0 brings the decorator layer into reach + +The gap ask 3 names is real: before this PR the Rust `LinkStorage` resolved +uniqueness and cascading deletes with its own code, while C# gets both from +`ILinksExtensions.DecorateWithAutomaticUniquenessAndUsagesResolution`. +`doublets` 0.5.0 exposes `doublets::decorators`, which is the same stack. + +`DoubletsStorage::map_store` composes any upstream (or caller-written) +decorator onto an open database while keeping its path, its advisory lock and +its change-detection fingerprint, and +`DoubletsStorage::with_automatic_uniqueness_and_usages_resolution` applies the +C# stack by name. The `doublets` crate — including `decorators` — is +re-exported from `link_cli`, so a downstream crate can build its own stack +without adding a direct dependency that could drift to an incompatible semver. + +Routing the CLI's `LinkStorage` through those resolvers is what made a Rust +delete cascade into the links that referenced the deleted one, and an update +that would duplicate an existing link merge into it, the way C# already did. + +### 5.2 The stores handed out addresses in different orders + +Which address a new link gets is *observable* — it is printed by `--after` and +it is what a later query refers to — so the two stores must allocate +identically or every subsequent query diverges. + +C#'s store reuses a freed address before growing, most recently freed first, +shrinks when the last link is deleted, and gives back the addresses it passed +over while reaching a requested one. The Rust `LinkStorage` grew monotonically. +Six harness scenarios (`reuse a freed address`, `reuse after a shrink`, +`reuse the newest hole first`, `auto-create frees the addresses it passed +over`, `auto-create leaves the new link the first address`) pin the contract +down; the free list is now persisted so the order survives between CLI +invocations, because a CLI process is one query long. + +### 5.3 `--changes` reported different changes + +Three separate causes, all fixed: + +- An auto-created reference was reported as a *creation* in Rust and as an + *update of the placeholder it started from* in C#. +- A delete reported only the deleted link in Rust; C# reports the whole + cascade of removed usages. +- The reported order came from `HashMap` iteration, so it varied with the + process's hash seed. It is now deterministic. + +### 5.4 Unspecified substitution halves were written literally + +This is the divergence found last, and the subtlest. A substitution half that +no restriction ever bound — a never-bound variable, or a `*` — is +*unspecified*, not an address. + +C# marks it with `links.Constants.Any`, which is a value the *store* +understands, and the store then gives it three meanings depending on where it +lands: + +| Position | Meaning | +|----------|---------| +| `SearchOrDefault` | wildcard — the lookup runs through `Each`, which reads `any` as "every value" | +| an `Update` substitution | keep the half already stored | +| a create | null (`0`) | + +which is one rule: *unspecified → the existing value, or null when there is +none.* + +The Rust processor marked the same thing with `u32::MAX`, a value its store +does **not** recognise — the store's `any` is `2147483644`, the hybrid-aware +constant. So `() (($a $a))` stored the literal `4294967295` in both halves +where C# stores `(1: 0 0)`. Five of six probe shapes diverged. + +The fix resolves at the **write boundary** rather than changing the sentinel: +`QueryProcessor::resolve_unspecified` and `QueryProcessor::search_unspecified` +sit in front of the five places the processor writes to or searches the store. +That keeps `u32::MAX` as the crate's single internal marker and leaves +restriction matching — and `NamedTypeLinks::search`, which is deliberately +literal because it backs uniqueness resolution — untouched. + +### 5.5 `MergeUsages` in Platform.Data.Doublets 0.18.1 (upstream, not fixed here) + +One scenario still diverges, and it is a C# bug, not a Rust one: + +``` +'() ((1 2) (2 1))' '((1: 1 2)) ((1: 2 1))' +``` + +C# leaves `(2: 2 0)`; Rust leaves `(2: 2 2)`. + +`Platform.Data.Doublets.Link` declares `(params T[] values)`, +`(IList)`, `(object)`, `(ref Link)` and `(index, source, target)`. It +has **no** two-argument `(source, target)` constructor, so `new Link(a, b)` +binds to the `params` overload, and `SetValues` reads a two-element list as +`(index, source)` with `target = default`. `MergeUsages` constructs its +replacement links that way, so it repoints usages onto a link with a null +target and a source that is really the index. + +[`evidence/csharp-merge-usages/run.sh`](evidence/csharp-merge-usages/run.sh) +reproduces this against `Platform.Data.Doublets` directly, with no `link-cli` +code involved. Filed as +[Data.Doublets#515](https://github.com/linksplatform/Data.Doublets/issues/515) +and recorded in the parity harness as its single `known_difference`. + +### 5.6 The constants differ, and it is unreachable in practice + +`doublets` 0.5.0 re-exports `platform-data` 2.0.0's `LinksConstants`. +`full_new` reserves six service values at the top of the internal range and +then takes the external range verbatim, so with external references enabled +`external_range` *starts on* `r#continue` — the two overlap by one address. +The C# `LinksConstants` starts the external range one past the +half, so no service constant is ever reported as external. + +The visible consequence for `link-cli` is that the main database's `any` +differs: `4294967292` in C# (default, internal-only constants) versus +`2147483644` in Rust (`LinksConstants::external()`, which every `LinkStorage` +reports because the hybrid external-reference half is not optional here — see +`rust/src/link_storage_doublets.rs`). + +Reaching that difference through the CLI means naming address `2147483644` or +`4294967292` in a query. Both CLIs then try to allocate roughly two billion +links and neither finishes; the difference is theoretical rather than +observable at the CLI. It is documented here and filed as +[data-rs#18](https://github.com/linksplatform/data-rs/issues/18) rather than +worked around. + +## 6. Implemented solution + +### Rust + +| Area | Change | +|------|--------| +| Basis | `doublets` 0.4.0 → 0.5.0 in both the `link-cli` and the `clink-wasm` lockfiles. | +| Reuse (R3) | `DoubletsStorage::map_store` and `::with_automatic_uniqueness_and_usages_resolution`; `doublets` and `doublets::decorators` re-exported from `link_cli`. | +| Cascades (R6) | `LinkStorage` routed through the upstream uniqueness and cascade resolvers; the transactions log records one transition per link a write actually touched, so rollback and branch switching no longer lose cascaded changes; the query processor restores links a resolved write deleted as a side effect, mirroring `RestoreUnexpectedLinkDeletions`. | +| Constants (R7) | `LinkStorage` reports the hybrid `LinksConstants`; its inherent `get_or_create` no longer resolves through the `Doublets` impl for `&mut LinkStorage`, which treats `any` as a wildcard. Name holders are ordered by address, so reserved pinned type names resolve deterministically. | +| Triggers (R6) | `PersistentTransformationDecorator` ports the C# persistent transformation triggers — `Once`/`Always` schema, the `.triggers.links` sidecar, and the embedded store. Triggers written by either implementation are readable by the other. Exposed on the CLI as `--always`, `--once`, `--never`, `--triggers`, `--triggers-file`, `--embed-triggers`. | +| Addresses (R6) | Address allocation matches the C# store, free list persisted (§5.2). | +| `--changes` (R6) | Placeholder updates, full delete cascades, deterministic order (§5.3). | +| Unspecified halves (R6) | `resolve_unspecified` / `search_unspecified` at the write boundary (§5.4). | +| Library (R4, R5) | Every module of `link_cli` is public, along with the query patterns, the resolved links, the link reference validator and the transition wire-format constants. `NamedTypeLinks` is documented as *the* seam: every layer is written against it and every decorator both implements it and wraps another implementation of it, so a cache, an access check or a remote store slots in anywhere — including under `QueryProcessor`, which never learns what is beneath it. | + +### C# + +| Area | Change | +|------|--------| +| Extension (R4) | Every decorator — `NamedTypesDecorator`, `NamedLinksDecorator`, `SimpleLinksDecorator`, `PinnedTypesDecorator`, `TransactionsDecorator`, `VersionControlDecorator`, `PersistentTransformationDecorator` — is unsealed with overridable members. The disposable ones follow `protected virtual void Dispose(bool)` so a subclass can release resources of its own. | +| Publication (R4) | `PersistentTransformationDecorator.PersistentTransformationQuery` and `InternalNamePrefix` are public, matching what the Rust library already exposed. | +| Enforcement | `ExtensibilityTests` subclasses four of the decorators and asserts the seam reflectively, so re-sealing a class or dropping a `virtual` fails the suite rather than silently narrowing the API. | + +The C# library was already a separate project +(`Foundation.Data.Doublets.Cli.Library`) that `Foundation.Data.Doublets.Cli` +consumes, so R5 was already satisfied there; what was missing was R4, which is +what this change delivers. + +### JS + +The JS surface is a web front end over the Rust WASM build, not a third +implementation of the CLI, so R6 does not apply to it as a feature matrix — +it inherits whatever `clink-wasm` exposes. Its dependencies were checked and +are current (§7). + +### Documentation + +`docs/` no longer claims persistent transformation triggers are C#-only; the +Rust CLI has them as of this PR. + +## 7. Dependencies (R2) + +Every dependency in every language was checked against its registry. Only one +was behind: + +| Language | Dependency | Before | After | +|----------|-----------|--------|-------| +| Rust | `doublets` | 0.4.0 | **0.5.0** | + +Everything else was already at its newest published stable version and is +recorded here so the check is auditable rather than implied: + +| Language | Dependency | Version | +|----------|-----------|---------| +| Rust | `thiserror` | 2.0.20 | +| Rust | `anyhow` | 1.0.104 | +| Rust | `links-notation` | 0.16.1 | +| Rust | `lino-arguments` | 0.3.0 | +| Rust (WASM) | `wasm-bindgen` / `wasm-bindgen-test` | 0.2.127 / 0.3.77 | +| Rust (WASM) | `serde` / `serde_json` | 1.0.229 / 1.0.151 | +| Rust (WASM) | `web-sys` | 0.3.104 | +| Rust (WASM) | `console_error_panic_hook` | 0.1.7 | +| C# | `Link.Foundation.Links.Notation` | 0.16.1 | +| C# | `Platform.Data` | 0.16.1 | +| C# | `Platform.Data.Doublets` | 0.18.1 | +| C# | `Platform.Data.Doublets.Sequences` | 0.6.5 | +| C# | `System.CommandLine` | 2.0.11 | +| C# | `xunit` / `xunit.runner.visualstudio` | 2.9.3 / 4.0.0 | +| C# | `Microsoft.NET.Test.Sdk` | 18.9.0 | +| C# | `coverlet.collector` | 10.0.1 | +| JS | `doublets-web` | ^0.1.3 | +| JS | `react` / `react-dom` | ^19.2.8 | +| JS | `lucide-react` | ^1.37.0 | +| JS | `vite` / `@vitejs/plugin-react` / `vite-plugin-wasm` | ^8.2.2 / ^6.1.1 / ^3.6.0 | + +`System.CommandLine` deserves a note: 3.0.0 exists on NuGet but only as a +prerelease, so 2.0.11 is the newest *stable* release and the pin stays. + +`links-notation` remains deliberately aligned at 0.16.1 across Rust and C#, +the invariant established in [issue 98](../issue-98/README.md#5-implemented-solution). + +## 8. Verification + +| Check | Result | +|-------|--------| +| `cargo fmt --all -- --check` (both workspaces) | clean | +| `cargo clippy --all-targets --all-features -- -D warnings` | clean | +| `cargo test` | 239 passed, 1 ignored | +| `dotnet format --verify-no-changes` | clean | +| `dotnet build --configuration Release` | 0 warnings, 0 errors | +| `dotnet test` | 254 passed | +| `node --test` (JS) | 9 passed | +| `evidence/cli-parity/run.sh` | 39 scenarios agree, 1 known upstream difference | + +The runs behind those numbers are kept in +[`dev/log/issues/100/pulls/101/verification/`](../../../dev/log/issues/100/pulls/101/verification), +per the convention established in issue 96. + +The parity harness is the primary evidence for R6. It runs the same query +sequence through both binaries, then compares two things rather than one: + +- the **final database dump**, and +- one accepted/rejected verdict **per query**. + +The verdicts are compared alongside the dumps because a query both CLIs refuse +leaves two empty databases, which a dump-only comparison would happily call a +match. The exit status is compared rather than the message text: the two +implementations are expected to agree on *what* they accept, not on how they +word a rejection. Trigger scenarios additionally dump the trigger sidecar, so +how a trigger is *stored* is compared and not just what it did. + +Coverage: creates, duplicate creates, updates, deletes, cascade deletes, +cascade chains, uniqueness-on-update, structural and wildcard deletes, named +links and named cascades, renames, nested composites, explicit indexes after a +gap, reverse update chains, self-referencing deletes, the six +address-allocation scenarios, the six unspecified-half scenarios, and eight +trigger scenarios including the embedded store. + +## 9. What the issue asked for versus what shipped + +| Ask | Status | +|-----|--------| +| 1. Report gaps to doublets-rs | Done: [doublets-rs#60](https://github.com/linksplatform/doublets-rs/issues/60), [#61](https://github.com/linksplatform/doublets-rs/issues/61), plus [Data.Doublets#515](https://github.com/linksplatform/Data.Doublets/issues/515) and [data-rs#18](https://github.com/linksplatform/data-rs/issues/18) in the neighbouring repositories the defects actually live in. The allocator was investigated and found already correct upstream, so nothing was filed for it (§4). | +| 2. All dependencies, all languages | Done and audited. One was behind (`doublets` 0.4.0 → 0.5.0); the other 22 are recorded at their current versions in §7, with `System.CommandLine` explicitly noted as latest-*stable*. | +| 3. Reuse the latest doublets, duplicate less | Done: uniqueness and cascade resolution now come from `doublets::decorators` instead of hand-written code, and `map_store` makes the whole upstream decorator layer composable. The duplication that *remains* — the Unicode/sequences port and the transactions layer — has no upstream counterpart yet, which is why it is filed as doublets-rs#60 and #61 rather than removed. | +| 4. Everything open for extension | Done in both languages. Rust: every module public plus the query and validation types; `NamedTypeLinks` documented as the swap-in seam. C#: every decorator unsealed and overridable, `Dispose(bool)` pattern, trigger query and name prefix published — enforced by reflective tests so it cannot silently regress. | +| 5. A library, not only a CLI | Already true in C#; now true in Rust down to the last module. The JS surface is a front end over the WASM build rather than a third implementation. | +| 6. Same features everywhere | Done, and *proved* rather than asserted: 39 scenarios agree across both CLIs. Five divergences were found and fixed here (cascades, addresses, constants, `--changes`, unspecified halves) and the Rust CLI gained the trigger flags it was missing. | +| 7. Remaining differences attributed | One remains, and it is an upstream C# defect with a standalone reproduction and an upstream issue (§5.5). | + +## 10. Risks and follow-ups + +- **The `MergeUsages` exemption.** Until + [Data.Doublets#515](https://github.com/linksplatform/Data.Doublets/issues/515) + is released, `update into duplicate` produces a corrupt link in C#. The + harness will turn red the moment the languages agree, which is the signal to + drop the exemption. +- **The constants overlap.** `is_external(continue)` is `true` in Rust and + `False` in C#. Unreachable through the CLI (§5.6), but a *library* consumer + that calls it directly will see the difference. Tracked as + [data-rs#18](https://github.com/linksplatform/data-rs/issues/18). +- **Duplicated layers.** The Unicode/sequences port and the transactions layer + are still maintained here because Rust has no upstream equivalent. They are + the standing answer to ask 3 and should be deleted in favour of upstream once + doublets-rs#60 and #61 land. +- **`u32::MAX` as an internal marker.** The unspecified-half fix resolves at + the write boundary rather than adopting the store's `any`. That is + deliberate — it keeps one marker and leaves restriction matching alone — but + it means a *new* write path added to the query processor has to route through + `resolve_unspecified`/`search_unspecified` to stay correct. The six parity + scenarios and six unit tests are the guard. +- **Advisory locking and MSRV 1.89** carry over unchanged from + [issue 98](../issue-98/README.md#8-risks-and-follow-ups). From 0f21839da9052b92d7e4039b0cc0a6631a2b5ed1 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 19:05:22 +0000 Subject: [PATCH 18/20] docs(issue-100): fix the relative link back to the case study --- dev/log/issues/100/pulls/101/verification/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/log/issues/100/pulls/101/verification/README.md b/dev/log/issues/100/pulls/101/verification/README.md index ee97e7b..328af5d 100644 --- a/dev/log/issues/100/pulls/101/verification/README.md +++ b/dev/log/issues/100/pulls/101/verification/README.md @@ -1,7 +1,7 @@ # Issue 100 / PR 101 verification logs Captured on the final commit of `issue-100-f2e0ccb162ad`, so the numbers quoted -in [the case study](../../../../../../docs/case-studies/issue-100/README.md#8-verification) +in [the case study](../../../../../../../docs/case-studies/issue-100/README.md#8-verification) can be checked against the runs that produced them. | File | Command | Result | From f9e567fcc184c627c1113d4b6e3c5930e26c36e3 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 19:16:31 +0000 Subject: [PATCH 19/20] refactor(rust): split the query processor and transactions decorator CI's file-size gate (csharp/scripts/check-file-size.mjs, 1000 lines max) failed on this branch: query_processor.rs had grown to 1110 lines while resolving unspecified substitution halves, and transactions/mod.rs to 1074 while recording cascaded transitions. Both files are split the way the repository already splits large ones: a child module that can reach the parent's private items, so nothing had to be made more visible than pub(super). rust/src/query_processor/mutations.rs -- the write side (delete, create/update, restore of unexpected deletions, EnsureLinkCreated), mirroring the C# AdvancedMixedQueryProcessor.Mutations.cs split, next to the existing query_processor/matching.rs. rust/src/transactions/recovery.rs -- the paths that read the sidecar log back: applying and reverting a single transition, crash recovery and log retention. Pure code motion: 239 Rust tests, cargo fmt, clippy -D warnings and the CLI parity harness (39 PASS + 1 KNOWN) are unchanged. --- rust/src/query_processor.rs | 329 +---------------------- rust/src/query_processor/mutations.rs | 347 +++++++++++++++++++++++++ rust/src/transactions/mod.rs | 328 +---------------------- rust/src/transactions/recovery.rs | 359 ++++++++++++++++++++++++++ 4 files changed, 711 insertions(+), 652 deletions(-) create mode 100644 rust/src/query_processor/mutations.rs create mode 100644 rust/src/transactions/recovery.rs diff --git a/rust/src/query_processor.rs b/rust/src/query_processor.rs index 84091c2..98a7b69 100644 --- a/rust/src/query_processor.rs +++ b/rust/src/query_processor.rs @@ -17,6 +17,8 @@ use crate::query_types::{Pattern, ResolvedLink}; // Pattern matching lives in a submodule; see query_processor/matching.rs. mod matching; +// Write-side operations live in a submodule; see query_processor/mutations.rs. +mod mutations; /// QueryProcessor handles LiNo query parsing and execution /// Corresponds to AdvancedMixedQueryProcessor in C# @@ -249,34 +251,6 @@ impl QueryProcessor { Ok(changes_list) } - /// Deletes `id` and appends every resulting change to `changes`. - /// - /// A delete cascades into the links that still used the deleted one, and - /// each of those removals is reported too. It is the direct analogue of - /// C#'s `RemoveLinks`, which passes the changes handler straight to the - /// store so the decorator stack reports the cascade: - /// - /// ```csharp - /// links.Delete(link, (before, after) => - /// options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue); - /// ``` - fn delete_observed( - &self, - storage: &mut impl NamedTypeLinks, - id: u32, - changes: &mut Vec<(Option, Option)>, - ) -> Result { - let mut observed = Vec::new(); - let deleted = storage.delete_observed(id, &mut |before, after| { - observed.push(( - (!before.is_null()).then_some(before), - (!after.is_null()).then_some(after), - )); - })?; - changes.append(&mut observed); - Ok(deleted) - } - fn validate_links_exist_or_will_be_created( &self, storage: &mut impl NamedTypeLinks, @@ -679,151 +653,6 @@ impl QueryProcessor { Ok(()) } - /// Final state every planned operation asks for, keyed by link address - /// and kept in the order the operations were planned. - /// - /// `None` marks a link the query deliberately deletes, so a cascade that - /// removes it is expected rather than a side effect. Mirrors the - /// `intendedFinalStates` dictionary the C# processor builds before - /// applying its planned operations. - fn intended_final_states( - operations: &[(Option, Option)], - ) -> Vec<(u32, Option)> { - let mut states: Vec<(u32, Option)> = Vec::new(); - let mut set = |index: u32, state: Option| match states - .iter_mut() - .find(|(existing, _)| *existing == index) - { - Some(entry) => entry.1 = state, - None => states.push((index, state)), - }; - for (before, after) in operations { - match (before, after) { - (_, Some(after)) if Self::is_normal_index(after.index) => { - set(after.index, Some(after.clone())) - } - (Some(before), None) if Self::is_normal_index(before.index) => { - set(before.index, None) - } - _ => {} - } - } - states - } - - /// Recreates links that a resolved write removed as a side effect. - /// - /// Mirrors `RestoreUnexpectedLinkDeletions` in the C# processor. The - /// uniqueness resolver merges a link into an existing duplicate by - /// deleting it, and the usages resolver cascades through the links that - /// reference it. When the query itself asked for such a link to exist, - /// the deletion is a side effect of the resolution order and has to be - /// undone — otherwise a query like - /// `((($index: $source $target)) (($index: $target $source)))` would lose - /// half of the links it swaps, because the first swap temporarily - /// duplicates a link that the second swap would have made unique again. - fn restore_unexpected_deletions( - &self, - storage: &mut impl NamedTypeLinks, - intended_final_states: &[(u32, Option)], - changes: &mut Vec<(Option, Option)>, - ) -> Result<()> { - for (index, intended) in intended_final_states { - let Some(intended) = intended else { - self.trace_msg(&format!( - "[RestoreUnexpectedLinkDeletions] Link {index} was intended-deletion => skip restore." - )); - continue; - }; - if storage.exists(*index) { - continue; - } - self.trace_msg(&format!( - "[RestoreUnexpectedLinkDeletions] Recreating link {index} => was unexpected deletion." - )); - let (before, restored) = self.create_or_update_resolved_link(storage, intended)?; - changes.push((before, Some(restored))); - } - Ok(()) - } - - /// Creates or updates the link a resolved definition asks for and reports - /// the states a `--changes` listener would see. - /// - /// The returned pair is `(before, after)`, where `before` is `None` only - /// when the link genuinely had to be allocated from nothing. Everything - /// else — an address that had to be filled in with - /// [`try_ensure_created`](NamedTypeLinks::try_ensure_created), a definition - /// that already matches its stored state, a duplicate of an existing - /// doublet — reports the state that was there before, mirroring - /// `CreateOrUpdateLink` in the C# processor: - /// - /// ```csharp - /// if (existingDoublet.Source != linkDefinition.Source || existingDoublet.Target != linkDefinition.Target) - /// { ... links.Update(...); } - /// else - /// { options.ChangesHandler?.Invoke(existingDoublet, existingDoublet); } - /// ``` - /// - /// Skipping the update when nothing changes is not only a reporting - /// detail: a redundant write shows up in the transitions log and — far - /// worse — counts as a write for the persistent transformation decorator, - /// which would replay every stored trigger for a query that changed - /// nothing. - fn create_or_update_resolved_link( - &self, - storage: &mut impl NamedTypeLinks, - definition: &ResolvedLink, - ) -> Result<(Option, Link)> { - let (before, id) = if Self::is_normal_index(definition.index) { - storage.try_ensure_created(definition.index)?; - let existing = storage - .get_link(definition.index) - .unwrap_or_else(|| Link::new(definition.index, 0, 0)); - let source = Self::resolve_unspecified(definition.source, existing.source); - let target = Self::resolve_unspecified(definition.target, existing.target); - if existing.source != source || existing.target != target { - self.trace_msg(&format!( - "[CreateOrUpdateLink] Updating link {}: {}->{source}, {}->{target}.", - definition.index, existing.source, existing.target - )); - storage.update(definition.index, source, target)?; - } else { - self.trace_msg(&format!( - "[CreateOrUpdateLink] Link {} is already S={source}, T={target} => no change.", - definition.index - )); - } - (Some(existing), definition.index) - } else if let Some(existing_id) = - Self::search_unspecified(storage, definition.source, definition.target) - { - self.trace_msg(&format!( - "[CreateOrUpdateLink] Link already found => ID={existing_id}, no changes." - )); - let existing = storage - .get_link(existing_id) - .unwrap_or_else(|| Link::new(existing_id, definition.source, definition.target)); - (Some(existing), existing_id) - } else { - let source = Self::resolve_unspecified(definition.source, 0); - let target = Self::resolve_unspecified(definition.target, 0); - self.trace_msg(&format!( - "[CreateOrUpdateLink] Creating new link => (S={source},T={target})." - )); - (None, storage.create(source, target)) - }; - - if let Some(name) = &definition.name { - storage.set_name(id, name)?; - } - - let after = storage - .get_link(id) - .unwrap_or_else(|| Link::new(id, definition.source, definition.target)); - Ok((before, after)) - } - fn links_matching_definition( &self, storage: &mut impl NamedTypeLinks, @@ -912,160 +741,6 @@ impl QueryProcessor { identifier == "*" || identifier.parse::().is_ok() } - /// Ensures a link is created from a LiNo pattern, recursing into its parts. - /// - /// Port of `EnsureNestedLinkCreatedRecursively` in the C# processor. Every - /// doublet it touches — including the nested ones — appends its - /// `(before, after)` states to `changes`, exactly as the C# version reports - /// them through `options.ChangesHandler`, so `--changes` lists the same - /// records in both languages. Leaves report nothing: C#'s `ResolveLeaf` - /// passes the update handler that ignores the changes handler. - fn ensure_link_created( - &self, - storage: &mut impl NamedTypeLinks, - lino_link: &LinoLink, - changes: &mut Vec<(Option, Option)>, - ) -> Result { - // Handle leaf nodes (names or numbers) - if !lino_link.has_values() { - if let Some(ref id) = lino_link.id { - if id == "*" || Self::is_variable(id) { - return Ok(u32::MAX); - } - - // Check if it's a number - if let Ok(num) = id.parse::() { - return Ok(num); - } - - // It's a name - get or create - return storage.get_or_create_named(id); - } - return Ok(0); - } - - // Handle composite links with 2 values - if lino_link.values_count() == 2 { - let values = lino_link.values.as_ref().unwrap(); - - // Recursively ensure source and target exist - let source_id = self.ensure_link_created(storage, &values[0], changes)?; - let target_id = self.ensure_link_created(storage, &values[1], changes)?; - - // Create or get the composite link - let link_id = if let Some(ref id) = lino_link.id { - if let Ok(num) = id.parse::() { - // Specific ID requested. - self.ensure_indexed_link(storage, num, source_id, target_id, changes)? - } else if id == "*" || Self::is_variable(id) { - self.ensure_doublet(storage, source_id, target_id, changes) - } else { - // Named link: this repository resolves the address through - // the name, where C# resolves it through `(source, target)` - // and names the result afterwards. The reported states are - // the same either way. - let existing = storage.get_by_name(id)?; - if let Some(id_num) = existing { - self.ensure_indexed_link(storage, id_num, source_id, target_id, changes)? - } else { - let new_id = storage.create( - Self::resolve_unspecified(source_id, 0), - Self::resolve_unspecified(target_id, 0), - ); - changes.push((None, storage.get_link(new_id))); - storage.set_name(new_id, id)?; - new_id - } - } - } else { - // Anonymous link - self.ensure_doublet(storage, source_id, target_id, changes) - }; - - return Ok(link_id); - } - - Err(LinkError::InvalidFormat("Invalid link structure".to_string()).into()) - } - - /// `EnsureLinkCreated` for a definition that names its own address. - /// - /// Fills the address in when the store does not have it yet, then writes - /// only if the stored doublet really differs — the `else` branch in C# - /// reports `(existing, existing)` without touching the store: - /// - /// ```csharp - /// TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} is already correct => no-op."); - /// options.ChangesHandler?.Invoke(storedD, storedD); - /// ``` - /// - /// The redundant write this avoids is not merely noise: it lands in the - /// transitions log and, with `--always`/`--once` triggers in play, replays - /// every stored transformation for a query that changed nothing. - fn ensure_indexed_link( - &self, - storage: &mut impl NamedTypeLinks, - index: u32, - source: u32, - target: u32, - changes: &mut Vec<(Option, Option)>, - ) -> Result { - storage.try_ensure_created(index)?; - let stored = storage - .get_link(index) - .unwrap_or_else(|| Link::new(index, 0, 0)); - let source = Self::resolve_unspecified(source, stored.source); - let target = Self::resolve_unspecified(target, stored.target); - if stored.source != source || stored.target != target { - self.trace_msg(&format!( - "[EnsureLinkCreated] Updating link {index} => {}->{source}, {}->{target}.", - stored.source, stored.target - )); - storage.update(index, source, target)?; - let after = storage - .get_link(index) - .unwrap_or_else(|| Link::new(index, source, target)); - changes.push((Some(stored), Some(after))); - } else { - self.trace_msg(&format!( - "[EnsureLinkCreated] Link {index} is already correct => no-op." - )); - changes.push((Some(stored), Some(stored))); - } - Ok(index) - } - - /// `EnsureLinkCreated` for a definition with no address of its own: the - /// existing doublet is reused and reported as an unchanged pair, and only a - /// genuinely new one reports a creation. - fn ensure_doublet( - &self, - storage: &mut impl NamedTypeLinks, - source: u32, - target: u32, - changes: &mut Vec<(Option, Option)>, - ) -> u32 { - if let Some(existing_id) = Self::search_unspecified(storage, source, target) { - self.trace_msg(&format!( - "[EnsureLinkCreated] Link already found => ID={existing_id} => no-op." - )); - let existing = storage - .get_link(existing_id) - .unwrap_or_else(|| Link::new(existing_id, source, target)); - changes.push((Some(existing), Some(existing))); - existing_id - } else { - let source = Self::resolve_unspecified(source, 0); - let target = Self::resolve_unspecified(target, 0); - self.trace_msg(&format!( - "[EnsureLinkCreated] Creating link for (S={source}, T={target})." - )); - let created = storage.create(source, target); - changes.push((None, storage.get_link(created))); - created - } - } - /// Simplifies the changes list. /// /// A missing side — the state before a creation, or the state after a diff --git a/rust/src/query_processor/mutations.rs b/rust/src/query_processor/mutations.rs new file mode 100644 index 0000000..01884d2 --- /dev/null +++ b/rust/src/query_processor/mutations.rs @@ -0,0 +1,347 @@ +//! Write-side operations for [`QueryProcessor`]. +//! +//! Extracted from `query_processor.rs` for issue #100: resolving unspecified +//! substitution halves pushed the file past the 1000-line limit enforced by +//! `rust/scripts/check-file-size.rs`. These are the methods that actually +//! mutate the store — deletion, creation, update and the restore pass that +//! undoes cascades a query did not ask for — mirroring the C# split into +//! `AdvancedMixedQueryProcessor.Mutations.cs`. + +use anyhow::Result; + +use crate::error::LinkError; +use crate::link::Link; +use crate::lino_link::LinoLink; +use crate::named_type_links::NamedTypeLinks; +use crate::query_types::ResolvedLink; + +use super::QueryProcessor; + +impl QueryProcessor { + /// Deletes `id` and appends every resulting change to `changes`. + /// + /// A delete cascades into the links that still used the deleted one, and + /// each of those removals is reported too. It is the direct analogue of + /// C#'s `RemoveLinks`, which passes the changes handler straight to the + /// store so the decorator stack reports the cascade: + /// + /// ```csharp + /// links.Delete(link, (before, after) => + /// options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue); + /// ``` + pub(super) fn delete_observed( + &self, + storage: &mut impl NamedTypeLinks, + id: u32, + changes: &mut Vec<(Option, Option)>, + ) -> Result { + let mut observed = Vec::new(); + let deleted = storage.delete_observed(id, &mut |before, after| { + observed.push(( + (!before.is_null()).then_some(before), + (!after.is_null()).then_some(after), + )); + })?; + changes.append(&mut observed); + Ok(deleted) + } + + /// Final state every planned operation asks for, keyed by link address + /// and kept in the order the operations were planned. + /// + /// `None` marks a link the query deliberately deletes, so a cascade that + /// removes it is expected rather than a side effect. Mirrors the + /// `intendedFinalStates` dictionary the C# processor builds before + /// applying its planned operations. + pub(super) fn intended_final_states( + operations: &[(Option, Option)], + ) -> Vec<(u32, Option)> { + let mut states: Vec<(u32, Option)> = Vec::new(); + let mut set = |index: u32, state: Option| match states + .iter_mut() + .find(|(existing, _)| *existing == index) + { + Some(entry) => entry.1 = state, + None => states.push((index, state)), + }; + for (before, after) in operations { + match (before, after) { + (_, Some(after)) if Self::is_normal_index(after.index) => { + set(after.index, Some(after.clone())) + } + (Some(before), None) if Self::is_normal_index(before.index) => { + set(before.index, None) + } + _ => {} + } + } + states + } + + /// Recreates links that a resolved write removed as a side effect. + /// + /// Mirrors `RestoreUnexpectedLinkDeletions` in the C# processor. The + /// uniqueness resolver merges a link into an existing duplicate by + /// deleting it, and the usages resolver cascades through the links that + /// reference it. When the query itself asked for such a link to exist, + /// the deletion is a side effect of the resolution order and has to be + /// undone — otherwise a query like + /// `((($index: $source $target)) (($index: $target $source)))` would lose + /// half of the links it swaps, because the first swap temporarily + /// duplicates a link that the second swap would have made unique again. + pub(super) fn restore_unexpected_deletions( + &self, + storage: &mut impl NamedTypeLinks, + intended_final_states: &[(u32, Option)], + changes: &mut Vec<(Option, Option)>, + ) -> Result<()> { + for (index, intended) in intended_final_states { + let Some(intended) = intended else { + self.trace_msg(&format!( + "[RestoreUnexpectedLinkDeletions] Link {index} was intended-deletion => skip restore." + )); + continue; + }; + if storage.exists(*index) { + continue; + } + self.trace_msg(&format!( + "[RestoreUnexpectedLinkDeletions] Recreating link {index} => was unexpected deletion." + )); + let (before, restored) = self.create_or_update_resolved_link(storage, intended)?; + changes.push((before, Some(restored))); + } + Ok(()) + } + + /// Creates or updates the link a resolved definition asks for and reports + /// the states a `--changes` listener would see. + /// + /// The returned pair is `(before, after)`, where `before` is `None` only + /// when the link genuinely had to be allocated from nothing. Everything + /// else — an address that had to be filled in with + /// [`try_ensure_created`](NamedTypeLinks::try_ensure_created), a definition + /// that already matches its stored state, a duplicate of an existing + /// doublet — reports the state that was there before, mirroring + /// `CreateOrUpdateLink` in the C# processor: + /// + /// ```csharp + /// if (existingDoublet.Source != linkDefinition.Source || existingDoublet.Target != linkDefinition.Target) + /// { ... links.Update(...); } + /// else + /// { options.ChangesHandler?.Invoke(existingDoublet, existingDoublet); } + /// ``` + /// + /// Skipping the update when nothing changes is not only a reporting + /// detail: a redundant write shows up in the transitions log and — far + /// worse — counts as a write for the persistent transformation decorator, + /// which would replay every stored trigger for a query that changed + /// nothing. + pub(super) fn create_or_update_resolved_link( + &self, + storage: &mut impl NamedTypeLinks, + definition: &ResolvedLink, + ) -> Result<(Option, Link)> { + let (before, id) = if Self::is_normal_index(definition.index) { + storage.try_ensure_created(definition.index)?; + let existing = storage + .get_link(definition.index) + .unwrap_or_else(|| Link::new(definition.index, 0, 0)); + let source = Self::resolve_unspecified(definition.source, existing.source); + let target = Self::resolve_unspecified(definition.target, existing.target); + if existing.source != source || existing.target != target { + self.trace_msg(&format!( + "[CreateOrUpdateLink] Updating link {}: {}->{source}, {}->{target}.", + definition.index, existing.source, existing.target + )); + storage.update(definition.index, source, target)?; + } else { + self.trace_msg(&format!( + "[CreateOrUpdateLink] Link {} is already S={source}, T={target} => no change.", + definition.index + )); + } + (Some(existing), definition.index) + } else if let Some(existing_id) = + Self::search_unspecified(storage, definition.source, definition.target) + { + self.trace_msg(&format!( + "[CreateOrUpdateLink] Link already found => ID={existing_id}, no changes." + )); + let existing = storage + .get_link(existing_id) + .unwrap_or_else(|| Link::new(existing_id, definition.source, definition.target)); + (Some(existing), existing_id) + } else { + let source = Self::resolve_unspecified(definition.source, 0); + let target = Self::resolve_unspecified(definition.target, 0); + self.trace_msg(&format!( + "[CreateOrUpdateLink] Creating new link => (S={source},T={target})." + )); + (None, storage.create(source, target)) + }; + + if let Some(name) = &definition.name { + storage.set_name(id, name)?; + } + + let after = storage + .get_link(id) + .unwrap_or_else(|| Link::new(id, definition.source, definition.target)); + Ok((before, after)) + } + + /// Ensures a link is created from a LiNo pattern, recursing into its parts. + /// + /// Port of `EnsureNestedLinkCreatedRecursively` in the C# processor. Every + /// doublet it touches — including the nested ones — appends its + /// `(before, after)` states to `changes`, exactly as the C# version reports + /// them through `options.ChangesHandler`, so `--changes` lists the same + /// records in both languages. Leaves report nothing: C#'s `ResolveLeaf` + /// passes the update handler that ignores the changes handler. + pub(super) fn ensure_link_created( + &self, + storage: &mut impl NamedTypeLinks, + lino_link: &LinoLink, + changes: &mut Vec<(Option, Option)>, + ) -> Result { + // Handle leaf nodes (names or numbers) + if !lino_link.has_values() { + if let Some(ref id) = lino_link.id { + if id == "*" || Self::is_variable(id) { + return Ok(u32::MAX); + } + + // Check if it's a number + if let Ok(num) = id.parse::() { + return Ok(num); + } + + // It's a name - get or create + return storage.get_or_create_named(id); + } + return Ok(0); + } + + // Handle composite links with 2 values + if lino_link.values_count() == 2 { + let values = lino_link.values.as_ref().unwrap(); + + // Recursively ensure source and target exist + let source_id = self.ensure_link_created(storage, &values[0], changes)?; + let target_id = self.ensure_link_created(storage, &values[1], changes)?; + + // Create or get the composite link + let link_id = if let Some(ref id) = lino_link.id { + if let Ok(num) = id.parse::() { + // Specific ID requested. + self.ensure_indexed_link(storage, num, source_id, target_id, changes)? + } else if id == "*" || Self::is_variable(id) { + self.ensure_doublet(storage, source_id, target_id, changes) + } else { + // Named link: this repository resolves the address through + // the name, where C# resolves it through `(source, target)` + // and names the result afterwards. The reported states are + // the same either way. + let existing = storage.get_by_name(id)?; + if let Some(id_num) = existing { + self.ensure_indexed_link(storage, id_num, source_id, target_id, changes)? + } else { + let new_id = storage.create( + Self::resolve_unspecified(source_id, 0), + Self::resolve_unspecified(target_id, 0), + ); + changes.push((None, storage.get_link(new_id))); + storage.set_name(new_id, id)?; + new_id + } + } + } else { + // Anonymous link + self.ensure_doublet(storage, source_id, target_id, changes) + }; + + return Ok(link_id); + } + + Err(LinkError::InvalidFormat("Invalid link structure".to_string()).into()) + } + + /// `EnsureLinkCreated` for a definition that names its own address. + /// + /// Fills the address in when the store does not have it yet, then writes + /// only if the stored doublet really differs — the `else` branch in C# + /// reports `(existing, existing)` without touching the store: + /// + /// ```csharp + /// TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} is already correct => no-op."); + /// options.ChangesHandler?.Invoke(storedD, storedD); + /// ``` + /// + /// The redundant write this avoids is not merely noise: it lands in the + /// transitions log and, with `--always`/`--once` triggers in play, replays + /// every stored transformation for a query that changed nothing. + fn ensure_indexed_link( + &self, + storage: &mut impl NamedTypeLinks, + index: u32, + source: u32, + target: u32, + changes: &mut Vec<(Option, Option)>, + ) -> Result { + storage.try_ensure_created(index)?; + let stored = storage + .get_link(index) + .unwrap_or_else(|| Link::new(index, 0, 0)); + let source = Self::resolve_unspecified(source, stored.source); + let target = Self::resolve_unspecified(target, stored.target); + if stored.source != source || stored.target != target { + self.trace_msg(&format!( + "[EnsureLinkCreated] Updating link {index} => {}->{source}, {}->{target}.", + stored.source, stored.target + )); + storage.update(index, source, target)?; + let after = storage + .get_link(index) + .unwrap_or_else(|| Link::new(index, source, target)); + changes.push((Some(stored), Some(after))); + } else { + self.trace_msg(&format!( + "[EnsureLinkCreated] Link {index} is already correct => no-op." + )); + changes.push((Some(stored), Some(stored))); + } + Ok(index) + } + + /// `EnsureLinkCreated` for a definition with no address of its own: the + /// existing doublet is reused and reported as an unchanged pair, and only a + /// genuinely new one reports a creation. + fn ensure_doublet( + &self, + storage: &mut impl NamedTypeLinks, + source: u32, + target: u32, + changes: &mut Vec<(Option, Option)>, + ) -> u32 { + if let Some(existing_id) = Self::search_unspecified(storage, source, target) { + self.trace_msg(&format!( + "[EnsureLinkCreated] Link already found => ID={existing_id} => no-op." + )); + let existing = storage + .get_link(existing_id) + .unwrap_or_else(|| Link::new(existing_id, source, target)); + changes.push((Some(existing), Some(existing))); + existing_id + } else { + let source = Self::resolve_unspecified(source, 0); + let target = Self::resolve_unspecified(target, 0); + self.trace_msg(&format!( + "[EnsureLinkCreated] Creating link for (S={source}, T={target})." + )); + let created = storage.create(source, target); + changes.push((None, storage.get_link(created))); + created + } + } +} diff --git a/rust/src/transactions/mod.rs b/rust/src/transactions/mod.rs index 0315215..07b658d 100644 --- a/rust/src/transactions/mod.rs +++ b/rust/src/transactions/mod.rs @@ -67,6 +67,8 @@ //! log. mod log; +// Replay, recovery and retention live in a submodule; see transactions/recovery.rs. +mod recovery; mod types; use std::collections::HashSet; @@ -86,9 +88,7 @@ pub use types::{ CommitMode, DoubletLink, GenericDoubletLink, GenericTransition, LogRetentionPolicy, Transition, TransitionKind, }; -use types::{ - APPLIED_MARKER_PREFIX, COMMIT_MARKER_PREFIX, ROLLBACK_MARKER_PREFIX, TRANSITION_NAME_PREFIX, -}; +use types::{COMMIT_MARKER_PREFIX, ROLLBACK_MARKER_PREFIX, TRANSITION_NAME_PREFIX}; /// Pending state of a transaction (used by the explicit transaction /// handle and by per-write auto-transactions). @@ -625,328 +625,6 @@ where self.enforce_retention()?; Ok(()) } - - /// Public helper for higher-level decorators (e.g. version control) - /// — applies a single transition without writing a new log entry. - pub fn apply_transition(&mut self, transition: &GenericTransition) { - self.replaying = true; - self.try_apply_transition(transition, false); - self.replaying = false; - } - - /// Public helper for higher-level decorators (e.g. version control) - /// — reverts a single transition without writing a new log entry. - pub fn revert_transition(&mut self, transition: &GenericTransition) { - self.replaying = true; - self.try_revert_transition(transition); - self.replaying = false; - } - - fn try_apply_transition(&mut self, transition: &GenericTransition, record_applied: bool) { - let zero = T::from_byte(0); - let result: Result<(), LinkError> = match transition.kind { - TransitionKind::Create => { - if transition.after.index != zero && !self.inner.link_exists(transition.after.index) - { - self.inner - .ensure_link_created(transition.after.index) - .and_then(|_| { - self.inner - .update_link( - transition.after.index, - transition.after.source, - transition.after.target, - ) - .map(|_| ()) - }) - } else { - Ok(()) - } - } - TransitionKind::Update => { - if transition.after.index != zero && self.inner.link_exists(transition.after.index) - { - self.inner - .update_link( - transition.after.index, - transition.after.source, - transition.after.target, - ) - .map(|_| ()) - } else { - Ok(()) - } - } - TransitionKind::Delete => { - if transition.before.index != zero - && self.inner.link_exists(transition.before.index) - { - self.inner.delete_link(transition.before.index).map(|_| ()) - } else { - Ok(()) - } - } - }; - if let Err(e) = result { - if self.trace { - eprintln!( - "[Transactions] Failed to apply transition seq={}: {e}", - transition.sequence - ); - } - } - if record_applied { - let _ = self.mark_applied(transition); - } - } - - fn try_revert_transition(&mut self, transition: &GenericTransition) { - let zero = T::from_byte(0); - let result: Result<(), LinkError> = match transition.kind { - TransitionKind::Create => { - if transition.after.index != zero && self.inner.link_exists(transition.after.index) - { - self.inner.delete_link(transition.after.index).map(|_| ()) - } else { - Ok(()) - } - } - TransitionKind::Update => { - if transition.before.index != zero - && self.inner.link_exists(transition.before.index) - { - self.inner - .update_link( - transition.before.index, - transition.before.source, - transition.before.target, - ) - .map(|_| ()) - } else { - Ok(()) - } - } - TransitionKind::Delete => { - if transition.before.index != zero - && !self.inner.link_exists(transition.before.index) - { - self.inner - .ensure_link_created(transition.before.index) - .and_then(|_| { - self.inner - .update_link( - transition.before.index, - transition.before.source, - transition.before.target, - ) - .map(|_| ()) - }) - } else { - Ok(()) - } - } - }; - if let Err(e) = result { - if self.trace { - eprintln!( - "[Transactions] Failed to revert transition seq={}: {e}", - transition.sequence - ); - } - } - } - - fn mark_applied(&mut self, transition: &GenericTransition) -> Result<(), LinkError> { - if self.applied.insert(transition.sequence) { - self.write_marker(&format!("{APPLIED_MARKER_PREFIX}{}", transition.sequence))?; - if transition.sequence > self.applied_sequence { - self.applied_sequence = transition.sequence; - } - } - Ok(()) - } - - // ----- Recovery ------------------------------------------------------- - - /// Rebuilds the in-memory log and marker tables from the sidecar - /// log store and re-applies committed-but-unapplied side-effects. - /// - /// Entries that cannot be parsed are skipped: an append-only log - /// can end in the partial entry of a crashed write, and the - /// links-backed log can hold names that belong to other features. - /// An entry whose addresses do not fit into `T` is *not* skipped — - /// that means the log was written by a wider address type and - /// silently dropping it would corrupt the recovered state. - pub fn recover(&mut self) -> Result<(), LinkError> { - self.log.clear(); - self.committed.clear(); - self.rolled_back.clear(); - self.applied.clear(); - self.sequence_counter = 0; - self.applied_sequence = 0; - - for entry in self.log_store.read_log_entries()? { - if let Some(payload) = entry.strip_prefix(TRANSITION_NAME_PREFIX) { - match GenericTransition::::parse(payload) { - Ok(transition) => { - insert_ordered(&mut self.log, transition); - if transition.sequence > self.sequence_counter { - self.sequence_counter = transition.sequence; - } - } - Err(LinkError::AddressOutOfRange(value)) => { - return Err(LinkError::AddressOutOfRange(value)) - } - Err(error) => { - if self.trace { - eprintln!("[Transactions] Skipping unreadable log entry: {error}"); - } - } - } - } else if let Some(rest) = entry.strip_prefix(COMMIT_MARKER_PREFIX) { - if let Ok(tx_id) = u128::from_str_radix(rest, 16) { - self.committed.insert(tx_id); - } - } else if let Some(rest) = entry.strip_prefix(ROLLBACK_MARKER_PREFIX) { - if let Ok(tx_id) = u128::from_str_radix(rest, 16) { - self.rolled_back.insert(tx_id); - } - } else if let Some(rest) = entry.strip_prefix(APPLIED_MARKER_PREFIX) { - if let Ok(seq) = rest.parse::() { - self.applied.insert(seq); - if seq > self.applied_sequence { - self.applied_sequence = seq; - } - } - } - } - - // Re-apply committed-but-not-applied transitions (crash mid-async). - let log_snapshot: Vec> = self.log.clone(); - self.replaying = true; - for transition in &log_snapshot { - if !self.committed.contains(&transition.transaction_id) { - continue; - } - if self.applied.contains(&transition.sequence) { - continue; - } - self.try_apply_transition(transition, true); - } - // Auto-rollback transitions written but never committed and never rolled back (R10). - let mut pending_tx_ids: Vec = Vec::new(); - for transition in log_snapshot.iter().rev() { - if self.committed.contains(&transition.transaction_id) { - continue; - } - if self.rolled_back.contains(&transition.transaction_id) { - continue; - } - self.try_revert_transition(transition); - if !pending_tx_ids.contains(&transition.transaction_id) { - pending_tx_ids.push(transition.transaction_id); - } - } - self.replaying = false; - for tx_id in pending_tx_ids { - self.rolled_back.insert(tx_id); - self.write_marker(&format!("{ROLLBACK_MARKER_PREFIX}{tx_id:032x}"))?; - } - Ok(()) - } - - fn enforce_retention(&mut self) -> Result<(), LinkError> { - match self.retention_policy.clone() { - LogRetentionPolicy::Infinite => Ok(()), - LogRetentionPolicy::Sized { max_transitions } => self.enforce_sized(max_transitions), - LogRetentionPolicy::Chunked { - chunk_size, - archive_directory, - } => self.enforce_chunked(chunk_size, &archive_directory), - } - } - - fn enforce_sized(&mut self, max_transitions: u64) -> Result<(), LinkError> { - if max_transitions == 0 { - return Ok(()); - } - while self.log.len() as u64 > max_transitions { - let head = self.log[0]; - if !self.applied.contains(&head.sequence) { - self.replaying = true; - self.try_apply_transition(&head, true); - self.replaying = false; - if !self.applied.contains(&head.sequence) { - break; // R7: never drop an un-applied transition. - } - } - self.log.remove(0); - if self.trace { - eprintln!( - "[Transactions] Dropped applied transition seq={} per sized retention.", - head.sequence - ); - } - } - Ok(()) - } - - fn enforce_chunked( - &mut self, - chunk_size: u64, - archive_directory: &Path, - ) -> Result<(), LinkError> { - if chunk_size == 0 { - return Ok(()); - } - if (self.log.len() as u64) < chunk_size { - return Ok(()); - } - let chunk: Vec> = - self.log.iter().take(chunk_size as usize).copied().collect(); - for transition in &chunk { - if !self.applied.contains(&transition.sequence) { - self.replaying = true; - self.try_apply_transition(transition, true); - self.replaying = false; - if !self.applied.contains(&transition.sequence) { - return Ok(()); // never drop un-applied - } - } - } - std::fs::create_dir_all(archive_directory).map_err(|error| { - LinkError::StorageError(format!( - "failed to create archive dir {}: {error}", - archive_directory.display() - )) - })?; - let timestamp = now_unix_ms(); - let file_name = format!( - "transitions-chunk-{timestamp}-{:032x}.log", - new_transaction_id() - ); - let path = archive_directory.join(file_name); - use std::io::Write; - let mut file = std::fs::File::create(&path).map_err(|error| { - LinkError::StorageError(format!( - "failed to create archive file {}: {error}", - path.display() - )) - })?; - for transition in &chunk { - writeln!(file, "{}", transition.serialize())?; - } - file.flush()?; - if self.trace { - eprintln!( - "[Transactions] Archived {} transitions to {}.", - chunk.len(), - path.display() - ); - } - self.log.drain(0..chunk.len()); - Ok(()) - } } /// Read paths that lend out references, available whenever the wrapped diff --git a/rust/src/transactions/recovery.rs b/rust/src/transactions/recovery.rs new file mode 100644 index 0000000..9e10c2a --- /dev/null +++ b/rust/src/transactions/recovery.rs @@ -0,0 +1,359 @@ +//! Transition replay, crash recovery and log retention for +//! [`GenericTransactionsDecorator`]. +//! +//! Extracted from `transactions/mod.rs` for issue #100: the file had grown +//! past the 1000-line limit enforced by `rust/scripts/check-file-size.rs`. +//! These are the paths that read the sidecar log back — applying and +//! reverting individual transitions, replaying an interrupted run, and +//! trimming or archiving the log once it grows — as opposed to the write +//! paths that record new transitions in the parent module. + +use std::path::Path; + +use doublets::data::LinkReference; + +use crate::error::LinkError; +use crate::storage::LinksStorage; + +use super::log::TransitionLogStore; +use super::types::{ + GenericTransition, LogRetentionPolicy, TransitionKind, APPLIED_MARKER_PREFIX, + COMMIT_MARKER_PREFIX, ROLLBACK_MARKER_PREFIX, TRANSITION_NAME_PREFIX, +}; +use super::{insert_ordered, new_transaction_id, now_unix_ms, GenericTransactionsDecorator}; + +impl GenericTransactionsDecorator +where + T: LinkReference, + S: LinksStorage, + L: TransitionLogStore, +{ + /// Public helper for higher-level decorators (e.g. version control) + /// — applies a single transition without writing a new log entry. + pub fn apply_transition(&mut self, transition: &GenericTransition) { + self.replaying = true; + self.try_apply_transition(transition, false); + self.replaying = false; + } + + /// Public helper for higher-level decorators (e.g. version control) + /// — reverts a single transition without writing a new log entry. + pub fn revert_transition(&mut self, transition: &GenericTransition) { + self.replaying = true; + self.try_revert_transition(transition); + self.replaying = false; + } + + pub(super) fn try_apply_transition( + &mut self, + transition: &GenericTransition, + record_applied: bool, + ) { + let zero = T::from_byte(0); + let result: Result<(), LinkError> = match transition.kind { + TransitionKind::Create => { + if transition.after.index != zero && !self.inner.link_exists(transition.after.index) + { + self.inner + .ensure_link_created(transition.after.index) + .and_then(|_| { + self.inner + .update_link( + transition.after.index, + transition.after.source, + transition.after.target, + ) + .map(|_| ()) + }) + } else { + Ok(()) + } + } + TransitionKind::Update => { + if transition.after.index != zero && self.inner.link_exists(transition.after.index) + { + self.inner + .update_link( + transition.after.index, + transition.after.source, + transition.after.target, + ) + .map(|_| ()) + } else { + Ok(()) + } + } + TransitionKind::Delete => { + if transition.before.index != zero + && self.inner.link_exists(transition.before.index) + { + self.inner.delete_link(transition.before.index).map(|_| ()) + } else { + Ok(()) + } + } + }; + if let Err(e) = result { + if self.trace { + eprintln!( + "[Transactions] Failed to apply transition seq={}: {e}", + transition.sequence + ); + } + } + if record_applied { + let _ = self.mark_applied(transition); + } + } + + pub(super) fn try_revert_transition(&mut self, transition: &GenericTransition) { + let zero = T::from_byte(0); + let result: Result<(), LinkError> = match transition.kind { + TransitionKind::Create => { + if transition.after.index != zero && self.inner.link_exists(transition.after.index) + { + self.inner.delete_link(transition.after.index).map(|_| ()) + } else { + Ok(()) + } + } + TransitionKind::Update => { + if transition.before.index != zero + && self.inner.link_exists(transition.before.index) + { + self.inner + .update_link( + transition.before.index, + transition.before.source, + transition.before.target, + ) + .map(|_| ()) + } else { + Ok(()) + } + } + TransitionKind::Delete => { + if transition.before.index != zero + && !self.inner.link_exists(transition.before.index) + { + self.inner + .ensure_link_created(transition.before.index) + .and_then(|_| { + self.inner + .update_link( + transition.before.index, + transition.before.source, + transition.before.target, + ) + .map(|_| ()) + }) + } else { + Ok(()) + } + } + }; + if let Err(e) = result { + if self.trace { + eprintln!( + "[Transactions] Failed to revert transition seq={}: {e}", + transition.sequence + ); + } + } + } + + pub(super) fn mark_applied( + &mut self, + transition: &GenericTransition, + ) -> Result<(), LinkError> { + if self.applied.insert(transition.sequence) { + self.write_marker(&format!("{APPLIED_MARKER_PREFIX}{}", transition.sequence))?; + if transition.sequence > self.applied_sequence { + self.applied_sequence = transition.sequence; + } + } + Ok(()) + } + + // ----- Recovery ------------------------------------------------------- + + /// Rebuilds the in-memory log and marker tables from the sidecar + /// log store and re-applies committed-but-unapplied side-effects. + /// + /// Entries that cannot be parsed are skipped: an append-only log + /// can end in the partial entry of a crashed write, and the + /// links-backed log can hold names that belong to other features. + /// An entry whose addresses do not fit into `T` is *not* skipped — + /// that means the log was written by a wider address type and + /// silently dropping it would corrupt the recovered state. + pub fn recover(&mut self) -> Result<(), LinkError> { + self.log.clear(); + self.committed.clear(); + self.rolled_back.clear(); + self.applied.clear(); + self.sequence_counter = 0; + self.applied_sequence = 0; + + for entry in self.log_store.read_log_entries()? { + if let Some(payload) = entry.strip_prefix(TRANSITION_NAME_PREFIX) { + match GenericTransition::::parse(payload) { + Ok(transition) => { + insert_ordered(&mut self.log, transition); + if transition.sequence > self.sequence_counter { + self.sequence_counter = transition.sequence; + } + } + Err(LinkError::AddressOutOfRange(value)) => { + return Err(LinkError::AddressOutOfRange(value)) + } + Err(error) => { + if self.trace { + eprintln!("[Transactions] Skipping unreadable log entry: {error}"); + } + } + } + } else if let Some(rest) = entry.strip_prefix(COMMIT_MARKER_PREFIX) { + if let Ok(tx_id) = u128::from_str_radix(rest, 16) { + self.committed.insert(tx_id); + } + } else if let Some(rest) = entry.strip_prefix(ROLLBACK_MARKER_PREFIX) { + if let Ok(tx_id) = u128::from_str_radix(rest, 16) { + self.rolled_back.insert(tx_id); + } + } else if let Some(rest) = entry.strip_prefix(APPLIED_MARKER_PREFIX) { + if let Ok(seq) = rest.parse::() { + self.applied.insert(seq); + if seq > self.applied_sequence { + self.applied_sequence = seq; + } + } + } + } + + // Re-apply committed-but-not-applied transitions (crash mid-async). + let log_snapshot: Vec> = self.log.clone(); + self.replaying = true; + for transition in &log_snapshot { + if !self.committed.contains(&transition.transaction_id) { + continue; + } + if self.applied.contains(&transition.sequence) { + continue; + } + self.try_apply_transition(transition, true); + } + // Auto-rollback transitions written but never committed and never rolled back (R10). + let mut pending_tx_ids: Vec = Vec::new(); + for transition in log_snapshot.iter().rev() { + if self.committed.contains(&transition.transaction_id) { + continue; + } + if self.rolled_back.contains(&transition.transaction_id) { + continue; + } + self.try_revert_transition(transition); + if !pending_tx_ids.contains(&transition.transaction_id) { + pending_tx_ids.push(transition.transaction_id); + } + } + self.replaying = false; + for tx_id in pending_tx_ids { + self.rolled_back.insert(tx_id); + self.write_marker(&format!("{ROLLBACK_MARKER_PREFIX}{tx_id:032x}"))?; + } + Ok(()) + } + + pub(super) fn enforce_retention(&mut self) -> Result<(), LinkError> { + match self.retention_policy.clone() { + LogRetentionPolicy::Infinite => Ok(()), + LogRetentionPolicy::Sized { max_transitions } => self.enforce_sized(max_transitions), + LogRetentionPolicy::Chunked { + chunk_size, + archive_directory, + } => self.enforce_chunked(chunk_size, &archive_directory), + } + } + + fn enforce_sized(&mut self, max_transitions: u64) -> Result<(), LinkError> { + if max_transitions == 0 { + return Ok(()); + } + while self.log.len() as u64 > max_transitions { + let head = self.log[0]; + if !self.applied.contains(&head.sequence) { + self.replaying = true; + self.try_apply_transition(&head, true); + self.replaying = false; + if !self.applied.contains(&head.sequence) { + break; // R7: never drop an un-applied transition. + } + } + self.log.remove(0); + if self.trace { + eprintln!( + "[Transactions] Dropped applied transition seq={} per sized retention.", + head.sequence + ); + } + } + Ok(()) + } + + fn enforce_chunked( + &mut self, + chunk_size: u64, + archive_directory: &Path, + ) -> Result<(), LinkError> { + if chunk_size == 0 { + return Ok(()); + } + if (self.log.len() as u64) < chunk_size { + return Ok(()); + } + let chunk: Vec> = + self.log.iter().take(chunk_size as usize).copied().collect(); + for transition in &chunk { + if !self.applied.contains(&transition.sequence) { + self.replaying = true; + self.try_apply_transition(transition, true); + self.replaying = false; + if !self.applied.contains(&transition.sequence) { + return Ok(()); // never drop un-applied + } + } + } + std::fs::create_dir_all(archive_directory).map_err(|error| { + LinkError::StorageError(format!( + "failed to create archive dir {}: {error}", + archive_directory.display() + )) + })?; + let timestamp = now_unix_ms(); + let file_name = format!( + "transitions-chunk-{timestamp}-{:032x}.log", + new_transaction_id() + ); + let path = archive_directory.join(file_name); + use std::io::Write; + let mut file = std::fs::File::create(&path).map_err(|error| { + LinkError::StorageError(format!( + "failed to create archive file {}: {error}", + path.display() + )) + })?; + for transition in &chunk { + writeln!(file, "{}", transition.serialize())?; + } + file.flush()?; + if self.trace { + eprintln!( + "[Transactions] Archived {} transitions to {}.", + chunk.len(), + path.display() + ); + } + self.log.drain(0..chunk.len()); + Ok(()) + } +} From d4d01a18bc170b2ee2a6432a4ab643b2fd475723 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 29 Aug 2026 19:23:34 +0000 Subject: [PATCH 20/20] docs(issue-100): record the module split in the case study --- docs/case-studies/issue-100/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/case-studies/issue-100/README.md b/docs/case-studies/issue-100/README.md index 642cc50..e6416d7 100644 --- a/docs/case-studies/issue-100/README.md +++ b/docs/case-studies/issue-100/README.md @@ -225,6 +225,7 @@ worked around. | Addresses (R6) | Address allocation matches the C# store, free list persisted (§5.2). | | `--changes` (R6) | Placeholder updates, full delete cascades, deterministic order (§5.3). | | Unspecified halves (R6) | `resolve_unspecified` / `search_unspecified` at the write boundary (§5.4). | +| Layout | The two files the new code pushed past the 1000-line CI gate are split the way the repository already splits large ones — a child module reaching the parent's private items, so nothing is widened beyond `pub(super)`: `query_processor/mutations.rs` (the write side, next to the existing `query_processor/matching.rs`, mirroring C#'s `AdvancedMixedQueryProcessor.Mutations.cs`) and `transactions/recovery.rs` (transition replay, crash recovery, log retention). | | Library (R4, R5) | Every module of `link_cli` is public, along with the query patterns, the resolved links, the link reference validator and the transition wire-format constants. `NamedTypeLinks` is documented as *the* seam: every layer is written against it and every decorator both implements it and wraps another implementation of it, so a cache, an access check or a remote store slots in anywhere — including under `QueryProcessor`, which never learns what is beneath it. | ### C#