diff --git a/.changeset/reject-negative-limits.md b/.changeset/reject-negative-limits.md new file mode 100644 index 0000000000..2cec606838 --- /dev/null +++ b/.changeset/reject-negative-limits.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Reject negative `.limit()` values and negative limits passed to `setWindow()` before they can change a query or its active window. Keep zero limits and negative offsets working. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 3b5412b255..aefa868b24 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -6700,17 +6700,40 @@ multiple groups, varying prefix predicates and downstream acquisition remain outside this lane; existing support/numeric budgets and their prior evidence remain unchanged. -Review5740133634 raises an adjacent negative-width contract question. The -support-window owner above is the location for a future maintainer decision and -its witness. With `TopKArray(5, -2)`, install ordered values0/4/6/8/10, then insert2 -at rank1. Both maina3c4d0676 and the integrated branch emit moveIn10/moveOut6 while -reporting size0. The executable same-source probe is recorded in the -[second-main integration review](oracle-reviews/2026-11-02-removal-oracle-gaps.md#second-main-integration--a3c4d0676). -The accepted rank-selection law in this change covers nonnegative widths. -No negative-width empty-result law is claimed. Defining that behavior needs a -maintainer decision before extending generated moves and source-edit histories. -The adapter reuse path requires nonnegative target bounds and cannot take this -negative-width input as an eligible page-window move. +The negative-width question has a maintainer decision (2026-11-02): public +`.limit(negative)` and `utils.setWindow({ limit: negative })` must throw +synchronously before changing the authored query or live window. The pagination +owner's [limit admission companion](../../packages/db/tests/query/limit-admission-oracle.ts) +checks that sign law, the documented RangeError class, and unchanged rows, +bounds, status, request errors, +publications, acquisitions, releases, aborts and prior operation outcome. Each +generated history exercises limit-only and combined moves before startup, +after readiness, during initial loading, during an unfinished move and during +source replay; builder construction is a separate path. Rejected operations +are followed by partial valid moves and source updates. Valid zero, -0, +positive integer, Infinity and negative-offset neighbors use independent +rank selection; every pending candidate has its own held fixture. +The settlement recorder follows the admitted operation to fulfillment or +rejection and compares its exact error at a named checkpoint. Each generated +move-pending negative history also crosses successful and failed acquisitions; +failure must retain the previous window and rows before a partial retry. +Every pending campaign reaches successful settlement in 21 histories, and both +negative move campaigns reach failed settlement in 21. A one-second diagnostic +budget detects stalled controlled work without imposing a one-task product cut. +Request-generation corruption fails the settlement comparison; corruption of +the rollback window after a failed acquisition fails the partial-next-use check. + +This is a public admission contract for ordinary ordered list queries, not a +negative-width result law for raw IVM operators. The original counterexample +remains: `TopKArray(5, -2)`, installed values 0/4/6/8/10, then insertion of 2, +emits moveIn 10/moveOut 6 while reporting size 0. It remains outside the raw +operator's supported nonnegative-width law. NaN, positive fractional widths, +raw authored IR, framework error timing and provider delivery are outside the +new admission evidence. Existing findOne, order and reentrancy error precedence +remains separate. All 24 fixed/random admission campaigns reach every declared +state in 21 histories each; unchanged production fails all six negative +receivers, and eight hostile mutations fail the intended comparisons. See the +[admission evidence](oracle-reviews/2026-11-02-negative-limit-admission.md). **Repaired operator defects:** negative-window rows, size, source edits and return moves now agree with absolute ranks on all three paths. Each path's diff --git a/docs/contributing/oracle-reviews/2026-11-02-negative-limit-admission.md b/docs/contributing/oracle-reviews/2026-11-02-negative-limit-admission.md new file mode 100644 index 0000000000..58514beba3 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-11-02-negative-limit-admission.md @@ -0,0 +1,439 @@ +# Negative limit admission + +## Oracle-first gate + +Production base: `30e85464ed2b9b12c5f10981a062427a1fc8daaa` (merged #2316). +No production file changed before the following RED runs. This follow-up has +its own evidence; the merged PR's results are not credited here. + +- **Law and authority:** maintainer decision on 2026-11-02 in the #2291/#2316 + follow-up: negative limits must throw. A rejected `.limit()` preserves its + builder; rejected `setWindow` preserves the existing window and demand. + This rejects admission, rather than defining a negative-width result. +- **Receiving paths:** all supported `.limit()` calls use BaseQueryBuilder; + callback/config/liveQueryCollectionOptions paths receive that builder. + `utils.setWindow` is bound to CollectionConfigBuilder. The companion runs + construction, unstarted, ready, initial-pending, move-pending and + replay-pending receivers separately. Adapter reuse already excludes negative + targets; window-controller page sizes normalize them. Neither creates a + separate negative-input receiver. This claims no framework error timing. +- **Reach:** each fixed and random campaign has one example plus 20 generated + histories. Each valid history tests 0, -0, a generated positive integer and + Infinity in fresh fixtures. Pending campaigns assert an unfinished operation + and held acquisition before each candidate. Negative histories test -2, + -0.5, -Number.MIN_VALUE, -Infinity and a generated negative magnitude, with + both omitted and supplied offsets. Reach checks admission, next use and held + work separately per receiver/campaign. +- **RED:** from `packages/db`, run + `TANSTACK_DB_ORACLE_STATISTICS=1 pnpm exec vitest run tests/query/pagination-oracle.property.test.ts --maxWorkers=2 --coverage.enabled=false -t 'negative limit admission'`. + Result before production edits: 12 intended negative admission assertion + failures; 12 valid-neighbor campaigns pass, each reaching every declared + state in 21 histories. Type checking reports no errors. The initial wrong + createLiveQueryCollection call was a setup error and supplies no RED credit; + it was repaired before these runs. +- **Generated RED and replay:** each of the six negative properties also fails + at its own admission-return comparison under seed 2291, path 1, through + `node --import tsx tests/oracle-replay.ts tests/query/pagination-oracle.property.test.ts --maxWorkers=2 --coverage.enabled=false` with + `TANSTACK_DB_ORACLE_PROPERTY=pagination.limit-admission--negative`, + `TANSTACK_DB_ORACLE_SEED=2291`, `TANSTACK_DB_ORACLE_PATH=1`. + The manifest selects the random test directly; no manual name filter is used. + The guarded witness records one failed execution of the requested property. +- **Limits:** ordinary ordered list queries, fully installed finite source + rows, and controlled acquisition settlement. Provider row delivery, + framework error timing, raw IR, NaN, positive fractional widths and raw IVM + negative widths are not claimed. findOne has its existing fixed-limit law; + this does not promise unrestricted positive limits there or revise error + precedence for queries already rejected by another guard. + +## Independent review and instrument repairs + +Three reviewers used oracle-review before production edits. Source-inspected +findings were resolved in the oracle before the final RED gate: + +- Copy authored builder bounds; do not compare a live IR object with itself. +- Record a discriminated thrown outcome, including thrown non-Error values. +- Yield while acquisitions remain held before comparing unchanged facts. +- Compare every accepted candidate's rows and bounds at its own settlement. +- Give each accepted pending candidate its own held fixture. +- Add held source replay: a guard after pendingSourceRecovery would miss it. +- Add direct manifest replay selection and a valid negative-offset neighbor. + +The reviewers ran no tests. Their source review is not GREEN evidence. + +## Guide accounting + +ORC-001/002/003: the maintainer sign decision, a pure sign model and an identity +relation on captured observations remain separate from production. Rank +selection is an independent full sort and absolute-rank predicate. Prose names +the grammar, public entry points and cuts beside the driver. + +ORC-004/008/009: ranks -5..5, 12 unique keyed rows, initial offsets 0..2, +initial limits 1..3 and negative magnitudes 1..1,000,000 vary the reported +history. Explicit threshold values retain fractions, the smallest negative +number and infinity. Removing the held/replay/partial-bound/source-update axes +loses their corresponding observation. NaN and positive fractions are excluded +because the decision does not settle their policy. The identity relation needs +only captured public observations; it duplicates no lifecycle state machine. + +ORC-005/013/014/015: receiver-specific admission-return, next-turn while held, +settled rows, inherited partial bounds and source-update observations check the +promised boundaries. Zero and tiny negative distinguish a misplaced threshold; +a negative-offset neighbor rejects a guard against all negative bounds. The +controlled source's whole source is installed before its loads; there is no +claim that a provider supplies that premise. Per-receiver declared reach is +asserted. No concrete missing-input hypothesis arises for a synchronous sign +check, whose argument is available at admission. + +ORC-007/010: both campaigns use the same grammar, examples, recorder, comparison +and budget; only seed choice differs. The guarded replay checks the requested +seed/path directly. Failure fidelity retains the first law/checkpoint and +original/reduced histories; cleanup preserves primary and secondary failures. + +ORC-011: no distinct shared-semantic-fault hypothesis needs another model; the +sign and identity laws have no incremental algorithm. ORC-016: examples and +generated histories use the same model/driver/checker; no unit assertions with +a separate expected answer are added. ORC-006 and ORC-012 closure use the repair and hostile-mutant evidence below. + + +## Repair evidence + +The oracle-first commit is `7ecf97dff2afb772e35970c095be83ecb11a188e`. +It contains the reviewed oracle and RED record with no production repair. +The repair uses one internal validator at builder cloning and before the live +window records a request generation. The existing findOne, order and reentrant +checks keep their ordering. No public method or error class is added. + +- **GREEN:** from `packages/db`, run + `TANSTACK_DB_ORACLE_STATISTICS=1 pnpm exec vitest run tests/query/pagination-oracle.property.test.ts tests/query/pagination.test.ts tests/query/live-query-collection.test.ts tests/live-query-window-controller.test.ts tests/production-error-messages-oracle.test.ts --maxWorkers=2 --coverage.enabled=false`. + All 533 tests in five files pass. All 24 new campaigns pass. Every declared + state is reached by 21 histories in its own campaign: admission on all six + receivers, next use on all five window receivers, and held work on each + initial/move/replay receiver. Each valid history reconstructs all four + candidates with separate fixtures. One example plus 20 generated histories + runs in both fixed-seed and fresh-seed campaigns. +- **Replay:** all twelve receiver/category properties pass the guarded replay + command above at seed 2291/path 1, each with exactly one witnessed execution. +- **Checks:** `pnpm exec tsc --noEmit -p packages/db/tsconfig.test.json`, + `pnpm lint:changed`, and `git diff --check` pass. The consumer bundle check + `pnpm test:production-errors` verifies 264 development literals (22 shared) + are erased in production and retained in development in ESM and CommonJS. + The new error site uses code 261 and built-in RangeError. + +### Hostile mutations + +Each mutation ran against a generated history through the guarded replay at +seed 2291/path 1. All eight failed by an oracle comparison, not setup failure, +crash or timeout. Mutations were restored after each run. + +| Wrong implementation | Rejected observation | +| --- | --- | +| Remove builder validation | builder admission return | +| Remove window validation | ready window admission return | +| Reject zero with <= 0 | valid builder admission return | +| Reject only negative integers | builder admission of -0.5 | +| Validate after pendingSourceRecovery | replay-pending admission return | +| Mutate builder limit, then throw | authored builder bounds at admission return | +| Queue a valid window move, then reject the negative call | initial-pending settled window | +| Reject positive limits while ordered work is pending | valid initial-pending admission return | + +The queued-move mutant is rejected at settlement. It does not show that a +private top-K mutation is publicly observable while the source is held. The +next-turn comparison observes only public facts while held; the later settled +and next-use comparisons protect changes that are visible only afterward. + +### Shipped weight + +`pnpm size:compare --packages db --base 30e85464ed2b9b12c5f10981a062427a1fc8daaa --exact-base --files` +reports db +23 gzip / +151 Brotli bytes. The typical application is +49 gzip / +-118 Brotli bytes. These are measured bundle deltas, not source-line counts. +The minified additions are 59 bytes for the single validator and 18 bytes for +its two callers. Sharing one error site avoids duplicate guards and diagnostics; +a default validation argument also removes an explicit undefined branch. No +existing lifecycle guard or recovery path becomes redundant under the new +admission law, so none is removed to pay for validation. This small growth is +the cost of checking the two previously unchecked public inputs. + +### Closure boundary + +The sign/admission defect is closed for the documented ordinary ordered list +receivers and generated histories at synchronous return, next turn while held, +settlement, partial-bound inheritance and subsequent source updates. This is +bounded evidence, not a proof for every history. The limits above remain; +in particular no raw-operator negative-width result law or additional numeric +validation policy is claimed. There is no known in-scope counterexample. + + +## Reviewed production head + +The tested production head is `46058c58c36d26d8dcdc921f5d70b008a4d5fe65`. +Its `packages/db/src` tree is `476f38cff16ffc97a67ce103f7d73789fe4e1ef1`. +The subsequent evidence/changeset commit changes no production source or oracle. + +## Size and review follow-up (2026-11-02) + +This entry supersedes the earlier size result. Reviewed and tested head: +`e7957c56a6b80b314fdbdcfded6f308284e81c4d`. +Its `packages/db/src` tree is `400ef43d38f69b6347fa32bb3526c30be32373b6`. +The admission companion blob is `1d85c87b2e0266b7b5b68ddb35dfccb0f9d192f1`. + +### Law, experiment and inference + +The existing builder and window contracts remain fixed. Negative limits still +throw synchronously before mutation; valid neighbors and existing guard +precedence remain unchanged. The builder and window utility JSDoc additionally +promise RangeError. The shared admission comparison previously checked only +whether the operation threw, discarding the recorded error value. + +The oracle comparison was strengthened before the production refactor. It now +checks the complete discriminated outcome and requires RangeError on rejection. +Unchanged production passed all 24 admission campaigns. Replacing RangeError +with Error, and separately throwing undefined, each failed all 12 negative +campaigns at the intended error-class comparison. Neither failed during setup, +crashed, timed out or survived. Both mutations were restored. This is a checker +repair: the original production class was already correct, so its baseline is +GREEN rather than a claimed production RED. + +The same six receivers, grammar, budgets, sign model and observation cuts +remain. Each campaign reaches admission in 21 histories; the five window +receivers also reach next use in 21; each pending receiver reaches its held +premise in 21. No assertion, input dimension, path or run budget was removed. +The original oracle-first RED and eight earlier mutant results remain recorded +above. ORC-001 through ORC-016 retain their earlier accounting; the new class +comparison supplies additional ORC-005/006 enforcement without changing the +independent model, history grammar or scope. + +### Production reductions + +All reductions preserve existing behavior for constructed immutable queries: + +- Merge query clauses once in the private clone helper, replacing 16 repeated + spreads. Explicit undefined still clears regular or functional selection. +- Delete the single-source wrapper's duplicate cardinality check. Its surviving + validator already requires exactly one key for both FROM and JOIN before + reading source values, then creates exactly one reference or throws. +- Remove the dead saved active window operation. The existing reentrancy guard + rejects an active operation; the synchronous operation still clears in finally. +- Share WHERE/HAVING reference conversion and expression validation. Conversion + remains conditional, so invalid primitive callback results remain rejected. +- Inline the one remaining type-description use, retaining null's special name + and deleting branches that duplicated typeof's result. + +The removed private wrapper's stale mangle entry was deleted. Other mangle +assignments, lifecycle guards, errors and recovery paths remain unchanged. +Injected mutable accessors through internal query IR or the internal owned +constructor path are outside the query-construction equivalence claim. + +### Validation and shipped weight + +From `packages/db`: + +```sh +TANSTACK_DB_ORACLE_STATISTICS=1 pnpm exec vitest run tests/query/builder tests/query/pagination-oracle.property.test.ts tests/query/pagination.test.ts tests/query/live-query-collection.test.ts tests/live-query-window-controller.test.ts tests/production-error-messages-oracle.test.ts tests/query/aggregate-select-oracle.property.test.ts tests/query/ir-stable-identity-oracle.test.ts --maxWorkers=2 --coverage.enabled=false +``` + +All 754 tests in 20 files pass, with no type errors. Test TypeScript compilation, +changed-file lint, the private-mangle check, formatting and diff checks pass. +The consumer error check verifies 264 literals, including 22 shared literals, +are erased in production and retained in development for ESM and CommonJS. + +The exact-baseline command in the earlier size section reports: + +| Bundle | Gzip delta | Brotli delta | +| --- | ---: | ---: | +| Typical app | -6 bytes (96,323 to 96,317) | -209 bytes | +| Full db exports | -21 bytes | +27 bytes | + +All eight app scenarios shrink in gzip. Brotli varies by scenario; Effect, +includes, join and local-storage scenarios still grow. This claims a net +reduction for the typical app in both compression formats, not every bundle. +The builder contributes 352 fewer minified bytes; the validator adds 59 and +the live builder adds 7, for 286 fewer minified app bytes overall. Names, +comments and formatting were not shortened to obtain these results. + +### Review evaluation and loss audit + +The comprehensive review and two delegated oracle reviewers found one item: +R1, the missing runtime error-class comparison. It is confirmed and fixed. +The simplifier's S1 clone merge, S2 dead-operation save, S3 redundant source +wrapper and S4 shared condition validation were evaluated separately and applied. +S5, the redundant type-description helper, was found during implementation and +independently reviewed. The sections above record each repair and its evidence. + +The final production and comparison changes received another source review; +both delegated reviewers used oracle-review. No new findings remained. Their +inspection does not substitute for the executed results above. The reviewers +identified a precise enforcement gap and justified the simplifications while +leaving byte savings to measurement. + +No known in-scope counterexample remains. The earlier provider, framework, +raw-IR and numeric-policy limits remain. Local validation is complete; whole-PR +readiness still requires creating the new follow-up PR and checking its CI. + +## Published replay-manifest repair + +PR #2333 CI found that the source-name integrity test treated the admission +family as literal names. At head `7fd0d09ef`, the focused command from +`packages/db` failed at the source-mapping comparison: + +```sh +pnpm exec vitest run tests/oracle-replay.test.ts -t 'maps literal owners' --maxWorkers=2 --coverage.enabled=false +``` + +The repair validates the six receiver names, both admission names, and the +computed source template, following the existing computed-family checks. +All twelve admission names also enter the existing guarded direct-replay +comparison, which requires exactly one executed property. No name is exempted +from source validation; no oracle model, comparison, production code or run +budget changed. The full command passes all 82 tests without type errors: + +```sh +pnpm exec vitest run tests/oracle-replay.test.ts --maxWorkers=2 --coverage.enabled=false +``` + +Changed-file lint and formatting pass. The published documentation review also +removed an inaccessible local-ledger citation and a reviewer hiring remark; +the technical findings and measured evidence remain above. + +## Ten-item follow-up review + +The review was evaluated against published head +`56287b7f4956012fd65087a6e46db1d8854fe8bd`. Production admission behavior stayed +unchanged. The public-entry, raw-IR and numeric-policy limits above still apply. + +| Item | Result and action | +| --- | --- | +| 1. Missing error 261 docs | Confirmed by `node scripts/generate-error-docs.mjs --check`. Regenerate the committed page and add a CLI integrity check of the repository page, alongside the existing fixture checks. Generation also refreshes error 206's stale source path. | +| 2. RangeError precedence | Executed public probes confirm all three earlier guards. Document their precedence explicitly and list the reentrancy error. The changeset promises rejection before mutation; it does not promise a particular class before these guards. | +| 3. Native error class | Keep the accepted RangeError contract. A new exported error class would add API surface. The existing error page explicitly distinguishes coded native errors from named TanStack error classes. | +| 4. Raw IR and TopK | The exact negative-width counterexample remains in the coverage map outside the supported raw-operator law. A compiler-only guard cannot replace synchronous builder admission or window moves, which do not recompile. No compiler contract is added. | +| 5. NaN and null | Executed probes confirm both can pass the runtime comparison. NaN policy remains undecided; null is outside the number-typed input. This negative-limit repair does not introduce general numeric-input validation. | +| 6. Operation settlement | Confirmed instrument gap. Observe the exact admitted operation outcome before cleanup and compare it at a named settlement checkpoint. Generation corruption now gives a comparison failure with a replayable reduced history. | +| 7. Corrupt settled window | The proposed settledWindow mutant already fails the original admission-return comparison: getWindow reads settledWindow. Preserve the distinct useful idea by adding failed held moves; a different rollback-currentWindow mutant survives the old grammar and fails the extension's partial-next-use check. | +| 8. Rejected-value diagnostic | The omission is confirmed. Current coded diagnostics identify the invalid limit and code 261; no accepted contract promises its value. Retain this as a diagnostic design proposal without changing production message inputs or shipped bytes. | +| 9. Condition return type | Constrain the internal helper's input and output to BasicExpression, the existing WHERE/HAVING IR condition type. Keep the runtime classifier and public callback signature unchanged. TypeScript validates the cleanup. | +| 10. Shared source invariant | Executed FROM/JOIN probes reject empty and multi-source objects before use and admit single-source neighbors. Their literal contexts cannot take the union exception. Retain the shared invariant without restoring a redundant wrapper or assertion. | + +### Settlement law and experiments + +The accepted rejection-identity law includes the outcome of an operation that +was already admitted. Atomic window publication also requires a failed move to +keep the previous settled window and public rows. The extension records promise +settlement before releasing controlled acquisitions. It compares fulfillment, +rejection identity, window bounds and independently selected rows, then performs +a partial retry and a source update. Every generated move-pending negative +history executes both successful and failed acquisition schedules. Other pending +receivers retain their successful schedules; this adds no initial-load or replay +failure-policy claim. + +The unchanged original instrument passes all 24 admission campaigns. Its +request-generation mutant produces an unmarked chaining-cycle failure for a +held move and a timeout during replay. Those are not named-comparison kills. +With the extended instrument, the same mutation fails the held-move and replay +settlement comparisons. Guarded generated replay uses +`pagination.limit-admission-replay-pending-negative`, seed `2291`, path `1`; +the witness records one requested execution and a reduced failure at +`replay-pending settled`. + +The settledWindow mutant fails the original `limit admission` return comparison +on both held-move and replay paths. The distinct rollback mutant changes only +`currentWindow = settledWindow` in the asynchronous rejection handler to an +invalid width. It passes the original two negative fixed campaigns and fails +the extension at `move-pending source update`, where the partial retry reports +limit -2 instead of the original positive limit. These are separate outcomes. +All temporary production mutations are restored. + +Two fresh agents used oracle-review for source inspection. One found that the +first extension required completion after one timer turn. A control that defers +successful settlement with `setTimeout(resolve, 0)` confirms that false rejection. +The repair waits for the recorded completion, with an explicit one-second +**diagnostic budget**, before comparing the observed outcome. It clears the +deadline timer. The same task-scheduled control passes afterward. This is bounded +progress evidence for the controlled fixture, not a product deadline or proof +about arbitrary provider timing. The campaign allows 30 seconds so repeated +bounded waits during shrinking can preserve a named failure. Seed, run count, +fixed examples and random-campaign parity remain unchanged. + +ORC-001/002/003/005: the original sign and identity authority remains; the new +outcome judgment derives from the injected acquisition result, independently +of production generations. Prose distinguishes acquisition settlement from +publication and names each comparison. ORC-004/008/009: both outcome schedules +share every generated payload; removing failure loses the rollback distinction, +and removing settlement observation loses the generation comparison. Existing +rank and width bounds remain unchanged. The previous settled window is necessary +because failure and success have different legal next observations. +ORC-006/007/010/013/015: mutants and the legal scheduling control distinguish +wrong state, a stalled operation and permitted asynchronous timing. Named +comparisons preserve original/reduced traces and exact rejection identity; +cleanup still releases held work. Declared successful/failed settlement reaches +supplement the existing per-receiver reaches. Both campaigns keep 20 generated +histories plus their shared example. ORC-011 introduces no second formulation: +no shared semantic fault hypothesis needs another model. ORC-014 remains limited +to controlled acquisition settlement, with no provider-delivery or framework +claim. ORC-016 adds no unit examples to the oracle. The committed-page check is +an ordinary CLI artifact-integrity test in its existing ordinary test file. + + +### Final follow-up verification + +The tested tree is based on `56287b7f4956012fd65087a6e46db1d8854fe8bd`. +The only production changes in this follow-up are the internal condition type +and the error-precedence JSDoc. Neither changes emitted runtime behavior. All +temporary mutations were restored before the final checks. The following +SHA-256 values identify the tested production and instrument sources: + +| Source | SHA-256 | +| --- | --- | +| `packages/db/src/query/validate-limit.ts` | `f4838c2d3bedfffa869ef205dc2f16515183c17b8f6502feee7978a65d588014` | +| `packages/db/src/query/builder/index.ts` | `2c88c4c32ae9dd7d84267248b29a7e17907bad3652f6a246e156913a8accb4ed` | +| `packages/db/src/query/live/collection-config-builder.ts` | `31c7a438095ea7b23a0d09ce73f1d5c2089d20b5ec4b140aa3c9d5047e651175` | +| `packages/db/tests/query/limit-admission-oracle.ts` | `433391cb0fd2c840e0fda53f7738e75dbe474154b7ede7fb1de74032d9c8375b` | +| `packages/db/tests/generate-error-docs.test.ts` | `26efa3154b6f796aa661c290f93b8bdd34aea98b963ae4765e84428f5c077f29` | + +From `packages/db`, the final selected suite passes **772 tests in 22 files**, +with no type errors: + +```sh +TANSTACK_DB_ORACLE_STATISTICS=1 pnpm exec vitest run tests/query/builder tests/query/pagination-oracle.property.test.ts tests/query/pagination.test.ts tests/query/live-query-collection.test.ts tests/live-query-window-controller.test.ts tests/production-error-messages-oracle.test.ts tests/query/aggregate-select-oracle.property.test.ts tests/query/ir-stable-identity-oracle.test.ts tests/generate-error-docs.test.ts tests/error-docs-commands.test.ts --maxWorkers=2 --coverage.enabled=false +``` + +The final guarded replay suite passes **82 tests**, with no type errors: + +```sh +pnpm exec vitest run tests/oracle-replay.test.ts --maxWorkers=2 --coverage.enabled=false +``` + +The successful-settlement reach is 21 histories for each pending fixed/random +campaign. Each negative move-pending campaign also reaches failed settlement +in 21 histories. The generated generation-mutant replay fails the named +settlement comparison and reduces to path `1:0:0:0:0:0:0:0:0:0:0:0`; it records +one requested execution and finishes in 13.74 seconds without a CLI timeout +override. The restored branch is GREEN. The distinct rollback mutant fails +its next-use comparison. The valid task-scheduled settlement control passes. + +The committed-page control rejects the old page with the generator's +`docs/errors.md is out of date` diagnostic. The regenerated page passes +`node scripts/generate-error-docs.mjs --check`. Test TypeScript compilation, +`pnpm lint:changed`, direct type-aware ESLint, formatting and `git diff --check` pass. No oracle run +budget decreased. The complete root `pnpm test` was not run locally. + +Shipped size was remeasured against the exact merged baseline +`30e85464ed2b9b12c5f10981a062427a1fc8daaa`: + +```sh +pnpm size:compare --packages db --base 30e85464ed2b9b12c5f10981a062427a1fc8daaa --exact-base --files +``` + +The typical app remains **-6 gzip bytes** (96,323 to 96,317) and **-209 Brotli +bytes**. Full db exports change by -21 gzip bytes and +27 Brotli bytes. All +eight app scenarios decrease in gzip; Brotli varies by scenario. These results +do not establish arbitrary provider timing, framework error timing, raw-IR +admission, NaN policy, or negative-width IVM result semantics. + + +The pre-commit type-aware lint found that the narrower helper input made the +null diagnostic appear unreachable. The final helper keeps its runtime guard +and widens only that diagnostic operand to unknown, because JavaScript callers +can violate the callback's static type. This type assertion emits no code. +The selected 772-test suite and type-aware lint pass again on that final source; +the source table above includes it. The oracle source is unchanged from its +82-test guarded replay run. diff --git a/docs/errors.md b/docs/errors.md index 32e60566a8..7adb02e1e4 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -2188,7 +2188,7 @@ Failed to get item with key "${String(key)}" from object store "${objectStore.na -## Error 206: `collection/index.ts` +## Error 206: `collection/validate-indexing.ts` Development builds show this message, with `${...}` replaced by values: @@ -2629,3 +2629,13 @@ Development builds show this message, with `${...}` replaced by values: ```text Source collection '${collectionId}' failed its first load ``` + + + +## Error 261: `query/validate-limit.ts` + +Development builds show this message, with `${...}` replaced by values: + +```text +limit must be nonnegative +``` diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index b636738f25..000d6639d0 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -210,7 +210,6 @@ "restoreInitialWindow": "d9", "settleTruncateReplay": "ea", "takeInternal": "eb", - "_createRefForSource": "ee", "compileBasePipeline": "ef", "scheduleIdleCleanup": "eg", "scheduleRepairRetry": "eh", diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index 62dbbfec0f..ae1aaafbb9 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -27,6 +27,7 @@ import { SubQueryMustHaveFromClauseError, } from '../../errors.js' import { validateJoinConditions } from '../join-conditions.js' +import { validateLimit } from '../validate-limit.js' import { codedMessage, devBuild } from '../../error-message.js' import { getQueryIR } from './query-ir.js' import { cloneQueryForPlacement } from './clone-query.js' @@ -152,27 +153,10 @@ export class BaseQueryBuilder { } private _clone( - query: Partial, + patch: Partial, ): BaseQueryBuilder { - // Every clone receives a freshly spread query, so it needs no copy. - return new BaseQueryBuilder(query, true) - } - - /** - * Creates a source reference from a source object - * @param source - An object with a single key-value pair - * @param context - Context string for error messages (e.g., "from clause", "join clause") - * @returns A tuple of [alias, ref] where alias is the source key and ref is the created reference - */ - private _createRefForSource( - source: TSource, - context: SourceClauseContext, - ): [string, CollectionSourceRef | QueryRef] { - const refs = this._createRefsForSource(source, context) - if (refs.length !== 1) { - throw new OnlyOneSourceAllowedError(context) - } - return refs[0]! + // Merge each clause into a fresh query; explicit undefined clears a clause. + return new BaseQueryBuilder({ ...this.query, ...patch }, true) } private _createRefsForSource( @@ -253,10 +237,9 @@ export class BaseQueryBuilder { from( source: SingleSource, ): QueryBuilder> { - const [, from] = this._createRefForSource(source, `from clause`) + const [, from] = this._createRefsForSource(source, `from clause`)[0]! return this._clone({ - ...this.query, from, }) as any } @@ -288,7 +271,6 @@ export class BaseQueryBuilder { ): QueryBuilder { if (sourceOrBranch instanceof BaseQueryBuilder) { return this._clone({ - ...this.query, from: new UnionAll( [sourceOrBranch, ...branches].map((branch) => cloneQueryForPlacement( @@ -307,7 +289,6 @@ export class BaseQueryBuilder { refs.length === 1 ? refs[0]![1] : new UnionFrom(refs.map((r) => r[1])) return this._clone({ - ...this.query, from, }) as any } @@ -351,7 +332,7 @@ export class BaseQueryBuilder { ): QueryBuilder< MergeContextWithJoinType, TJoinType> > { - const [alias, from] = this._createRefForSource(source, `join clause`) + const [alias, from] = this._createRefsForSource(source, `join clause`)[0]! // Create a temporary context for the callback const currentAliases = this._getCurrentAliases() @@ -372,7 +353,6 @@ export class BaseQueryBuilder { const existingJoins = this.query.join || [] return this._clone({ - ...this.query, join: [...existingJoins, joinClause], }) as any } @@ -515,26 +495,11 @@ export class BaseQueryBuilder { aliases, this._getCurrentBindings(), ) as RefsForContext - const rawExpression = callback(refProxy) - - // Allow bare boolean column references like `.where(({ u }) => u.active)` - // by converting ref proxies to PropRef expressions, the same way helper - // functions like `not()` and `eq()` do via `toExpression()`. - const expression = isRefProxy(rawExpression) - ? toExpression(rawExpression) - : rawExpression - - // Validate that the callback returned a valid expression - // This catches common mistakes like using JavaScript comparison operators (===, !==, etc.) - // which return boolean primitives instead of expression objects - if (!isExpressionLike(expression)) { - throw new InvalidWhereExpressionError(getValueTypeName(expression)) - } + const expression = toCondition(callback(refProxy)) const existingWhere = this.query.where || [] return this._clone({ - ...this.query, where: [...existingWhere, expression], }) as any } @@ -575,26 +540,11 @@ export class BaseQueryBuilder { ? createRefProxyWithSelected(aliases, this._getCurrentBindings()) : createRefProxy(aliases, this._getCurrentBindings()) ) as RefsForContext - const rawExpression = callback(refProxy) - - // Allow bare boolean column references like `.having(({ $selected }) => $selected.isActive)` - // by converting ref proxies to PropRef expressions, the same way helper - // functions like `not()` and `eq()` do via `toExpression()`. - const expression = isRefProxy(rawExpression) - ? toExpression(rawExpression) - : rawExpression - - // Validate that the callback returned a valid expression - // This catches common mistakes like using JavaScript comparison operators (===, !==, etc.) - // which return boolean primitives instead of expression objects - if (!isExpressionLike(expression)) { - throw new InvalidWhereExpressionError(getValueTypeName(expression)) - } + const expression = toCondition(callback(refProxy)) const existingHaving = this.query.having || [] return this._clone({ - ...this.query, having: [...existingHaving, expression], }) as any } @@ -669,7 +619,6 @@ export class BaseQueryBuilder { }) return this._clone({ - ...this.query, select: select, fnSelect: undefined, // remove the fnSelect clause if it exists }) as any @@ -753,7 +702,6 @@ export class BaseQueryBuilder { const existingOrderBy: OrderBy = this.query.orderBy || [] return this._clone({ - ...this.query, orderBy: [...existingOrderBy, ...orderByClauses], }) as any } @@ -812,7 +760,6 @@ export class BaseQueryBuilder { // Extend existing groupBy expressions (multiple groupBy calls should accumulate) const existingGroupBy = this.query.groupBy || [] return this._clone({ - ...this.query, groupBy: [...existingGroupBy, ...newExpressions], ...(typeof keys === `function` ? {} : { grouping: keys.grouping }), }) as any @@ -824,6 +771,7 @@ export class BaseQueryBuilder { * * @param count - Maximum number of rows to return * @returns A QueryBuilder with the limit applied + * @throws RangeError if count is negative * * @example * ```ts @@ -835,8 +783,8 @@ export class BaseQueryBuilder { * ``` */ limit(count: number): QueryBuilder { + validateLimit(count) return this._clone({ - ...this.query, limit: count, }) as any } @@ -860,7 +808,6 @@ export class BaseQueryBuilder { */ offset(count: number): QueryBuilder { return this._clone({ - ...this.query, offset: count, }) as any } @@ -881,7 +828,6 @@ export class BaseQueryBuilder { */ distinct(): QueryBuilder { return this._clone({ - ...this.query, distinct: true, }) as any } @@ -901,7 +847,6 @@ export class BaseQueryBuilder { */ findOne(): QueryBuilder { return this._clone({ - ...this.query, // TODO: enforcing return only one result with also a default orderBy if none is specified // limit: 1, singleResult: true, @@ -997,7 +942,6 @@ export class BaseQueryBuilder { callback: (row: TContext[`schema`]) => TFuncSelectResult, ): FnSelectQueryResult { return builder._clone({ - ...builder.query, select: undefined, // remove the select clause if it exists fnSelect: callback, }) as any @@ -1021,7 +965,6 @@ export class BaseQueryBuilder { callback: (row: TContext[`schema`]) => any, ): QueryBuilder { return builder._clone({ - ...builder.query, fnWhere: [ ...(builder.query.fnWhere || []), callback as (row: NamespacedRow) => any, @@ -1049,7 +992,6 @@ export class BaseQueryBuilder { callback: (row: FunctionalHavingRow) => any, ): QueryBuilder { return builder._clone({ - ...builder.query, fnHaving: [ ...(builder.query.fnHaving || []), callback as (row: NamespacedRow) => any, @@ -1086,12 +1028,18 @@ export class BaseQueryBuilder { } } -// Helper to get a descriptive type name for error messages -function getValueTypeName(value: unknown): string { - if (value === null) return `null` - if (value === undefined) return `undefined` - if (typeof value === `object`) return `object` - return typeof value +// WHERE and HAVING admit bare boolean columns by converting their ref proxies. +// Keep other values unchanged: JavaScript comparisons return primitives, which +// must fail validation rather than becoming literal query expressions. +function toCondition(value: BasicExpression): BasicExpression { + const expression = isRefProxy(value) ? toExpression(value) : value + if (!isExpressionLike(expression)) { + throw new InvalidWhereExpressionError( + // JavaScript callers can return null despite the callback's static type. + (expression as unknown) === null ? `null` : typeof expression, + ) + } + return expression } // Helper to ensure we have a BasicExpression/Aggregate for a value diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 5ca54cc646..51d0aa75f8 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1817,7 +1817,11 @@ custom-key query needs contributor reduction. Inline materialization must not create recursive Collection machinery. A windowed query has an `orderBy`; its window is the ordered view, by offset -and limit, that `setWindow` moves. A `findOne` is bounded by a top-K too: it +and limit, that `setWindow` moves. A negative limit throws synchronously at +`.limit()` or `setWindow()` admission, before changing the authored query, +window or demand (maintainer decision, 2026-11-02). Zero, including -0, stays +valid. Negative offsets retain their absolute-rank selection semantics. +A `findOne` is bounded by a top-K too: it keeps its compiled limit (1, or 0 for `.limit(0).findOne()`), and `setWindow` throws `SetWindowFindOneLimitError` for any other limit. An ordered `findOne` has a movable window. An unordered `findOne` is a fixed one-row result that diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 4f54869d14..fd040af61f 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1,5 +1,6 @@ import { D2, output } from '@tanstack/db-ivm' import { compileCollectionQuery } from '../compiler/index.js' +import { validateLimit } from '../validate-limit.js' import { LoadSubsetOperationAbortedError, MissingAliasInputsError, @@ -72,9 +73,13 @@ export type LiveQueryCollectionUtils = UtilsRecord & { * and an unordered one is a fixed one-row result that accepts only its * compiled limit at offset 0. * + * Existing query-shape and reentrancy guards run before limit validation. + * * @throws `SetWindowRequiresOrderByError` if the query is neither windowed * nor a `findOne`, or an unordered `findOne` would move its offset. * @throws `SetWindowFindOneLimitError` if a `findOne` would change its limit. + * @throws `SetWindowReentrancyError` during synchronous graph or source work. + * @throws RangeError if the supplied limit is negative and those guards pass. * @returns `true` if no subset loading was triggered, or `Promise` that resolves when the subset has been loaded */ setWindow: (options: WindowOptions) => true | Promise @@ -391,6 +396,8 @@ export class CollectionConfigBuilder< throw new SetWindowReentrancyError() } + validateLimit(options.limit) + // Keep caller-owned objects out of the long-lived query state. A caller may // reuse and mutate its options object after this operation settles. const baseWindow = @@ -461,7 +468,6 @@ export class CollectionConfigBuilder< } } } - const previousOperation = this.activeWindowOperation const operation: { generation: number failed: boolean @@ -488,7 +494,7 @@ export class CollectionConfigBuilder< loadOperation?.cancel() throw error } finally { - this.activeWindowOperation = previousOperation + this.activeWindowOperation = undefined } const settlement = loadOperation?.wait() ?? true diff --git a/packages/db/src/query/validate-limit.ts b/packages/db/src/query/validate-limit.ts new file mode 100644 index 0000000000..fc106f2378 --- /dev/null +++ b/packages/db/src/query/validate-limit.ts @@ -0,0 +1,12 @@ +import { codedMessage, devBuild } from '../error-message.js' + +/** Reject an invalid width before query construction or window movement. */ +export function validateLimit(limit = 0): void { + if (limit < 0) { + throw new RangeError( + devBuild() && process.env.NODE_ENV !== `production` + ? `limit must be nonnegative` + : codedMessage(261), + ) + } +} diff --git a/packages/db/tests/fixtures/error-site-messages.json b/packages/db/tests/fixtures/error-site-messages.json index c96b9866bf..7f9f22ec3b 100644 --- a/packages/db/tests/fixtures/error-site-messages.json +++ b/packages/db/tests/fixtures/error-site-messages.json @@ -986,5 +986,10 @@ "file": "query/live/collection-config-builder.ts", "template": "Source collection '${collectionId}' failed its first load", "literals": ["Source collection '", "' failed its first load"] + }, + "261": { + "file": "query/validate-limit.ts", + "template": "limit must be nonnegative", + "literals": ["limit must be nonnegative"] } } diff --git a/packages/db/tests/generate-error-docs.test.ts b/packages/db/tests/generate-error-docs.test.ts index b479b2d149..c852868637 100644 --- a/packages/db/tests/generate-error-docs.test.ts +++ b/packages/db/tests/generate-error-docs.test.ts @@ -1,7 +1,9 @@ // Ordinary CLI controls. The existing production-error oracle owns catalog // judgment; these examples check modes, file effects and malformed inputs. import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import { expect, it } from 'vitest' import { withErrorDocsFixture, @@ -14,6 +16,23 @@ function fulfilled(result: ReturnType): void { expect(result.status, result.stderr).toBe(0) } +// The message oracle checks freshly generated headings. Check the committed +// artifact too, so a new production code cannot ship without its linked page. +it(`keeps the committed error page current with its catalogs`, () => { + const result = spawnSync( + process.execPath, + [ + join( + dirname(fileURLToPath(import.meta.url)), + `../../../scripts/generate-error-docs.mjs`, + ), + `--check`, + ], + { encoding: `utf8` }, + ) + fulfilled(result) +}) + it(`emits exactly the default generated bytes without stdout from default mode`, () => { withErrorDocsFixture((fixture) => { writeErrorDocsExample(fixture) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 8c82724865..b7bf3950f1 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -5,6 +5,18 @@ export { recordOracleReplay } from './oracle-replay-witness.js' type OracleEnvironment = Record const staticOracleProperties = [ + `pagination.limit-admission-replay-pending-negative`, + `pagination.limit-admission-replay-pending-valid`, + `pagination.limit-admission-builder-negative`, + `pagination.limit-admission-builder-valid`, + `pagination.limit-admission-unstarted-negative`, + `pagination.limit-admission-unstarted-valid`, + `pagination.limit-admission-ready-negative`, + `pagination.limit-admission-ready-valid`, + `pagination.limit-admission-initial-pending-negative`, + `pagination.limit-admission-initial-pending-valid`, + `pagination.limit-admission-move-pending-negative`, + `pagination.limit-admission-move-pending-valid`, `framework.react.result-shape`, `framework.react-dependency-list.result-shape`, `framework.preact.result-shape`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 80e2795a11..0e2b61301f 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -13,6 +13,12 @@ import { registeredOracleProperties } from './oracle-config.js' const ownerGroups: ReadonlyArray< readonly [string, string, string] | readonly [string, string, string, string] > = [ + [ + `db/tests/query/pagination-oracle.property.test.ts`, + `pagination`, + `limit-admission-replay-pending-negative limit-admission-replay-pending-valid limit-admission-builder-negative limit-admission-builder-valid limit-admission-unstarted-negative limit-admission-unstarted-valid limit-admission-ready-negative limit-admission-ready-valid limit-admission-initial-pending-negative limit-admission-initial-pending-valid limit-admission-move-pending-negative limit-admission-move-pending-valid`, + `db/tests/query/limit-admission-oracle.ts`, + ], ...[ [`react`, `react-db/tests/conformance.test.tsx`], [`react-dependency-list`, `react-db/tests/conformance.test.tsx`], @@ -971,6 +977,12 @@ const replayTestNames = new Map([ const seedReplayProperties = new Set([`evaluators.like`]) function directReplayTestName(property: string): string | undefined { + if (property.startsWith(`pagination.limit-admission-`)) { + const suffix = property.slice(`pagination.limit-admission-`.length) + const negative = suffix.endsWith(`-negative`) + const path = suffix.slice(0, -(negative ? `-negative` : `-valid`).length) + return `${path} ${negative ? `rejects negatives` : `admits valid neighbors`} \\(random\\)` + } if (property.startsWith(`framework.`)) { switch (property.split(`.`).at(-1)) { case `result-shape`: diff --git a/packages/db/tests/oracle-replay.test.ts b/packages/db/tests/oracle-replay.test.ts index 1f6ed9f6fb..d8a67366e9 100644 --- a/packages/db/tests/oracle-replay.test.ts +++ b/packages/db/tests/oracle-replay.test.ts @@ -139,6 +139,23 @@ describe(`guarded oracle replay`, () => { `tests/query/pagination-oracle.property.test.ts`, 1, ], + ...[ + `builder`, + `unstarted`, + `ready`, + `initial-pending`, + `move-pending`, + `replay-pending`, + ].flatMap((path) => + [`negative`, `valid`].map( + (admission) => + [ + `pagination.limit-admission-${path}-${admission}`, + `tests/query/pagination-oracle.property.test.ts`, + 1, + ] as const, + ), + ), ] as const)( `selects only the requested %s replay lane`, (property, file, expectedTests) => { @@ -505,6 +522,27 @@ describe(`named oracle replay manifest`, () => { // These oracles number their properties in file order. const [family] = entry.property.split(`.`) expect(source).toContain(`\`${family}.\${id}\``) + } else if (entry.property.startsWith(`pagination.limit-admission-`)) { + const suffix = entry.property.slice( + `pagination.limit-admission-`.length, + ) + const separator = suffix.lastIndexOf(`-`) + const path = suffix.slice(0, separator) + const admission = suffix.slice(separator + 1) + expect([ + `builder`, + `unstarted`, + `ready`, + `initial-pending`, + `move-pending`, + `replay-pending`, + ]).toContain(path) + expect([`negative`, `valid`]).toContain(admission) + expect(source).toContain(`\`${path}\``) + expect(source).toContain(`for (const negative of [false, true])`) + expect(source).toContain( + 'pagination.limit-admission-${path}-${negative ? `negative` : `valid`}', + ) } else if (entry.property.startsWith(`pagination.matrix.`)) { const [, , familyCell] = entry.property.split(`.`) const [family, cell] = familyCell!.split(`-`) diff --git a/packages/db/tests/query/limit-admission-oracle.ts b/packages/db/tests/query/limit-admission-oracle.ts new file mode 100644 index 0000000000..292400cea3 --- /dev/null +++ b/packages/db/tests/query/limit-admission-oracle.ts @@ -0,0 +1,455 @@ +/** + * A negative limit throws RangeError synchronously, before query construction or a + * window operation changes anything. Authority: maintainer decision 2026-11-02, + * following the negative-width counterexample recorded in oracle-coverage.md. + * Zero (including -0), positive integer limits and Infinity remain admitted. + * Negative offsets keep their separate absolute-rank contract. + * + * This companion extends the pagination owner with an admission judgment: + * rejection is the identity transformation on public observations. It does not + * model top-K internals or give negative widths a result-set interpretation. + * The controlled on-demand source installs its whole finite source up front; + * held promises control acquisition settlement, not row delivery. These checks + * establish core admission, not provider pagination or framework error timing. + * NaN and positive fractions are outside this decision. + * A rejected call also preserves an admitted operation's eventual outcome. + * Held moves settle both successfully and with a source failure; failed moves + * keep the previous window and rows (ARCHITECTURE.md, Atomic window publication). + */ +import { fc } from '@fast-check/vitest' +import { describe, expect, it } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { Query, getQueryIR } from '../../src/query/builder/index.js' +import { createLiveQueryCollection } from '../../src/query/live-query-collection.js' +import { createDeferred } from '../../src/deferred.js' +import { declareOracle } from '../oracle-declaration.js' +import { oraclePropertyOptions, oracleReplayTest } from '../oracle-config.js' +import { createOracleReach } from '../oracle-reach.js' +import { + atOracleCheckpoint, + createOracleFailureFidelity, +} from '../oracle-failure-fidelity.js' +import { withHistoryCleanup } from '../optimistic-history-oracle.js' +import { flushPromises } from '../utils.js' +import type { LoadSubsetOptions, SyncConfig } from '../../src/types.js' + +type Row = { id: number; rank: number } +type Window = { offset: number; limit: number } +type Path = + | `builder` + | `unstarted` + | `ready` + | `initial-pending` + | `move-pending` + | `replay-pending` +type History = { + ranks: Array + limit: number + offset: number + negative: number +} + +// Each history reconstructs the reported -2 example and threshold neighbors. +// Removing fractions, tiny negatives or infinity admits integer-only guards; +// removing zero admits a <= 0 guard. The generated magnitude explores beyond +// those boundary witnesses. No NaN or positive fractional policy is inferred. +const historyArbitrary: fc.Arbitrary = fc.record({ + ranks: fc.array(fc.integer({ min: -5, max: 5 }), { + minLength: 12, + maxLength: 12, + }), + limit: fc.integer({ min: 1, max: 3 }), + offset: fc.integer({ min: 0, max: 2 }), + negative: fc.integer({ min: 1, max: 1_000_000 }).map((value) => -value), +}) +const example: History = { + ranks: Array.from({ length: 12 }, (_, index) => index), + limit: 2, + offset: 0, + negative: -2, +} + +// The sign law predicts admission. Rejected inputs have no state transition; +// accepted windows independently sort and select absolute, nonnegative ranks. +function admitted(limit: number): boolean { + return limit >= 0 +} +function selected(rows: ReadonlyArray, window: Window): Array { + return [...rows] + .sort((a, b) => a.rank - b.rank || a.id - b.id) + .filter( + (_, rank) => rank >= window.offset && rank < window.offset + window.limit, + ) +} +function checkAdmission( + limit: number, + outcome: ReturnType, +): void { + expect(outcome.threw, `admission of limit ${limit}`).toBe(!admitted(limit)) + // Both public entry points document RangeError. A wrong thrown value must + // not satisfy rejection merely because it preserves the other observations. + if (outcome.threw) expect(outcome.error).toBeInstanceOf(RangeError) +} + +function attempt( + action: () => T, +): { threw: false; result: T } | { threw: true; error: unknown } { + try { + const result = action() + // A wrongly accepted async move must not create an unhandled rejection. + if (result instanceof Promise) void result.catch(() => {}) + return { threw: false, result } + } catch (error) { + return { threw: true, error } + } +} + +// Record fulfillment and rejection before releasing controlled acquisitions. +// The completion promise lets the driver wait without prescribing a task cut. +function settlement(operation: true | Promise) { + const observation: { + value: `pending` | `fulfilled` | `rejected` + error?: unknown + } = { value: operation === true ? `fulfilled` : `pending` } + const done = + operation === true + ? Promise.resolve() + : operation.then( + () => { + observation.value = `fulfilled` + }, + (error) => { + observation.value = `rejected` + observation.error = error + }, + ) + return { observation, done } +} + +// One second is a diagnostic budget for this controlled fixture, not a product +// deadline. On expiry the named comparison reports pending and retains its trace; +// a task-scheduled implementation may complete at any point within that budget. +async function waitForSettlement( + outcome: ReturnType, +): Promise { + const deadline = createDeferred() + const timer = setTimeout(() => deadline.resolve(), 1000) + try { + await Promise.race([outcome.done, deadline.promise]) + } finally { + clearTimeout(timer) + } +} + +function checkSettlement( + { observation }: ReturnType, + error?: Error, +): void { + expect(observation).toEqual( + error ? { value: `rejected`, error } : { value: `fulfilled` }, + ) + if (error) expect(observation.error).toBe(error) +} + +async function runHistory( + history: History, + path: Path, + negative: boolean, + reached: (state: string) => void, + candidate?: number, + rejectHeldMove = false, +): Promise { + let rows = history.ranks.map((rank, index) => ({ id: index + 1, rank })) + const initialWindow = { offset: history.offset, limit: history.limit } + const values = negative + ? [-2, -0.5, -Number.MIN_VALUE, -Infinity, history.negative] + : [candidate!] + const events: Array<{ type: string; options: LoadSubsetOptions }> = [] + const pending: Array>> = [] + let hold = path === `initial-pending` + let sync!: Parameters[`sync`]>[0] + const source = createCollection({ + getKey: (row) => row.id, + syncMode: `on-demand`, + autoIndex: `off`, + startSync: true, + sync: { + sync: (operations) => { + sync = operations + operations.begin() + for (const row of rows) + operations.write({ type: `insert`, value: { ...row } }) + operations.commit() + operations.markReady() + return { + loadSubset: (options) => { + events.push({ type: `load`, options }) + options.signal?.addEventListener( + `abort`, + () => events.push({ type: `abort`, options }), + { once: true }, + ) + if (!hold) return true + const deferred = createDeferred() + pending.push(deferred) + return deferred.promise + }, + unloadSubset: (options) => { + events.push({ type: `unload`, options }) + }, + } + }, + }, + }) + const builder = new Query() + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .orderBy(({ row }) => row.id) + const live = createLiveQueryCollection({ + query: builder.limit(initialWindow.limit).offset(initialWindow.offset), + }) + let unsubscribe = () => {} + const release = async (error?: Error) => { + hold = false + for (const deferred of pending) + if (error) deferred.reject(error) + else deferred.resolve() + await flushPromises() + } + + await withHistoryCleanup( + async () => { + if (path === `builder`) { + const before = { ...getQueryIR(builder) } + for (const value of values) { + const outcome = attempt(() => builder.limit(value)) + reached(value < 0 ? `negative` : `valid`) + atOracleCheckpoint(`limit admission`, `builder return`, () => { + checkAdmission(value, outcome) + expect(getQueryIR(builder)).toEqual(before) + if (!outcome.threw) + expect(getQueryIR(outcome.result).limit).toBe(value) + }) + } + return + } + + const publications: Array> = [] + let operation: true | Promise = true + let operationOutcome = settlement(operation) + let expectedWindow = initialWindow + if (path !== `unstarted`) { + const subscription = live.subscribeChanges(() => { + publications.push( + Array.from(live.values(), ({ id, rank }) => ({ id, rank })), + ) + }) + unsubscribe = () => subscription.unsubscribe() + operation = live.preload() + if (path !== `initial-pending`) await operation + if (path === `move-pending` || path === `replay-pending`) { + hold = true + if (path === `replay-pending`) { + sync.begin() + sync.truncate() + for (const row of rows) + sync.write({ type: `insert`, value: { ...row } }) + sync.commit() + await flushPromises() + } + expectedWindow = { offset: 6, limit: history.limit + 1 } + operation = live.utils.setWindow(expectedWindow) + } + operationOutcome = settlement(operation) + await flushPromises() + if (hold) { + expect(pending.length, `held acquisition premise`).toBeGreaterThan(0) + expect(operation).toBeInstanceOf(Promise) + expect(operationOutcome.observation.value).toBe(`pending`) + reached(`held`) + } + } + const observe = () => ({ + rows: Array.from(live.values(), ({ id, rank }) => ({ id, rank })), + window: live.utils.getWindow(), + status: live.status, + error: live.utils.lastSubsetError, + publications: publications.map((batch) => [...batch]), + events: [...events], + operationOutcome: operationOutcome.observation.value, + }) + for (const value of values) { + const requests = negative + ? [{ limit: value }, { offset: 5, limit: value }] + : [{ offset: value === history.limit ? -1 : 5, limit: value }] + for (const options of requests) { + const before = observe() + // Omitted offsets and combined moves must both reject atomically. + const outcome = attempt(() => live.utils.setWindow(options)) + reached(value < 0 ? `negative` : `valid`) + atOracleCheckpoint(`limit admission`, `${path} return`, () => { + checkAdmission(value, outcome) + if (!admitted(value)) expect(observe()).toEqual(before) + }) + if (admitted(value)) + expectedWindow = { offset: options.offset!, limit: value } + await flushPromises() + atOracleCheckpoint( + `rejection preserves window`, + `${path} next turn`, + () => { + if (!admitted(value)) expect(observe()).toEqual(before) + }, + ) + if (admitted(value) && !outcome.threw) { + const candidateOutcome = settlement(outcome.result) + await release() + const preloadOutcome = settlement(live.preload()) + await waitForSettlement(candidateOutcome) + await waitForSettlement(preloadOutcome) + atOracleCheckpoint( + `accepted window`, + `${path} candidate settled`, + () => { + checkSettlement(candidateOutcome) + checkSettlement(preloadOutcome) + expect(live.utils.getWindow()).toEqual(expectedWindow) + expect(observe().rows).toEqual(selected(rows, expectedWindow)) + }, + ) + } + } + } + const loadError = rejectHeldMove + ? new Error(`held move failed`) + : undefined + await release(loadError) + if (rejectHeldMove) expectedWindow = initialWindow + if (path === `unstarted`) { + operationOutcome = settlement(live.preload()) + } + await waitForSettlement(operationOutcome) + atOracleCheckpoint( + `rejection preserves window`, + `${path} settled`, + () => { + checkSettlement(operationOutcome, loadError) + expect(live.utils.getWindow()).toEqual(expectedWindow) + expect(observe().rows).toEqual(selected(rows, expectedWindow)) + if (loadError) expect(live.utils.lastSubsetError).toBe(loadError) + }, + ) + if (path.endsWith(`pending`)) + reached(rejectHeldMove ? `failed-settlement` : `successful-settlement`) + // A partial move detects an invisible mutation of requested bounds; then + // a source change checks that rejection left the graph usable. + expectedWindow = { offset: 1, limit: expectedWindow.limit } + await live.utils.setWindow({ offset: 1 }) + rows = rows.map((row) => (row.id === 1 ? { ...row, rank: -20 } : row)) + sync.begin() + sync.write({ type: `update`, value: { ...rows[0]! } }) + sync.commit() + await flushPromises() + atOracleCheckpoint(`valid next use`, `${path} source update`, () => { + expect(live.utils.getWindow()).toEqual(expectedWindow) + expect(observe().rows).toEqual(selected(rows, expectedWindow)) + }) + reached(`next-use`) + }, + () => [release, unsubscribe, () => live.cleanup(), () => source.cleanup()], + ) +} + +declareOracle({ + check: [checkAdmission, checkSettlement], + model: [admitted, selected], + grammar: [historyArbitrary], +}) + +export function registerLimitAdmissionOracle(): void { + describe(`negative limit admission`, () => { + for (const path of [ + `builder`, + `unstarted`, + `ready`, + `initial-pending`, + `move-pending`, + `replay-pending`, + ] as const) { + for (const negative of [false, true]) { + const property = `pagination.limit-admission-${path}-${negative ? `negative` : `valid`}` + for (const campaign of [`fixed`, `random`] as const) { + const registration = + campaign === `fixed` + ? oracleReplayTest(it) + : oracleReplayTest(it, property) + registration( + `${path} ${negative ? `rejects negatives` : `admits valid neighbors`} (${campaign})`, + async () => { + const states = [ + negative ? `negative` : `valid`, + ...(path === `builder` ? [] : [`next-use`]), + ...(path.endsWith(`pending`) ? [`held`] : []), + ...(path.endsWith(`pending`) ? [`successful-settlement`] : []), + ...(negative && path === `move-pending` + ? [`failed-settlement`] + : []), + ] + const reach = createOracleReach(property, states, { + fixedHistories: 1, + }) + const fidelity = createOracleFailureFidelity((history) => + JSON.stringify(history), + ) + let count = 0 + await fidelity.assert(() => + fc.assert( + fc.asyncProperty(historyArbitrary, (history) => { + reach.beginHistory({ fixed: count++ === 0 }) + return fidelity.run(history, async () => { + // Each accepted value gets its own held premise. Settling + // zero must not move the remaining candidates to readiness. + for (const candidate of negative + ? [undefined] + : [0, -0, history.limit, Infinity]) { + // Each generated payload crosses both legal outcomes + // of an admitted move. Rejection must preserve either. + for (const rejectHeldMove of negative && + path === `move-pending` + ? [false, true] + : [false]) + await runHistory( + history, + path, + negative, + (state) => reach.reach(state), + candidate, + rejectHeldMove, + ) + } + }) + }), + { + ...oraclePropertyOptions(20, property, { + examples: [[example]], + }), + ...(campaign === `fixed` ? { seed: 2291 } : {}), + }, + ), + ) + reach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) { + process.stderr.write( + `${property} ${campaign} ${JSON.stringify(Object.fromEntries(reach.counts()))}\n`, + ) + } + }, + // Shrinking a stalled operation can spend the diagnostic budget + // on several candidates before reporting its named comparison. + 30_000, + ) + } + } + } + }) +} diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index ce2a0ddaec..77b8d468d6 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -17,6 +17,7 @@ import { TraceAssertionError } from '../trace-runner.js' import { flushPromises, mockSyncCollectionOptions } from '../utils.js' import { withHistoryCleanup } from '../optimistic-history-oracle.js' import { cleanupAll } from './pagination-support.js' +import { registerLimitAdmissionOracle } from './limit-admission-oracle.js' import type { PageRow } from './pagination-support.js' import type { ChangeMessage, @@ -2844,6 +2845,8 @@ async function expectInflightRequestFillsNewWindow(): Promise { } } +registerLimitAdmissionOracle() + describe(`pagination recomputation oracle`, () => { it(`refills a joined result window through a contract-compliant source`, async () => { type ParentRow = { id: number; rank: number; groupId: number }