From b8d3a9b05328c33a5213976902db3d82d257ad38 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 10 Nov 2026 10:50:46 +0530 Subject: [PATCH 1/2] feat(prepared): copyable PreparedQuery and BrowserPreparedQuery limit setters (#872) PreparedQuery derives Clone so language bindings, which hold a prepared query behind a shared handle, can return a new prepared object from each limit setter. BrowserPreparedQuery gains withMaxDerivedFacts, withQueryMemoryLimit and withMaxIterations, each returning a new query that keeps the database open. A number at or above usize::MAX means no limit; a negative, fractional or non-finite one is API-017. Part of #862. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H2a7QouMzKp3tAWxgVsxRf --- CHANGELOG.md | 2 + CLAUDE.md | 6 +- docs/CONFIGURATION.md | 34 ++++++++ docs/TEST_COVERAGE.md | 9 ++- src/browser/mod.rs | 148 ++++++++++++++++++++++++++++++++++ src/query/datalog/prepared.rs | 4 + tests/limit_keywords_test.rs | 21 +++++ 7 files changed, 217 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b30ca21..6a769c88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Per-transaction WAL sync mode and chunk size: `TxOptions` and `Minigraf::begin_write_with`** (#871, part of #862). `TxOptions` (`#[non_exhaustive]`; fields `synchronous: Option` and `chunk_facts: Option`, builders `new`, `synchronous`, `chunk_facts`) sets either option for one `WriteTransaction`; `None` means the database's value, and `begin_write()` is `begin_write_with(TxOptions::default())`. `synchronous` decides whether that transaction's WAL entry is synced, whatever `OpenOptions::synchronous` is, so a bulk load can commit with `Normal` on a `Full` database; a `Full` commit syncs the whole WAL, so earlier `Normal` entries become durable with it. It has no effect on a transaction that becomes large (a checkpoint, durable in both modes; its WAL guard header is always synced, a deviation from the #862 design recorded there) or on an in-memory database. `chunk_facts` replaces `OpenOptions::transaction_chunk_facts` for that transaction, with the same clamp (1..=1,000,000). The options are not stored in the file, not gated by `allow_overrides`, and apply to that transaction only. `begin_write_with` fails as `begin_write` does (`INT-001`, `API-014`). The WAL writer no longer keeps a sync mode: each append is given its transaction's. See [docs/DURABILITY.md](docs/DURABILITY.md) for what survives a crash when modes are mixed. +- **Copyable prepared queries and browser limit setters** (#872, part of #862). `PreparedQuery` is `Clone`: a copy is an independent statement over the same database, so a limit setter applied to one leaves the other unchanged; language bindings use it to return a new prepared object from each setter. `BrowserPreparedQuery` gains `withMaxDerivedFacts(n)`, `withQueryMemoryLimit(bytes)` and `withMaxIterations(n)`, each returning a new prepared query that keeps the database open; a number at or above the platform's `usize::MAX` means no limit, and a negative, fractional or non-finite number throws `[API-017]`. `BrowserDb` still takes no open options. `docs/CONFIGURATION.md` lists which settings every binding exposes. + - **Configurable recursion round limit** (#861, part of #862). `OpenOptions::max_iterations` sets the most rounds one stratum of a rule query may run (default 1,000,000, see Breaking changes); `:max-iterations N` or `$slot` replaces it for one query, up or down (INT-012/013/014/015 as for `:max-derived-facts`), gated by the new `Overridable::MaxIterations`, which **is** in `Overridable::DEFAULTS`; `PreparedQuery::with_max_iterations` replaces it for one statement, ungated, and an allowed keyword still overrides it. Rounds are still counted per stratum. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md). ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 551386011..7ca4f0ace 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,7 +178,7 @@ cargo run < demos/demo_negation.txt - `rules.rs`: `RuleRegistry` — thread-safe rule management - `types.rs`: `EdnValue`, `Pattern`, `DatalogQuery`, `AsOf`, `ValidAt`, `Count` (`:limit`/`:offset` (#306) and `:max-derived-facts`/`:max-results`/`:query-memory-limit` (#870)/`:max-iterations` (#861) literal or slot), `DatalogQuery::order_by` (`:find` elements with `Order`, #306), `DatalogQuery::distinct` (default-false bare flag, #493), `WhereClause` (incl. `Not`, `NotJoin`); `PseudoAttr` enum, `AttributeSpec` wrapper - `optimizer.rs`: Selectivity-based join reordering on every build (the `wasm` feature is a deprecated no-op). `reorder()` gates every reordering site; tests turn it off per thread (`set_reorder`, `minigraf::__reorder` under `test-hooks`, #596); `plan_seeded` orders a body with its imported variables counted as bound, `plan_after` orders a rule body's clauses after its delta, each joining on a variable bound before it where one can (#694), and `subplans()` lets tests force the engine's negation bodies and `or` branches hashed or correlated (`set_subplans`, `minigraf::__subplans`, #546) - - `prepared.rs`: `BindValue`, `PreparedQuery` — parse-once/execute-many with named `$slot` bind slots; `prepare_query`, `substitute`; `Entity` and `Val(Ref)` fit each other's slots, bind mistakes are `API-022`/`023`/`024` (#181); a `:limit $n` or `:offset $o` slot takes `Val(Integer(n >= 0))` (`resolve_count_slot`, #306); limit-keyword slots follow their literal range (`resolve_limit_slot`, #870); `with_max_derived_facts`/`with_query_memory_limit`/`with_max_iterations` replace the database limits for one statement, ungated (#870, #861) + - `prepared.rs`: `BindValue`, `PreparedQuery` — parse-once/execute-many with named `$slot` bind slots; `prepare_query`, `substitute`; `Entity` and `Val(Ref)` fit each other's slots, bind mistakes are `API-022`/`023`/`024` (#181); a `:limit $n` or `:offset $o` slot takes `Val(Integer(n >= 0))` (`resolve_count_slot`, #306); limit-keyword slots follow their literal range (`resolve_limit_slot`, #870); `with_max_derived_facts`/`with_query_memory_limit`/`with_max_iterations` replace the database limits for one statement, ungated (#870, #861); `PreparedQuery: Clone`, so bindings derive a new prepared query per setter (#872) - `magic_sets.rs`: Magic-sets rewriting for demand-driven recursive rule evaluation (not applied to rules with `not`/`not-join`) 3a. **`src/query/engine/`** — Streaming query engine (#473), runs every query (#551): `plan.rs` (the operator tree of every query, or the executor's error code for it: patterns, expression clauses, `not`/`not-join` and `or`/`or-join`, and rule invocations, read as patterns of their relations (`plan_rules`, #548; a magic-set head constant is `HeadArg::Const`), in `optimizer::plan` order, then the `or`s in text order, then the deferred `not`s; an `or` branch or a `not` body is planned as a `:where` is; a clause list with no pattern first starts from `Seed`; a constant no cell equals is `Term::Never`, a scan that reads nothing; `:as-of` resolved when the query opens (`Snapshot::tx_bound`: a counter bound when `tx_id` rises with `tx_count`, else `TxBound::Timestamp`: committed `tx_count` ranges and pending `tx_id <= t`); a per-fact pseudo-attribute pattern reads the metadata column its source's scan fills (the hidden variable the parser named, #594), and `:db/valid-at` joins as `[e _ _]`; plain `:find` variables, aggregates with `:with`, and window functions, over an aggregate's groups too), `scan.rs` (one pattern over committed key ranges via `CommittedReader::key_source`/`live_keys`, AVET ranges for cross-kind constants, then the pending range by whole runs; one entity per probe for a join), `join.rs` (`IndexNestedLoopJoin` when the pattern's entity is bound, or, under a constant attribute and entity not constant, its value (#721: per outer row the AVET ranges of the values that join-equal it and the attribute's pending facts with its key, read once per query in `IdSpace::probes`; an entity cell needs the keyword naming it, known only where the query made the entity from a keyword (rule heads, magic seeds), else the join becomes the planner's `HashJoin`); `HashJoin` otherwise, and on a relation; `JoinKey`: floats by IEEE `==`, a keyword equals a ref to the entity it names), `filter.rs` (`Filter`/`Bind` and pseudo-attribute steps over batches; a `Bind` on a bound slot keeps the rows where the result is join-equal, #596), `collect.rs` (`CollectSet`, #316: `[(collect-set ?e :a) ?s]` per row probes `[e :a ?v]` as an `IndexNestedLoopJoin` does and binds the values as one set, `#{}` when none, metered; `Expr::CollectSet`, parsed only as a whole binding clause), `anti.rs` (`AntiJoin`, #546: a body with the same rows for every outer row runs once into a hash set of its imported-variable keys; any other body, or one whose first pattern's entity is imported, runs per outer row from `seed.rs`'s one-row `Seed`, via `Operator::rewind`; never deduplicates), `aggregate.rs` (`Aggregate`, #547: a hash table of groups keyed on cells as `Value`'s `Ord` compares values, one entry when ungrouped; `count`/`count-distinct` on cells, other aggregates on values via `datalog::aggregate::Agg`; groups sorted by value and finished in that order; a float group shows its least value by `value_order`; #641/#781 certify a complete grouping tuple as `Grouping::Ordered`: count/count-distinct/sum/avg, fixed-scalar extrema and Integer/nil sum-distinct; one varying ordered slot plus permitted scalar constants/repetitions, including :with (which retains Sort), no window/callback/CollectSet; one active group, bounded output batches, shared finish_group, internal DICT-cache eviction; unproved arguments and registered finalizers keep full-drain validation), `order.rs` and `order/arguments.rs` (#641/#781: snapshot-bound grouping/order and argument-domain proofs of the complete result order; safe committed integer/float/Boolean ranges can set the private `ScanSpec.index` hint to AVET and omit Sort, with `plan.find` restored from Sort.cols; entity-and-attribute scans retain EAVT; pending/staged records in that range, callbacks and CollectSet keep the original strategies; DICT comparators prove only window partition adjacency, never final Sort or aggregate group order; mixed integer/float keys can prove grouping order but keep Sort, and unique group keys discharge tie suffixes; `minigraf::__input_order` forces fallback under `test-hooks`), `sort.rs` (`Sort`, #306: the `:find` columns as values, in the full order of `:order-by` — its keys, then every other `:find` column ascending, by `datalog::aggregate::row_order`/`value_order`; under a `:limit` a heap of the best `offset + limit` rows; reads its whole input, dropping the DICT lookups after each batch, metered; its rows turned back into cells only as returned), `limit.rs` (`Limit`, #306: skips `offset` rows, passes on at most `limit`, then closes its child so nothing below reads more; planned after the last step, after `Builder::finish`, and after `Sort`), `window.rs` (`Window`, #547: holds each input row's passed-on cells and its named variables' values, then computes every window with `datalog::aggregate::windows`, the executor's code, and returns the rows in input order; #641 resolves compatible built-in windows to `WindowPartitioned`, retaining one partition and charged lookahead, coalescing completed partitions into bounded output batches, evicting DICT caches at internal input/result boundaries; full-input mode keeps query-lifetime caches), `union.rs`/`distinct.rs` (`Union` and `Distinct`, #546: each `or` branch planned as a `not` body is, hashed into a table or run per outer row; then one row per distinct binding, as the executor's `Value`s compare; `:distinct` adds `DistinctFind` after aggregates/windows and before sorting/pagination, keyed on final `:find` columns by aggregate-group equality, with canonical float zero/NaN output including set elements, #493), `cell.rs`/`batch.rs` (id cells in columnar batches of 1,024), `ids.rs` (DICT and provisional ids, `keyword_eid`), `project.rs` (ids → values for returned rows only; bounded stack UUID hints share DICT paths, with cache insertion and charges in projection order, #530), `relation.rs`/`recursive.rs` (#548: rule queries; a relation per evaluated predicate holds its derived rows, a set by `encode_value` identity, appended per round so `Old`/`Delta`/`Full` are ranges; `Recursive` runs each stratum's semi-naive rounds, one run per same-stratum top-level invocation, a rule reading its stratum otherwise run in full every round; scans read derived rows after committed and pending ones, with `nil` metadata), `meter.rs` (#549: one `Meter` per query, shared by every holder of state through a `Held` — hash join build sides, `not`/`or` tables, distinct sets, aggregate groups, window rows, rule relations and their temporaries, `IdSpace`, the set a `CollectSet` builds; each entry charged by a per-entry estimate including retained set elements and strings before it is inserted, containers grown with `try_reserve`, released on `rewind`/`close`/drop; over `OpenOptions::query_memory_limit`, or a refused allocation, is `QRY-012`; `Stream::close` drops the operator tree, keeping its counters). `mod.rs`: `Operator` (`next_batch`/`rewind`/`close`/`counters`), `Stream` (the operator tree over an owned `Snapshot`, pulled one batch at a time). `DatalogExecutor::open` / `execute_query` run every query on it: a cursor gets the `Stream` (`Answer::Stream`), `execute` drains it. @@ -197,7 +197,7 @@ cargo run < demos/demo_negation.txt 8. **`src/error.rs`** — Structured error codes: `MinigrafError`, `ErrorCategory`, `ErrorCode` registry (PRS/QRY/STG/WAL/API/INT codes); must match `docs/ERROR_REFERENCE.md`. `MinigrafError::invalid_argument` (API-017) and `MinigrafError::closed` (API-018) exist for language bindings -9. **`src/browser/`** — Browser WASM backend (`browser` feature): `buffer.rs` (`BrowserBufferBackend`, in-memory pages with dirty tracking; free pages a pinned generation may read are withheld from release, `pins::split_released`), `indexeddb.rs` (IndexedDB persistence); `BrowserDb.query` returns a `BrowserCursor` (JSON row batches); `BrowserDb.prepare` returns a `BrowserPreparedQuery` (`execute`/`query` with a bindings object, read by `binds.rs`: plain JS values or `{type, value}` forms, #181; a set as `{type: 'set', value: [..]}` or a JS `Set`, #316); `udfs.rs`: `registerPredicate`/`registerAggregate` (#180; values as `{type, value}` objects, a throw is `QRY-014`; JS values held in a thread-local table, the registry keeps ids). Tests here must be `#[wasm_bindgen_test]` (the module only builds for wasm32); `.github/workflows/wasm.yml` runs them in headless browsers, runs the WASI tests under Wasmtime, and lints both wasm targets. New file-backed integration test files need `#![cfg(not(target_arch = "wasm32"))]` +9. **`src/browser/`** — Browser WASM backend (`browser` feature): `buffer.rs` (`BrowserBufferBackend`, in-memory pages with dirty tracking; free pages a pinned generation may read are withheld from release, `pins::split_released`), `indexeddb.rs` (IndexedDB persistence); `BrowserDb.query` returns a `BrowserCursor` (JSON row batches); `BrowserDb.prepare` returns a `BrowserPreparedQuery` (`execute`/`query` with a bindings object; `withMaxDerivedFacts`/`withQueryMemoryLimit`/`withMaxIterations` return a new one, #872; read by `binds.rs`: plain JS values or `{type, value}` forms, #181; a set as `{type: 'set', value: [..]}` or a JS `Set`, #316); `udfs.rs`: `registerPredicate`/`registerAggregate` (#180; values as `{type, value}` objects, a throw is `QRY-014`; JS values held in a thread-local table, the registry keeps ids). Tests here must be `#[wasm_bindgen_test]` (the module only builds for wasm32); `.github/workflows/wasm.yml` runs them in headless browsers, runs the WASI tests under Wasmtime, and lints both wasm targets. New file-backed integration test files need `#![cfg(not(target_arch = "wasm32"))]` ### Data Model @@ -250,7 +250,7 @@ Auto-migrates v7 → v8 on open (spec §9, with a backup meta page). v1–v6 are ## Test Coverage -**2132 tests** (2111 active, 21 ignored; unit + integration + doc). Engine answers once checked by the executor are frozen in `tests/answers/` (#551). +**2133 tests** (2112 active, 21 ignored; unit + integration + doc). Engine answers once checked by the executor are frozen in `tests/answers/` (#551). See `docs/TEST_COVERAGE.md` for the full per-file breakdown. The #760 deterministic storage suite adds three native unit tests in a separate `--cfg minigraf_shuttle` build, outside the default count. Run only `graph::storage::deterministic::` with that cfg; ordinary unit tests do not enter its scheduler. `deterministic-schedules.yml` runs relevant v3 PRs on Linux/macOS/Windows plus Linux MSRV, and larger nightly/manual searches with replay artifacts. The identical main workflow activates the v3 schedule after both PRs merge. See `docs/testing/deterministic-schedules.md` for scope, controls and findings. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2485bf9ee..eb5f9e7e4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -141,3 +141,37 @@ let pq = db .with_query_memory_limit(16 << 20); let rows = pq.execute(&[])?; ``` + +`PreparedQuery` is `Clone` (#872): `pq.clone().with_max_iterations(10)` gives +a second statement with its own limits and leaves `pq` unchanged. + +## Language bindings + +From v3.0.0 every binding exposes the override list, its default, the +`transaction_chunk_facts` and `max_iterations` open options and the three +setters (#872). Each setter returns a new prepared query and leaves the one +it was called on unchanged. The default list is read from +`Overridable::DEFAULTS`, never written out again in a binding. `TxOptions` +reaches the bindings with binding write transactions (#824). + +| Binding | Override list | Default list | Open options | Setters | +|---|---|---|---|---| +| Python | `minigraf.open(..., allow_overrides=[Overridable.MAX_DERIVED_FACTS, ...])` | `minigraf.OVERRIDABLE_DEFAULTS` | `transaction_chunk_facts=`, `max_iterations=` | `with_max_derived_facts`, `with_query_memory_limit`, `with_max_iterations` | +| Java, Android | builder `allowOverrides(...)` | `Minigraf.OVERRIDABLE_DEFAULTS` | `transactionChunkFacts`, `maxIterations` | `withMaxDerivedFacts`, `withQueryMemoryLimit`, `withMaxIterations` | +| Swift | `allowOverrides` | `Overridable.defaults` | `transactionChunkFacts`, `maxIterations` | `withMaxDerivedFacts`, `withQueryMemoryLimit`, `withMaxIterations` | +| Node | `allowOverrides: ['maxDerivedFacts', 'maxResults', 'queryMemoryLimit', 'maxIterations']` | `OVERRIDABLE_DEFAULTS` | `transactionChunkFacts`, `maxIterations` | `withMaxDerivedFacts`, `withQueryMemoryLimit`, `withMaxIterations` | +| C | `"allow_overrides": ["max_derived_facts", "max_results", "query_memory_limit", "max_iterations"]` in the options JSON | `minigraf_overridable_defaults()` (a JSON array) | `"transaction_chunk_facts"`, `"max_iterations"` | `minigraf_prepared_with_max_derived_facts`, `minigraf_prepared_with_query_memory_limit`, `minigraf_prepared_with_max_iterations` | +| Browser (`BrowserDb`) | none | none | none | `withMaxDerivedFacts`, `withQueryMemoryLimit`, `withMaxIterations` on `BrowserPreparedQuery` | + +As in Rust, a missing override list means the default list and an empty one +refuses every override keyword; a list replaces the defaults rather than +adding to them. Counts are signed in the bindings, and a negative one is +`API-017`. The no-limit value of a setter is the one each binding already +uses for `query_memory_limit`: `i64::MAX` in Python, Java, Android and Swift, +`Number.MAX_SAFE_INTEGER` or more in Node, `SIZE_MAX` in C. + +`BrowserDb` takes no open options, so it always runs with the defaults, +`Overridable::DEFAULTS` among them, and refuses `:query-memory-limit` in +query text. Its prepared queries' setters take a non-negative integer +number; at or above the platform's largest `usize` (2^32 − 1) it means no +limit, and a negative, fractional or non-finite number is `API-017`. diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 7c2aa481c..24b0e1fb5 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -4,12 +4,12 @@ | | Default features | With `--features test-hooks` | |---|---|---| -| **Active** | 2,111 | 2,149 | +| **Active** | 2,112 | 2,150 | | **Ignored** | 21 | 30 | -| **Total** | 2,132 | 2,179 | +| **Total** | 2,133 | 2,180 | Counts above are the current Cargo test inventory (active versus ignored), -including #777, #789, #781, #794, #795, #837, #865, #869, #870, #861, #863 and #871 (Linux). Verification results below identify which suites ran. +including #777, #789, #781, #794, #795, #837, #865, #869, #870, #861, #863, #871 and #872 (Linux). Verification results below identify which suites ran. **#863 nightly candidates**: four engine unit tests check the `test-hooks` path events and memory peaks (both builds). With `test-hooks` only, @@ -661,12 +661,13 @@ These are not `#[test]`s; run them with `cargo kani` or `.github/workflows/kani. - **`OpenOptions::allow_overrides`** (#869; `tests/allow_overrides_test.rs`, 6 tests; `src/overrides.rs`, 2 unit tests; 1 doc test on `OpenOptions::allow_overrides`): the defaults allow `:max-derived-facts` and `:max-results`; an empty list refuses each with `API-025` from `execute`, `query`, `prepare` and `WriteTransaction::execute`, and queries without them still run; a list replaces the defaults; a refused statement leaves the write transaction usable; `DEFAULTS` read back, chained and with duplicates; the list holds on a file database and is not persisted; `:max-derived-facts` is reported first when both keywords are refused. API-025's registration and documentation are checked by the registry tests. - **`:query-memory-limit`, limit slots and `PreparedQuery` setters** (#870; `tests/limit_keywords_test.rs`, 17 tests; parser 3 unit tests, `src/overrides.rs` 1 unit test; 2 doc tests on the setters; 1 `#[wasm_bindgen_test]` in `src/browser/mod.rs`): the keyword lowers and raises the database limit (execute, cursor, write transaction); under 0 every stateful query fails with `QRY-012`, while a value-only query over checkpointed facts runs (keyword and setter); it is refused by default with `API-025` (literal and slot, every entry point, `BrowserDb`) and the list replaces the defaults; `INT-012`/`013`/`015` validation; slots on all three keywords change the outcome per execution, with `API-024`/`API-022`/`INT-014`/`INT-015` bind errors and `API-010` on `execute`/`query`; the setters replace the database value, a keyword overrides them, and they are not gated; `check_overrides` order over three keywords; `Count::literal` on a slot is `INT-032`. Grammar corpus: 3 valid, 2 syntax, 2 semantic fixtures; verifier 41 tokens and an `INT-015` witness; one `datalog_parser` fuzz seed. - **`:max-iterations`, `OpenOptions::max_iterations` and `PreparedQuery::with_max_iterations`** (#861; `tests/limit_keywords_test.rs`, 8 tests; parser 1 unit test; `src/overrides.rs` order test extended; 1 doc test on the setter; 1 `#[wasm_bindgen_test]` in `src/browser/mod.rs`; `tests/allow_overrides_test.rs` pins the new `DEFAULTS`): a 1,500-round chain passes at the new 1,000,000 default (over the old fixed 1,000); the option, keyword (execute, write transaction) and setter each set the limit up or down, the keyword over the setter, with the 4-edge chain's 5-round boundary checked both ways and `0` failing; rounds are counted per stratum (two 5-round strata pass at 5); `INT-020` names `:max-iterations` and `OpenOptions::max_iterations`; the keyword is in `DEFAULTS` and refused with `API-025` under an empty list (execute, query, prepare); `INT-012`/`013`/`014`/`015` validation; slot outcomes per execution with `INT-014`/`API-024`/`API-022` bind errors and `API-010` on `execute`; the setter is ungated and applies to cursors. Grammar corpus: 2 valid, 2 syntax, 2 semantic fixtures; verifier 42 tokens; one `datalog_parser` fuzz seed. +- **Copyable `PreparedQuery` and browser setters** (#872; `tests/limit_keywords_test.rs`, 1 test; 2 `#[wasm_bindgen_test]` in `src/browser/mod.rs`): a clone and its source are independent under each of the three setters, in both directions; `BrowserPreparedQuery.withMaxDerivedFacts(1)`/`withMaxIterations(1)` give `INT-020` (execute and cursor) and `withQueryMemoryLimit(16)` `QRY-012` while the receiver still returns every row, with no override keyword in the text; a chained setter keeps the limits not set again and the last value applies, `usize::MAX` and above meaning no limit; a negative, fractional, NaN or infinite argument is `API-017` on every setter, `0` and `-0` pass. ## Browser and WASI None of these are counted in the summary. The wasm32 targets run in CI (`.github/workflows/wasm.yml`). -- **In a browser** (`wasm-pack test --headless`, Firefox and Chrome): 62 `wasm_bindgen_test` tests. The final #641 snapshot passed in headless Firefox on the bench; Chrome remains a CI run. +- **In a browser** (`wasm-pack test --headless`, Firefox and Chrome): 68 `wasm_bindgen_test` tests (Firefox, 2026-11-10). The final #641 snapshot passed in headless Firefox on the bench; Chrome remains a CI run. - Three committed-memory-backend input-order fixtures: scalar Sort omission, streaming counts with Boolean/float representatives and mixed numeric group order, and partition windows preserving float edges and numeric group classes (#641). - Twenty-six for `BrowserDb`, including IndexedDB persistence, the default `allow_overrides` list (#869), the cursor, and the flush returning every dirty page and failing on one it cannot read (#470). - Eleven for `BrowserBufferBackend` (which used `#[test]` and never ran before). diff --git a/src/browser/mod.rs b/src/browser/mod.rs index f15151472..d162ae669 100644 --- a/src/browser/mod.rs +++ b/src/browser/mod.rs @@ -532,6 +532,59 @@ impl BrowserPreparedQuery { cursor: Some(cursor), }) } + + /// A new prepared query with `n` as its derived-facts limit; this one is + /// unchanged (#872). An allowed `:max-derived-facts` or `:max-results` in + /// the query still overrides it. `n` is a non-negative integer; at or + /// above the platform's largest `usize` (2^32 − 1) it means no limit. + /// Anything else throws `[API-017]`. + #[wasm_bindgen(js_name = withMaxDerivedFacts)] + pub fn with_max_derived_facts(&self, n: f64) -> Result { + let n = limit_arg("withMaxDerivedFacts", n)?; + Ok(self.derive(|q| q.with_max_derived_facts(n))) + } + + /// A new prepared query with `bytes` as its query memory limit; this one + /// is unchanged (#872). `0` lets only queries that hold no state run. + /// Takes its argument as `withMaxDerivedFacts` does. + #[wasm_bindgen(js_name = withQueryMemoryLimit)] + pub fn with_query_memory_limit(&self, bytes: f64) -> Result { + let bytes = limit_arg("withQueryMemoryLimit", bytes)?; + Ok(self.derive(|q| q.with_query_memory_limit(bytes))) + } + + /// A new prepared query with `n` as its rounds-per-stratum limit; this + /// one is unchanged (#872). An allowed `:max-iterations` in the query + /// still overrides it. Takes its argument as `withMaxDerivedFacts` does. + #[wasm_bindgen(js_name = withMaxIterations)] + pub fn with_max_iterations(&self, n: f64) -> Result { + let n = limit_arg("withMaxIterations", n)?; + Ok(self.derive(|q| q.with_max_iterations(n))) + } + + fn derive(&self, set: impl FnOnce(PreparedQuery) -> PreparedQuery) -> BrowserPreparedQuery { + BrowserPreparedQuery { + _db: self._db.clone(), + query: set(self.query.clone()), + } + } +} + +/// A setter's count: a non-negative integer, saturated at `usize::MAX` (no +/// limit); a negative, fractional or non-finite number is `API-017`. +fn limit_arg(what: &str, n: f64) -> Result { + if !n.is_finite() || n < 0.0 || n.fract() != 0.0 { + return Err(JsValue::from_str( + &crate::MinigrafError::invalid_argument(format!( + "{what}: {n} is not a non-negative integer" + )) + .to_string(), + )); + } + // Checked above: whole and non-negative, and `as` saturates at + // `usize::MAX`, so a number at or above it is no limit. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + Ok(n as usize) } fn as_slice(binds: &[(String, BindValue)]) -> Vec<(&str, BindValue)> { @@ -770,6 +823,101 @@ mod tests { assert!(text.starts_with("[INT-020] "), "rejection is INT-020"); } + async fn reach_db() -> BrowserDb { + let db = BrowserDb::open_in_memory().expect("open_in_memory"); + db.execute("(transact [[:a :edge :b] [:b :edge :c] [:c :edge :d]])".to_string()) + .await + .expect("transact"); + db.execute("(transact [[:p :v 1] [:q :v 1] [:r :v 2]])".to_string()) + .await + .expect("transact"); + db.execute("(rule [(reach ?x ?y) [?x :edge ?y]])".to_string()) + .await + .expect("rule"); + db.execute("(rule [(reach ?x ?y) [?x :edge ?z] (reach ?z ?y)])".to_string()) + .await + .expect("rule"); + db + } + + fn result_rows(json: &str) -> usize { + let v: serde_json::Value = serde_json::from_str(json).unwrap(); + v["results"].as_array().unwrap().len() + } + + fn rejection(r: Result) -> String { + r.err() + .and_then(|e| e.as_string()) + .expect("rejection is a string") + } + + const REACH: &str = "(query [:find ?x ?y :where (reach ?x ?y)])"; + const GROUPED: &str = "(query [:find ?v (count ?e) :where [?e :v ?v]])"; + + // #872: each setter returns a new prepared query; the receiver keeps its + // limits. The query text has no override keyword. + #[wasm_bindgen_test] + async fn prepared_setters_derive_new_queries() { + let db = reach_db().await; + let pq = db.prepare(REACH).expect("prepare"); + let none = JsValue::UNDEFINED; + for derived in [ + pq.with_max_derived_facts(1.0).expect("setter"), + pq.with_max_iterations(1.0).expect("setter"), + ] { + let text = rejection(derived.execute(none.clone())); + assert!(text.starts_with("[INT-020] "), "derived query is INT-020"); + assert!(derived.query(none.clone()).is_err(), "cursor is refused"); + } + assert_eq!(result_rows(&pq.execute(none.clone()).expect("receiver")), 6); + + let grouped = db.prepare(GROUPED).expect("prepare"); + let small = grouped.with_query_memory_limit(16.0).expect("setter"); + let text = rejection(small.execute(none.clone())); + assert!(text.starts_with("[QRY-012] "), "derived query is QRY-012"); + assert_eq!( + result_rows(&grouped.execute(none.clone()).expect("receiver")), + 2 + ); + + // A derived query takes another setter; the last value applies and + // the others are kept. + let raised = pq + .with_max_iterations(1.0) + .and_then(|q| q.with_max_derived_facts(1.0)) + .and_then(|q| q.with_max_iterations(1e300)) + .expect("setters"); + let text = rejection(raised.execute(none.clone())); + assert!(text.starts_with("[INT-020] "), "derived-facts limit kept"); + let unlimited = pq + .with_max_iterations(1.0) + .and_then(|q| q.with_max_iterations(usize::MAX as f64)) + .expect("setters"); + assert_eq!(result_rows(&unlimited.execute(none).expect("no limit")), 6); + } + + // #872: a setter's argument is a non-negative integer number. + #[wasm_bindgen_test] + async fn prepared_setters_reject_bad_counts() { + let db = reach_db().await; + let pq = db.prepare(REACH).expect("prepare"); + for n in [-1.0, 1.5, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + for r in [ + pq.with_max_derived_facts(n), + pq.with_query_memory_limit(n), + pq.with_max_iterations(n), + ] { + let text = r.err().and_then(|e| e.as_string()).expect("rejected"); + assert!(text.starts_with("[API-017] "), "rejection is API-017"); + } + } + assert!(pq.with_max_iterations(0.0).is_ok(), "zero passes through"); + assert!( + pq.with_max_iterations(-0.0).is_ok(), + "negative zero is zero" + ); + } + #[wasm_bindgen_test] async fn in_memory_transact_and_query() { let db = BrowserDb::open_in_memory().expect("open_in_memory"); diff --git a/src/query/datalog/prepared.rs b/src/query/datalog/prepared.rs index aebd6ddc4..4e52f24c8 100644 --- a/src/query/datalog/prepared.rs +++ b/src/query/datalog/prepared.rs @@ -76,6 +76,10 @@ fn bind_value_type_name(bv: &BindValue) -> &'static str { /// let young = pq.execute(&[("age", BindValue::Val(Value::Integer(25)))]).unwrap(); /// let older = pq.execute(&[("age", BindValue::Val(Value::Integer(30)))]).unwrap(); /// ``` +/// +/// A clone is an independent statement over the same database: a limit +/// setter applied to one does not change the other (#872). +#[derive(Clone)] pub struct PreparedQuery { template: DatalogQuery, slot_names: Vec, diff --git a/tests/limit_keywords_test.rs b/tests/limit_keywords_test.rs index 31fdba497..01a9d7ad4 100644 --- a/tests/limit_keywords_test.rs +++ b/tests/limit_keywords_test.rs @@ -530,3 +530,24 @@ fn iterations_setter_replaces_the_database_value() { .with_max_iterations(1); assert_eq!(rows(pq.execute(&[]).expect("keyword wins")), 10); } + +// ─── Clone (#872) ──────────────────────────────────────────────────────────── + +#[test] +fn prepared_query_clone_is_independent() { + let db = setup(OpenOptions::new()); + let pq = db.prepare(REACH).expect("prepare"); + let low = pq.clone().with_max_derived_facts(1); + assert_eq!(code(low.execute(&[])), "INT-020"); + assert_eq!(rows(pq.execute(&[]).expect("source unchanged")), 10); + + let low = db.prepare(REACH).expect("prepare").with_max_iterations(1); + let high = low.clone().with_max_iterations(REACH_ROUNDS as usize); + assert_eq!(rows(high.execute(&[]).expect("copy raised")), 10); + assert_eq!(code(low.execute(&[])), "INT-020"); + + let grouped = db.prepare(GROUPED).expect("prepare"); + let small = grouped.clone().with_query_memory_limit(64); + assert_eq!(code(small.execute(&[])), "QRY-012"); + assert_eq!(rows(grouped.execute(&[]).expect("source unchanged")), 5); +} From 0a77e6fb9cf339a429ebe48de82c5686bb7de84d Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 10 Nov 2026 15:59:54 +0530 Subject: [PATCH 2/2] docs: split the Java and Android rows of the binding table (#872) Android has no options builder and no Minigraf object: its override list is an OpenOptions field and its defaults a top-level OVERRIDABLE_DEFAULTS. Swift's setters carry their argument labels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H2a7QouMzKp3tAWxgVsxRf --- docs/CONFIGURATION.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index eb5f9e7e4..63d443482 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -157,8 +157,9 @@ reaches the bindings with binding write transactions (#824). | Binding | Override list | Default list | Open options | Setters | |---|---|---|---|---| | Python | `minigraf.open(..., allow_overrides=[Overridable.MAX_DERIVED_FACTS, ...])` | `minigraf.OVERRIDABLE_DEFAULTS` | `transaction_chunk_facts=`, `max_iterations=` | `with_max_derived_facts`, `with_query_memory_limit`, `with_max_iterations` | -| Java, Android | builder `allowOverrides(...)` | `Minigraf.OVERRIDABLE_DEFAULTS` | `transactionChunkFacts`, `maxIterations` | `withMaxDerivedFacts`, `withQueryMemoryLimit`, `withMaxIterations` | -| Swift | `allowOverrides` | `Overridable.defaults` | `transactionChunkFacts`, `maxIterations` | `withMaxDerivedFacts`, `withQueryMemoryLimit`, `withMaxIterations` | +| Java | builder `Minigraf.options().allowOverrides(...)` | `Minigraf.OVERRIDABLE_DEFAULTS` | `transactionChunkFacts`, `maxIterations` | `withMaxDerivedFacts`, `withQueryMemoryLimit`, `withMaxIterations` | +| Android | `OpenOptions(allowOverrides = ...)` | top-level `OVERRIDABLE_DEFAULTS` | `transactionChunkFacts`, `maxIterations` | `withMaxDerivedFacts`, `withQueryMemoryLimit`, `withMaxIterations` | +| Swift | `OpenOptions(allowOverrides: ...)` | `Overridable.defaults` | `transactionChunkFacts`, `maxIterations` | `withMaxDerivedFacts(n:)`, `withQueryMemoryLimit(bytes:)`, `withMaxIterations(n:)` | | Node | `allowOverrides: ['maxDerivedFacts', 'maxResults', 'queryMemoryLimit', 'maxIterations']` | `OVERRIDABLE_DEFAULTS` | `transactionChunkFacts`, `maxIterations` | `withMaxDerivedFacts`, `withQueryMemoryLimit`, `withMaxIterations` | | C | `"allow_overrides": ["max_derived_facts", "max_results", "query_memory_limit", "max_iterations"]` in the options JSON | `minigraf_overridable_defaults()` (a JSON array) | `"transaction_chunk_facts"`, `"max_iterations"` | `minigraf_prepared_with_max_derived_facts`, `minigraf_prepared_with_query_memory_limit`, `minigraf_prepared_with_max_iterations` | | Browser (`BrowserDb`) | none | none | none | `withMaxDerivedFacts`, `withQueryMemoryLimit`, `withMaxIterations` on `BrowserPreparedQuery` |