From a6889c115828fcb41af0ff54d1a0eee4a5013c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Thu, 1 Oct 2026 16:10:21 +0200 Subject: [PATCH 01/28] design: slice S0 settles the harness decisions and writes the probe examples Four decisions on paper before the first code, all on the acceptance contract. - E-13 changes its option. A caller under test is an ordinary client carrying a token signed by a fixture issuer; auth.config.ts reads the issuer and a data-URI key set from environment variables. The admin key acting as an identity is dropped: it passes Convex's visibility check for internal functions. - E-15 is new: the native harness. Two compositions, faults only through parts the fixture composition supplies, assertions only on what a client or admin access can read, four controls and no clock or scheduler switch. Sc L2-9's production-configuration run is read as the scenarios routed to the production composition. - An 'or' in a scenario's text enumerates cases; a bullet never stands in for one (PLAN section 4, acceptance contract verification). - Seventeen example Specs: four verify the acceptance contract, thirteen are probes 1 to 5 and verify F4, F5, F13, F14 and F15. The Protocol's root moves to the repository root so that test anchors outside design/ bind to the Specs. check.py follows, and its --slice listing no longer runs validate. Ledger: four S0 findings ruled, one fixed and three partially fixed with what each later slice still owes. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- design/.gitignore | 1 - design/PLAN.md | 16 ++++----- design/README.md | 25 +++++++------- design/reviews/consensus-ledger.json | 34 +++++++++++-------- ...acceptance-production-configuration.sdp.md | 4 +-- .../specs/application/first-experiment.sdp.md | 4 +-- ...rity.client-claims-system-namespace.sdp.md | 2 +- .../command/tenancy-and-authority.sdp.md | 2 +- ...robe-3-component-call-from-mutation.sdp.md | 29 ++++++++++++++++ ...s.probe-3-component-call-from-query.sdp.md | 28 +++++++++++++++ ...ckend-restart-with-pending-mutation.sdp.md | 28 +++++++++++++++ ...client-closed-with-pending-mutation.sdp.md | 27 +++++++++++++++ ...confirmed.probe-1-http-client-retry.sdp.md | 27 +++++++++++++++ ...robe-4-component-call-shares-limits.sdp.md | 29 ++++++++++++++++ ...s.probe-4-nested-call-shares-limits.sdp.md | 29 ++++++++++++++++ ...s-have-limits.probe-4-nesting-depth.sdp.md | 27 +++++++++++++++ ...boundary.probe-2-component-boundary.sdp.md | 30 ++++++++++++++++ ...nt-boundary.probe-2-nested-mutation.sdp.md | 30 ++++++++++++++++ ...ctive.probe-5-pages-stay-contiguous.sdp.md | 29 ++++++++++++++++ ...tive.probe-5-split-required-relayed.sdp.md | 28 +++++++++++++++ ...active.probe-5-subscription-updates.sdp.md | 27 +++++++++++++++ design/specs/foundation.pack.sdp.md | 19 ++++++++++- ...-identity-reaches-internal-function.sdp.md | 29 ++++++++++++++++ ...ixture-issuer-token-yields-identity.sdp.md | 29 ++++++++++++++++ ...y-client-refused-component-function.sdp.md | 29 ++++++++++++++++ ...ry-client-refused-internal-function.sdp.md | 29 ++++++++++++++++ .../specs/platform/acceptance-contract.sdp.md | 22 ++++++++---- design/tools/check.py | 28 +++++++++------ 29 files changed, 581 insertions(+), 62 deletions(-) delete mode 100644 design/.gitignore create mode 100644 design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.sdp.md create mode 100644 design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md create mode 100644 design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.sdp.md create mode 100644 design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.sdp.md create mode 100644 design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.sdp.md create mode 100644 design/specs/facts/f13-transactions-have-limits.probe-4-component-call-shares-limits.sdp.md create mode 100644 design/specs/facts/f13-transactions-have-limits.probe-4-nested-call-shares-limits.sdp.md create mode 100644 design/specs/facts/f13-transactions-have-limits.probe-4-nesting-depth.sdp.md create mode 100644 design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.sdp.md create mode 100644 design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.sdp.md create mode 100644 design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.sdp.md create mode 100644 design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md create mode 100644 design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.sdp.md create mode 100644 design/specs/platform/acceptance-contract.admin-key-acting-as-identity-reaches-internal-function.sdp.md create mode 100644 design/specs/platform/acceptance-contract.fixture-issuer-token-yields-identity.sdp.md create mode 100644 design/specs/platform/acceptance-contract.ordinary-client-refused-component-function.sdp.md create mode 100644 design/specs/platform/acceptance-contract.ordinary-client-refused-internal-function.sdp.md diff --git a/AGENTS.md b/AGENTS.md index 0f5219e..e4bee28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ The Convex transactional domain platform. `docs/` holds the inputs. `design/` ho - `docs/convex-transactional-domain-platform-decisions.md`, "the doc", says what the platform is. Its decisions are proposals until the owner rules. Only the owner states `ready` on a Spec, settles an open question or edits the doc. - A Spec is intended truth. Report code that disagrees with a Spec as a gap, and the main thread rules which side changes. -- Read the corpus through its graph. Run `npm ci` once, then `npx sdp q '' --root design --json` with a body from `node_modules/@libar-dev/software-delivery-protocol/docs/agent-surface/recipes.md`. Always `npx sdp`: a bare `sdp` on macOS is an unrelated tool. +- Read the corpus through its graph. Run `npm ci` once, then, from the repository root, `npx sdp q '' --json` with a body from `node_modules/@libar-dev/software-delivery-protocol/docs/agent-surface/recipes.md`. Always `npx sdp`: a bare `sdp` on macOS is an unrelated tool. - Before you write or edit a Spec, read `design/PLAN.md` section 6 and `node_modules/@libar-dev/software-delivery-protocol/.agents/skills/sdp-authoring/SKILL.md`. - `python3 design/tools/check.py` prints `OK` when the Specs, the README and the ledger agree. Run it before you say they do. - Name the tier behind a claim about behavior: compiled, pure test, `convex-test`, or native backend. diff --git a/design/.gitignore b/design/.gitignore deleted file mode 100644 index 9ab870d..0000000 --- a/design/.gitignore +++ /dev/null @@ -1 +0,0 @@ -generated/ diff --git a/design/PLAN.md b/design/PLAN.md index 2c73547..9c75f1f 100644 --- a/design/PLAN.md +++ b/design/PLAN.md @@ -10,17 +10,17 @@ The CLI is the `sdp` binary of the pinned `@libar-dev/software-delivery-protocol ```sh # from the repository root -npx sdp validate design +npx sdp validate ``` -Both forms were run against the scaffold in this directory on 2026-09-30 and printed `validate: 0 errors · 0 warnings`. No `--exclude` is needed; `design/` carries no fixtures. Output goes to `design/generated/` (graph, contracts, registrar manifest), which `design/.gitignore` ignores. +The Protocol's root is the repository root since slice S0, so that one graph holds the Specs under `design/specs/` and the test anchors that verify them, which live outside `design/`. No `--exclude` is needed. Output goes to `generated/` at the repository root (graph, contracts, registrar manifest), which `.gitignore` ignores. A test imports its example's step contract from `generated/contracts/`, so `npx sdp build` runs before the tests. Other verbs, same path and root rules: -- `npx sdp validate design --watch` is the authoring loop; it re-runs on carrier changes and stays alive. -- `npx sdp view design` writes the Design Review to `design/generated/design-review/` (an index plus one page per Spec and Pack). This is the human-readable rendering of the corpus. -- `npx sdp q '' --root design --json` evaluates a recipe body from the package's `docs/agent-surface/recipes.md` against this corpus. Recipes 3 (one Spec's guarantees), 5 (Pack review), 6 (concept search), 7 (readiness divergence), 8 (warnings as data), 9 (promotion preflight) and 11 (the lower ladder) are the useful ones here. -- `npx sdp new spec specs//.sdp.md --id ID --kind KIND --altitude ALT --title TITLE --outcome OUTCOME` scaffolds an idea-rung stub. PATH is relative to the current directory, so run it from `design/`. +- `npx sdp validate --watch` is the authoring loop; it re-runs on carrier changes and stays alive. +- `npx sdp view` writes the Design Review to `generated/design-review/` (an index plus one page per Spec and Pack). This is the human-readable rendering of the corpus. +- `npx sdp q '' --json` evaluates a recipe body from the package's `docs/agent-surface/recipes.md` against this corpus. Recipes 3 (one Spec's guarantees), 5 (Pack review), 6 (concept search), 7 (readiness divergence), 8 (warnings as data), 9 (promotion preflight) and 11 (the lower ladder) are the useful ones here. +- `npx sdp new spec design/specs//.sdp.md --id ID --kind KIND --altitude ALT --title TITLE --outcome OUTCOME` scaffolds an idea-rung stub. PATH is relative to the current directory, so from the repository root it starts with `design/specs/`. Exit code 1 means an error; warnings exit 0. The corpus must exit 0 with zero errors and zero warnings. @@ -273,7 +273,7 @@ Fact slugs: `f01-serializable-mutations-under-occ`, `f02-component-calls-commit- ## 4. Scenarios to examples -Every row of the doc's acceptance table becomes at least one `example` Spec. When a row enumerates cases (three injection points, three order sizes, two broken subsystems), each case is one sibling example binding a different point, per SDP's point-per-example law. Titles are the doc's scenario text. The tier comes from the acceptance contract: domain (pure), simulator, native, or end to end. +Every row of the doc's acceptance table becomes at least one `example` Spec. When a row enumerates cases (three injection points, three order sizes, two broken subsystems), each case is one sibling example binding a different point, per SDP's point-per-example law. An "or" in the scenario's text enumerates cases the same way (a worker or agent namespace, rate or capacity refusal). An "or" in the pass condition does not: the example binds the alternative its design produces. A verification bullet adds assertions to a case and never stands in for one. Titles are the doc's scenario text. The tier comes from the acceptance contract: domain (pure), simulator, native, or end to end. | Sc | Scenario (doc text) | Parent Spec | Example ID | Tier | Pkg | |---|---|---|---|---|---| @@ -329,7 +329,7 @@ Five packages, disjoint by directory. Sequence: A first, because every other pac Definition of done, every package: -1. `npx sdp validate design` exits 0 on the merged corpus. While a foreign ID from the package's cross-package list is not yet authored, the only tolerated error is `conformance/referential-integrity` naming that ID. +1. `npx sdp validate` exits 0 on the merged corpus. While a foreign ID from the package's cross-package list is not yet authored, the only tolerated error is `conformance/referential-integrity` naming that ID. 2. No warning. An example with no test bound is data below `ready`, not a warning. 3. Every Spec in the package's inventory exists with the stated kind, altitude, readiness and minimum relations; every scenario assigned to the package has its example(s). 4. The pack manifest lists every Spec of the package in inventory order. diff --git a/design/README.md b/design/README.md index cc2e354..bd4e0a6 100644 --- a/design/README.md +++ b/design/README.md @@ -14,20 +14,20 @@ Run the review and open its index: ```sh npm ci # once, from the repository root: installs the pinned Protocol CLI -npx sdp view design -open design/generated/design-review/index.md +npx sdp view +open generated/design-review/index.md ``` Always `npx sdp` or an `npm run` script. A bare `sdp` on macOS resolves to an unrelated Xcode tool. The Protocol version is the one `package.json` pins; `python3 design/tools/check.py` prints it. -The index lists every Spec with its kind, altitude, stated readiness and the floor its structure reaches. Each Spec has one page under `generated/design-review/spec/`, each pack under `generated/design-review/pack/`. The directory is gitignored; regenerate it after any edit. +The index lists every Spec with its kind, altitude, stated readiness and the floor its structure reaches. Each Spec has one page under `generated/design-review/spec/`, each pack under `generated/design-review/pack/`. The Protocol's root is the repository root, because the graph binds the Specs under `design/specs/` to the tests that verify them, which live outside `design/`. `generated/` at the repository root is gitignored; regenerate it after any edit. A reading order that works. Start with the epic ([`transactional-domain-platform`](specs/platform/transactional-domain-platform.sdp.md)) and three of its five platform children ([`layers-and-profiles`](specs/platform/layers-and-profiles.sdp.md), [`decision-method`](specs/platform/decision-method.sdp.md), [`vocabulary`](specs/platform/vocabulary.sdp.md)); the other two, [`acceptance-contract`](specs/platform/acceptance-contract.sdp.md) and [`existing-systems`](specs/platform/existing-systems.sdp.md), are best read before the scenario table below. Then the twelve laws in [`specs/laws/`](specs/laws/) and decisions D1 to D12 in [`specs/decisions/`](specs/decisions/). Then go up the layers: the kernel ([`outcome-model`](specs/kernel/outcome-model.sdp.md) first, then [`decider-contract`](specs/kernel/decider-contract.sdp.md) and [`domain-kernel`](specs/kernel/domain-kernel.sdp.md)), the context component and its children in [`specs/context/`](specs/context/), the command pipeline in [`specs/command/`](specs/command/) (read [`actor-and-scope`](specs/command/actor-and-scope.sdp.md) before [`command-pipeline`](specs/command/command-pipeline.sdp.md)), and the composed application in [`specs/application/`](specs/application/). Read the fact ledger and the probe plan in [`specs/facts/`](specs/facts/) before the Layer 3 material, because the obligation module's justification is a probe that has not run. Layers 4 to 6 are trigger, promise and rules only. -Questions the graph answers better than the files. Pass a recipe body from the Protocol's catalog to `sdp q` with this corpus as the root. The catalog ships in the package, at `node_modules/@libar-dev/software-delivery-protocol/docs/agent-surface/recipes.md`: +Questions the graph answers better than the files. Pass a recipe body from the Protocol's catalog to `sdp q`, run from the repository root. The catalog ships in the package, at `node_modules/@libar-dev/software-delivery-protocol/docs/agent-surface/recipes.md`: ```sh -npx sdp q '' --root design --json +npx sdp q '' --json ``` Recipe 3 gives one Spec's sections, relations and verifiers. Recipe 5 reviews a pack. Recipe 6 finds a concept by phrase, so `D7` or `staleVersion` lands on every Spec that carries it. Recipe 7 lists Specs whose stated readiness their structure does not earn; it returns `[]` on this corpus. Recipe 8 returns the warnings as data. Recipe 9 is the promotion preflight for one Spec. Recipe 11 lists every non-ready Spec by family with its floor. Recipe 20 is the open-question register, blocking questions first. Recipe 21 lists what one Spec rests on, with the rung each target states. Recipe 22 audits Spec ids written in prose against declared relations. Recipe 23 searches keyed Design entries. @@ -36,7 +36,7 @@ Recipe 3 gives one Spec's sections, relations and verifiers. Recipe 5 reviews a ```sh # from the repository root -npx sdp validate design +npx sdp validate # the session check: validate, readiness divergence, and this project's own policy python3 design/tools/check.py @@ -45,11 +45,11 @@ python3 design/tools/check.py Expected result on the integrated corpus: ``` -168 specs · 5 packs · 0 anchors → 173 nodes · 881 edges (0 errors, 0 warnings) +185 specs · 5 packs · 4 anchors → 194 nodes · 936 edges (0 errors, 0 warnings) validate: 0 errors · 0 warnings (conformance + honesty over the one graph) ``` -Any warning and any error is a defect. Every example declares `verifies` toward its parent for fidelity to the acceptance contract, and no test anchor binds it yet. The Protocol reads an unbound example below `ready` as data and does not warn; recipe 10 lists each one as a declared verifier that is not enabled. `npx sdp validate design --watch` is the authoring loop. +Any warning and any error is a defect. Every example declares `verifies` toward its parent for fidelity to the acceptance contract, and no test anchor binds it yet. The Protocol reads an unbound example below `ready` as data and does not warn; recipe 10 lists each one as a declared verifier that is not enabled. `npx sdp validate --watch` is the authoring loop. ## The component map @@ -91,7 +91,7 @@ Detail follows the build (decision method, rule 4). Full means interfaces, TypeS | 5 | `agents` | [`durable-and-later`](specs/durable-and-later.pack.sdp.md) | 7 | | 6 | `advanced` | [`durable-and-later`](specs/durable-and-later.pack.sdp.md) | 8 | -Counts by kind and by stated readiness are derived, not kept here: `npx sdp census design` writes the census, and the session check prints the readiness tally. Three groups state `scoped`: every Layer 4 to 6 non-example Spec, held by its deferral, and the three assumed facts F14, F15 and F17, each held by its probe. Nothing states `ready`. Every pack carries `modelRefs: [spec:platform.vocabulary]`; the application pack also refs the example domain's model. +Counts by kind and by stated readiness are derived, not kept here: `npx sdp census` writes the census, and the session check prints the readiness tally. Three groups state `scoped`: every Layer 4 to 6 non-example Spec, held by its deferral, and the three assumed facts F14, F15 and F17, each held by its probe. Nothing states `ready`. Every pack carries `modelRefs: [spec:platform.vocabulary]`; the application pack also refs the example domain's model. What each layer fixes, in one paragraph each. @@ -101,7 +101,7 @@ Layer 1, context side. A bounded context is one Convex component with `streams` Layer 1, parent side. Every command runs through one top-level mutation with eleven steps: parse, authenticate, establish scope, authorize, look up the receipt, admit, read the maintenance gate and mint the whole `OperationRef`, execute the contexts through `ctx.runMutation` on their operations with the facts the executor captured, update read models from the `streams` the executor returned, insert the receipt and the audit record, emit a diagnostic and return. The only catch is an outermost rethrow that gives a context's bare `Rejection` and the pipeline's own rejections one wire shape. Each command has one declaration and two static exports: `publicCommand` (a `mutation`, namespace fixed to `public`, reads `ctx.auth`) and `internalCommand` (an `internalMutation` that trusted server code calls with `actor`, `namespace` and a required `requestKey`); the doubled function count and the single-export alternative it beat are recorded under E-7. Receipts are thin, keyed by four indexed fields, guarded by a SHA-256 fingerprint that excludes captured facts, read and inserted in the same transaction; a rejection or transient refusal writes none, and the second UI submit of a create is answered with `entityExists`, not an idempotent success (E-39). The declaration's `bounds` is the one input bound, checked at step 1, and its `writes` names the stream types the executor may write. The internal entry takes an optional `causedBy`, which is how a reaction's derived command names its publication event (E-31), and a derived command carries the `worker` namespace (E-6). The actor, tenant scope, caller namespace, the worker's `Authority` and the grants table are defined once in [`actor-and-scope`](specs/command/actor-and-scope.sdp.md) and reused by the envelope, the receipts table and the obligation record. -Layer 2. A use case calls each context once with a list, applies the cross-context rule to the DTOs it gets back, and returns the union of stream versions with every context call's `streams` entries; the pipeline's step 9 writes the read models the declaration lists from those entries through one `applyProjection` shared with rebuild, and a rejection or throw from the second context rolls back the first. Rebuild is a new numbered generation: live commands write it too, backfill batches of at most 100 entities, capped by the context's byte-derived page bound, write only where the target is missing or older, verification precedes a one-write switch, and the old generation stays for 7 days. Cross-stream history views rebuild under the write pause, one gate document the pipeline reads for every command, with the generation's fence for background writers. Such a view is a `HistoryProjection` that folds events into its row; its paused rebuild closes the view's source stream types, so every writer of those streams is held whether or not it maintains the view, and its backfill pages each source's history. Restore closes writers through `MAINTENANCE_MODE`, restores the snapshot, runs four checkpointed checks (journal, domain, read model, receipt), reopens only on zero findings, and only then hands over to the durable profile's schedule rebuild. Audit is written inside the transaction and fails closed; diagnostics go through a sink that never throws. The first experiment is Orders and Inventory with `PlaceOrder`, `CancelOrder`, the order summary and the order allocation history view that Sc L2-6 builds; its six cost targets are constraint Specs, and its native runs authenticate through the local backend's admin key acting as the production issuer's identity, the one named difference from production (E-13). +Layer 2. A use case calls each context once with a list, applies the cross-context rule to the DTOs it gets back, and returns the union of stream versions with every context call's `streams` entries; the pipeline's step 9 writes the read models the declaration lists from those entries through one `applyProjection` shared with rebuild, and a rejection or throw from the second context rolls back the first. Rebuild is a new numbered generation: live commands write it too, backfill batches of at most 100 entities, capped by the context's byte-derived page bound, write only where the target is missing or older, verification precedes a one-write switch, and the old generation stays for 7 days. Cross-stream history views rebuild under the write pause, one gate document the pipeline reads for every command, with the generation's fence for background writers. Such a view is a `HistoryProjection` that folds events into its row; its paused rebuild closes the view's source stream types, so every writer of those streams is held whether or not it maintains the view, and its backfill pages each source's history. Restore closes writers through `MAINTENANCE_MODE`, restores the snapshot, runs four checkpointed checks (journal, domain, read model, receipt), reopens only on zero findings, and only then hands over to the durable profile's schedule rebuild. Audit is written inside the transaction and fails closed; diagnostics go through a sink that never throws. The first experiment is Orders and Inventory with `PlaceOrder`, `CancelOrder`, the order summary and the order allocation history view that Sc L2-6 builds; its six cost targets are constraint Specs, and its native runs authenticate ordinary clients with tokens signed by a fixture issuer that the deployment's environment variables name, the one named difference from production (E-13). Layer 3. An obligation is one recorded promise that commits with the business change and whose first dispatch is scheduled in the same transaction. The module lives in the parent as a library plus the `obligations` and `obligationRepairs` tables. A command's static reactions are the `createObligation` calls of its executor body, not a field of its declaration, and the record stores the one `Authority` shape of the actor and scope contract. A local reaction is a scheduled wrapper that runs the body as a nested mutation, so the effect and its completion commit together or the wrapper records the attempt after the body failed; a body refused by the write pause or a capacity policy is deferred without counting an attempt. A cron sweeper reads `_scheduled_functions`, whose `state.kind` is a lowercase literal, to tell a queued dispatch from a dead one, rearms at most five times, and escalates. External effects claim in a mutation, call in an action that makes exactly one provider call, and settle in a second mutation under one of four repetition policies; a stale worker's late evidence is kept and reconciled. Retention never erases unresolved work; restore starts with `OBLIGATIONS_DISPATCH` off and rebuilds schedules from the obligations after the transactional restore has reopened writers. All of it is gated on Probe 7 through the [`do-nothing-check`](specs/obligations/do-nothing-check.sdp.md) decision, made once for the durable profile and not per reaction, and every numeric default in the layer is provisional until the first experiment, which each Layer 3 Spec records against decision method rule 4. @@ -194,8 +194,9 @@ An extension is a design claim the doc does not make. Each one is three things t | E-10 | [`obligations.lifecycle-transitions`](specs/obligations/lifecycle-transitions.sdp.md), [`obligations.record-contract`](specs/obligations/record-contract.sdp.md) | The allowed transitions between the six states and who causes each; the `obligations` and `obligationRepairs` tables with seven and two indexes; a 64 KiB inline payload bound; ten late-evidence entries; two attention reasons beyond D13's four, `recoveryFailing` and `chainBound` | | E-11 | [`effects.claim-call-settle`](specs/effects/claim-call-settle.sdp.md) | The signatures of `claimAttempt`, `readForCall`, `callProvider`, `reconcileAttempt`, `settleAttempt`, `expireLease`; the `RepetitionPolicy` union with a fourth value `none` | | E-12 | [`constraints.events-stay-small`](specs/constraints/events-stay-small.sdp.md) | A 16 KiB payload bound measured by the journal's `append`, so a fold of 600 events since the latest baseline stays near 10 MiB under the 16 MiB read ceiling; the reserved `baseline` event is exempt and bounded by its stream type's `budgetBytes`; the 1 second query timeout binds first and the first experiment measures it | -| E-13 | [`platform.acceptance-contract`](specs/platform/acceptance-contract.sdp.md) | The native tier's identity comes from the local backend's admin key acting as an identity with the production issuer's name and the test subject, the mechanism S13's guide uses; parity for Sc L2-9 is code path, schema, concurrency and the authority model, and the identity source is the one named difference on every evidence record | +| E-13 | [`platform.acceptance-contract`](specs/platform/acceptance-contract.sdp.md) | The native tier's callers are ordinary clients carrying tokens the harness signs for a fixture issuer; `auth.config.ts` reads the issuer and a data-URI key set from the deployment's environment variables; parity for Sc L2-9 is code path, schema, concurrency, the authority model and Convex's visibility check, and the issuer is the one named difference on every evidence record. The admin key acting as an identity was dropped on 2026-10-01: it passes the visibility check for internal functions | | E-14 | [`platform.acceptance-contract`](specs/platform/acceptance-contract.sdp.md) | A fifth tier, the build tier: a compile of the fixture app with the change applied that must fail at `tsc` or at the registration check, run before the native suite with no backend; Sc L2-4 is its one scenario | +| E-15 | [`platform.acceptance-contract`](specs/platform/acceptance-contract.sdp.md) | The native harness: the admin key is for setup and reading, never for a caller under test; two compositions, fixture and production; faults only through parts the fixture composition supplies; assertions only on what a client or admin access can read; four controls (the backend process, the clients, the environment variables, fixture functions) and no clock or scheduler switch; the production-configuration run of Sc L2-9 is the scenarios routed to the production composition | | E-20 | [`kernel.decider-contract`](specs/kernel/decider-contract.sdp.md), [`kernel.domain-kernel`](specs/kernel/domain-kernel.sdp.md) | The concrete generics, `DomainEvent`, `DecisionActor` as the kernel's structural view of the actor, `DecisionContext { now, actor, facts }`, `DecideResult`, `Invariant`, `Transitions` and `transition`; the kernel imports nothing from Convex and nothing from the command family | | E-21 | [`kernel.outcome-model`](specs/kernel/outcome-model.sdp.md) | `Outcome`, `Rejection`, `CommittedOutcome`, `AffectedRef`; a committed outcome carries at least one event; the reserved codes `staleVersion`, `operationTooLarge` and `entityExists`, the last raised by the adapter for a create planned at expected version 0 whose stream exists | | E-22 | [`context.context-component`](specs/context/context-component.sdp.md) | One registered mutation per sanctioned operation via `defineOperation` with args `{ tenantId, actor, operation, input, facts }`; `contextId` as a code constant equal to the mount name; 256 streams, 800 written documents and 8 MiB of stream documents read or written per call, derived from each stream type's byte budget; `convex-helpers` as the component's dependency; the query and maintenance surface | @@ -272,7 +273,7 @@ The seven probes are pending, not rulings. Each is recorded on the Specs that re Every open question in the corpus, by Spec, is recipe 20 of the Protocol's catalog, the open-question register. It lists the Specs that hold a blocking question first and reports the totals, so no table is kept here: ```sh -npx sdp q '' --root design --json +npx sdp q '' --json ``` ## ID changes diff --git a/design/reviews/consensus-ledger.json b/design/reviews/consensus-ledger.json index 907a9b3..69ecb48 100644 --- a/design/reviews/consensus-ledger.json +++ b/design/reviews/consensus-ledger.json @@ -2077,10 +2077,11 @@ ], "issue": "The native tier is 'a disposable local backend per test' and the contract says nothing about how a test controls time or interrupts work, yet the examples depend on both. Interruption: L2-5 'kills the backend's scheduler after the third batch', L2-8 'stops the scheduler after its first batch', L2-6 'interrupts after the second history batch', L1-3 has 'the harness drops the response'. A local backend offers no scheduler switch, and a restart leaves the pending batch to run by itself, which exercises none of `resumeGeneration`. Time: L3-2 'advances past the grace' of 120 seconds on a 60 second cron, L3-5 'advances past the lease' of 35 minutes, L3-8 'advances 31 days', L3-4 needs eight attempts under a backoff that sums to minutes. No Spec makes any of these constants configurable and every one is read from `Date.now()` inside the function. The first experiment needs the interruptions to pass Layer 2, so it cannot be run from the Specs without inventing a harness, and the Layer 3 examples cannot run in bounded time at all.", "fix": "Add a harness contract to `spec:platform.acceptance-contract` as an [extension]. Interruption is a fault flag in the fixture app, a row a named batch or wrapper reads and throws on, never a scheduler kill, so the chain ends the way a developer error ends it and the operator path is what resumes it; a lost response is a caller that discards the result. Time: every duration the design pins, grace, cron interval, lease, backoff, deferral, retention horizons, rollback period, receipt window, is read from one module configuration with the pinned defaults, and a native run that shortens them records 'adjusted configuration' with the values, which the evidence rule already requires. Say which examples run with shortened values and that Sc L2-9's production-configuration run excludes them.", - "status": "open", - "note": "", - "slice": "S0", - "detector": "judgement" + "status": "partially-fixed", + "note": "S0, 2026-10-01. `spec:platform.acceptance-contract` now names the native tier's controls under E-15: the backend process (start on fresh storage, stop, restart on the same storage), the clients (close, discard a response), the environment variables and, on the fixture composition, fixture functions. It has no clock control and no scheduler switch. Two claims in the issue are too broad: three durations are already configuration or arguments (receipt retention per command, backoff per handler, the rollback period), and the sweeper measures elapsed time with performance.now(). The fix's 'one module configuration' is not taken: whether the durations Layer 3 pins become configuration is left to Layer 3 and sits in the owner queue, and a lease cannot be shortened apart from the action timeout it must exceed. Replacing every interruption with an injected throw is not taken either: L3-2 separates a cancelled dispatch from a failed wrapper. No Layer 0 to 2 example needs time to pass. Still open: the Layer 2 examples that say 'kills the backend's scheduler' or 'stops the scheduler' (L2-5, both L2-6 siblings, L2-8) name a control that does not exist. S4 and S5 reword each to a listed control when they build it, and the build must show `resumeGeneration` run after an interruption that leaves no pending batch. The Layer 3 to 6 examples that advance time wait for Layer 3.", + "slice": "S4", + "detector": "judgement", + "unreviewed": true }, { "round": 3, @@ -2684,10 +2685,11 @@ ], "issue": "The first experiment passes 'when every Layer 0, 1 and 2 scenario passes on a native backend', and many of those examples need a seam, an observation point or a domain that no Spec gives. This is beside r3-architecture-native-tier-cannot-move-time-or-interrupt, which covers interrupting a batch chain and moving time. (1) Faults inside one mutation. L1-2: 'The fixture context's adapter `execute` saves the folded state and then throws on a fault flag the test sets', 'appends the new events to the journal and then throws on a fault flag', 'The fault sits in the parent ... immediately before the receipt insert of step 10'. L2-1: 'injects a plain throw inside the Inventory component's `allocate` operation after Orders has appended its events'. ALL-1: 'points the fixture app's audit writer at a table whose validator refuses the record', 'replaces the fixture app's diagnostic sink with one that throws'. `execute`, `runPipeline` and `writeAudit(ctx, record)` are pinned with no hook, and baseline-operations says 'the production composition has no injection point'. (2) Observation inside the backend. L1-10: 'a spy on the decider records zero calls to `decide`'. L1-4: 'the Orders decider's `decide` did not run for the second call'. ALL-1: 'the fixture's swallowed-error counter is one', where `emitDiagnostic` 'increments a counter in memory', which a test process cannot read from a mutation's isolate. L2-3: 'exactly one `ctx.runMutation` on Orders and one on Inventory from the fixture counter', a 'fixture wrapper around `ctx.runMutation`', in an example that binds `{configuration: \"production configuration\"}`. (3) The fixture app. The contract says 'The kernel's fixture app is separate from the example app' and never says what it contains or which examples run on which app. L1-1, L1-10 and L1-11 bind states `draft` and `submitted` and operations `ship`, `amend naming the reviewed version` and `claim one unit`; the example domain has none of them, its model says '`PlaceOrder` and `CancelOrder` are the only public commands of the experiment', and neither names a reviewed version, so Sc L1-10 has no command to run on the experiment's application. (4) Sc L2-9 'runs the full Layer 0 to 2 suite' against a deployment where 'no test-only function is registered', while L2-7 uses 'a parent test mutation' and the items above need test code inside the backend.", "fix": "Add a harness section to `spec:platform.acceptance-contract`, as an extension beside the one r3-architecture-native-tier-cannot-move-time-or-interrupt asks for, and point every example at it. Say what the fixture app is: a second composition that mounts the same libraries with a small fixture context whose stream has the `draft` and `submitted` states and the `ship`, `amend` and `claim` operations, and say which examples run on it and which on the Orders and Inventory application; or move L1-1, L1-10 and L1-11 onto the example domain and give `CancelOrder` an optional reviewed version in `spec:application.orders-inventory-example`. Pin the fault seam once: for example the fixture context's decider or `combine` throws on a fault row for the context-side points, and the fixture app wraps `insertReceipt`, `writeAudit` and the diagnostic sink, so the shared library carries no hook. Replace each in-backend spy with an assertion on stored data or the returned error, such as `staleVersion` with `details.current` and no new event for 'decide never ran', and say how the swallowed diagnostic is observed, for example a log line the test reads. State how component calls are counted without a wrapper in the production composition, or that the count comes from the function log alone. State that Sc L2-9 runs the subset of the suite that needs no fixture function and name that subset.", - "status": "open", - "note": "", - "slice": "S0", - "detector": "judgement" + "status": "partially-fixed", + "note": "S0, 2026-10-01. `spec:platform.acceptance-contract` states under E-15: two compositions, fixture and production, with the rule that routes an example to one; faults only through parts the fixture composition supplies, with no hook or flag in the shared libraries; assertions only on what a client or the harness's admin access can read, and for 'did not run' a fixture part that fails the call if it runs; and Sc L2-9's production-configuration run as the scenarios routed to the production composition, which is a reading for the owner because the doc also says every Layer 0 to 2 scenario passes on a native backend. The L2-9 example's bullets follow that reading. Two substitutions in the fix are not taken: a throw from `decide` or `combine` reaches neither 'after the state write' nor 'after the journal append', and a `staleVersion` result does not show that `decide` never ran. Still open, by slice. S1: give the fixture context its states and operations (draft, submitted; ship, amend, claim), name the composition on each Layer 1 example, show the three L1-2 fault points reached with the library's own `execute` and pipeline unmodified, and replace each in-backend spy. S2: the same for L2-1's throw, and a source for the component-call count on the production composition. S5: ALL-1's audit and sink faults, the swallowed-error counter a test cannot read, and L2-7's parent test mutation.", + "slice": "S1", + "detector": "judgement", + "unreviewed": true }, { "round": 3, @@ -2888,10 +2890,11 @@ ], "issue": "Rubric item 18 holds as the plan's section 4 states it: all 43 rows have examples, 54 in all, each with the doc's pass condition verbatim as its outcome (checked by script), and nine rows are split into siblings. The split rule is applied unevenly, though. README's ID changes add a sibling for L2-1, L2-6, L3-2 and L3-6 'because the doc's scenario row enumerates two cases', and four rows of the same form keep one bound point with the second case only in a verification bullet: L1-7 'a worker or agent namespace' binds `worker` and tests `agent` in a bullet, L1-9 'Rate or capacity refusal' binds a rate refusal only, L2-4 'Rename a typed handler or break its argument contract' binds the rename only although the parent's space has all three values of `change`, and L1-11 'the same stock or unique value' binds stock only. For L1-11 the parent's example space cannot bind the other case at all: `state` is `\"draft\"|\"submitted\"|\"stock of 1\"` and no operation creates a subject with a claimed unique value, although the adapter's `uniqueValueWorked` bullet designs exactly that.", "fix": "Either add the four siblings, `client-claims-agent-namespace`, `capacity-refusal-then-retry`, `broken-argument-contract-fails-build` and `competing-unique-value` with a `claimed unique value` state and a creating operation in `spec:context.context-component`'s vocabulary, and list them in README's ID changes, or state in `spec:platform.acceptance-contract` the rule that decides when an 'or' in a row is a case and when it is a variant covered by a verification bullet, so the next reviewer can check it.", - "status": "open", - "note": "", - "slice": "S0", - "detector": "judgement" + "status": "partially-fixed", + "note": "S0, 2026-10-01. The rule is now exact in `PLAN.md` section 4 and in the acceptance contract's verification: an 'or' in a scenario's text enumerates cases and each gets a sibling example; an 'or' in the pass condition does not; a verification bullet never stands in for a case. The fix's alternative, a rule that lets a bullet cover a variant, is not taken, because it would change a rule the corpus already states in three places. One correction to the issue: the capacity and the unique-value cases are absent altogether, not carried by a bullet. Siblings still to write, each with the slice that builds its parent: S1 `client-claims-agent-namespace` (L1-7), `capacity-refusal-then-retry` (L1-9) and `competing-unique-value` (L1-11, which needs a claimed-unique-value state and a creating operation in the fixture context's vocabulary); S2 a query sibling of `committed-state-visible` (L2-2) and `broken-argument-contract-fails-build` (L2-4). README's ID changes list them when they exist.", + "slice": "S1", + "detector": "judgement", + "unreviewed": true }, { "round": 3, @@ -3587,10 +3590,11 @@ ], "issue": "E-13 gives every native test its identity through 'the local backend's admin key acting as an identity' and says 'the token's source is the one named difference from production' (tenancy-and-authority `nativeTierIdentity`; acceptance-contract `tierNativeIdentity` and the E-13 rule: 'the identity's signature source is the one named difference'). The mechanism exists: `setAdminAuth(token, actingAsIdentity)` in convex-js `src/browser/http_client.ts` and `ConvexTestingHelper.withIdentity` in `convex-helpers/testing.ts` (github.com/get-convex/convex-js and get-convex/convex-helpers, read 2026-10-01). But a request that carries the admin key is not checked like a client request. In `crates/udf/src/validation.rs` of get-convex/convex-backend (read 2026-10-01) `check_visibility_access` allows an internal function when `identity.is_admin() || identity.is_system() || identity.is_acting_as_user()` ('Admins may have the ability to run the internal function'), and the same test opens a component's public functions to such a caller. The docs agree for the admin tools: internal functions can be run 'using the Dashboard' and 'from the CLI' (docs.convex.dev/functions/internal-functions, read 2026-10-01). So the harness identity can call every `internalMutation` and every component function directly. Two things follow. First, the Sc L1-7 example's sibling assertion, 'The test also calls the internal entry from the client with namespace `agent` and asserts that Convex refuses the call', cannot pass when the client is the harness client E-13 pins for 'caller user-1': the call is accepted and the internal entry runs with a client-chosen actor and namespace. Second, the parity statement for Sc L2-9 is incomplete: under the acting-as identity `clientClaimRefused` ('a client call to the internal entry is refused by Convex before any handler runs') and `clientReach` in context-component do not hold for the test's own client, so a test that reaches an internal entry or a component function by mistake passes natively and fails in production. E-13 also says the alternative costs 'a JWKS endpoint per run'; the custom JWT page says of `jwks`: 'If you'd like to avoid hitting an external service you may use a data URI' (docs.convex.dev/auth/advanced/custom-jwt, read 2026-10-01), so no endpoint is needed.", "fix": "On `spec:platform.acceptance-contract` (owner of E-13) name the second difference: an admin acting-as identity passes Convex's visibility check for internal and component functions, so the harness client is more privileged than any production client. Pin that every assertion about visibility, Sc L1-7's direct call to the internal entry and any 'a client cannot reach a component function' check, is made from a second client that holds no admin key (unauthenticated is enough, since the refusal precedes authentication), and say so in the L1-7 example's verification bullet and in `clientClaimRefused`. Correct the E-13 alternative: a test issuer needs no endpoint, because a `customJwt` provider takes its JWKS as a data URI; the cost is one extra provider entry in the fixture's `auth.config.ts`, which the evidence record names as adjusted configuration. Add both differences to `evidenceRecord`.", - "status": "open", - "note": "", + "status": "fixed", + "note": "S0, 2026-10-01. E-13's option changed on `spec:platform.acceptance-contract`: a caller under test is an ordinary client carrying a token the harness signs for a fixture issuer, and `auth.config.ts` reads the issuer and a data-URI key set from the deployment's environment variables. The admin key is for setup and reading only (E-15). Restated in `tenancy-and-authority` (`nativeTierIdentity`), `first-experiment` (the rule and `identitySource`), the L2-9 and L1-7 examples and README. Four examples verify the contract on a native backend. Two corrections to the finding: the refusal precedes the handler, not authentication; and the fix's cost line is right, a data-URI key set needs no endpoint. The owner confirms E-13.", "slice": "S0", - "detector": "native local-backend test" + "detector": "native local-backend test", + "unreviewed": true }, { "round": 3, diff --git a/design/specs/application/first-experiment.native-acceptance-production-configuration.sdp.md b/design/specs/application/first-experiment.native-acceptance-production-configuration.sdp.md index b3d6e5f..8024ccb 100644 --- a/design/specs/application/first-experiment.native-acceptance-production-configuration.sdp.md +++ b/design/specs/application/first-experiment.native-acceptance-production-configuration.sdp.md @@ -25,6 +25,6 @@ Then authority, schemas, concurrency and code path {parity: "match a release"} ## Verification — executable - Runs in the end-to-end tier on the production composition; every test owns its disposable backend. -- The test deploys the release build, not a test build, with the production `auth.config.ts`, grants, namespaces and entry points and the production schema, and runs the full Layer 0 to 2 suite against it. -- The test obtains each identity through the local backend's admin key acting as an identity whose `issuer` and `subject` are the production issuer's, because a production issuer cannot mint tokens for a disposable backend; the evidence record names the identity source as the one difference from production, under E-13 of the acceptance contract. +- The test deploys the release build, not a test build, with the production `auth.config.ts`, grants, namespaces and entry points and the production schema, and runs against it every Layer 0 to 2 scenario that the acceptance contract routes to the production composition under E-15; the scenarios it routes to the fixture composition run there with the same libraries, schemas and authority model. +- The test obtains each identity from a token the harness signs for the fixture issuer that the deployment's environment variables name, carried by an ordinary client, because a production issuer cannot mint tokens for a disposable backend; the evidence record names that issuer as the one difference from production, under E-13 of the acceptance contract. - The test asserts that no test-only function is registered in the deployment and that the evidence record of the run states no configuration difference from production other than the identity source. diff --git a/design/specs/application/first-experiment.sdp.md b/design/specs/application/first-experiment.sdp.md index 982d526..e2d0497 100644 --- a/design/specs/application/first-experiment.sdp.md +++ b/design/specs/application/first-experiment.sdp.md @@ -70,7 +70,7 @@ The six cost targets are the constraint Specs this workflow is constrained by. T - rule: The cost targets are one top-level commit per successful command, zero core projection jobs, O(N) business work allowed, one public command execution per business intent, no application-wide counter, and no queue recovery needed for essential reads (First experiment) - rule: The results feed the open question on component cost and the Layer 3 decisions (First experiment, OQ1, Decision method rule 4) - rule: Native acceptance runs with production configuration: the same authority, schemas, concurrency and code path as a release (Sc L2-9) -- rule: [extension] The native tier obtains its identities through the local backend's admin key acting as an identity whose issuer and subject are the ones production's `auth.config.ts` names, so the actor mapping, the grants, the namespaces and the entry points are the production ones; the token's source is the one named difference from production and every run's evidence record states it, as `spec:platform.acceptance-contract` rules under E-13 (E-13, S13, Sc L2-9) +- rule: [extension] The native tier obtains its identities from tokens the harness signs for the fixture issuer that the deployment's environment variables name, carried by ordinary clients, so the actor mapping, the grants, the namespaces, the entry points and Convex's visibility check are the production ones; the issuer is the one named difference from production and every run's evidence record states it, as `spec:platform.acceptance-contract` rules under E-13 (E-13, S13, Sc L2-9) - rule: Every run is recorded as acceptance evidence: commit, installed layers, backend and dependency versions, configuration, dataset, command and result, and a run with adjusted configuration states how it differs from production (Acceptance scenarios) - rule: The kernel's fixture app is separate from the example app, and test-only functions never ship (Acceptance scenarios) - rule: [extension] The second lifecycle command is `CancelOrder`, the essential summary is the order summary read model, and the provisional maximum is 100 lines (E-46, First experiment) @@ -89,7 +89,7 @@ The six cost targets are the constraint Specs this workflow is constrained by. T The experiment is an application, a test suite and a report. The application is the production composition: two context components, the parent with its pipeline, use cases, read models, registry and gate. The suite is the acceptance scenarios of Layers 0 to 2, run against a disposable local backend as the acceptance contract prescribes. The report is a set of measurement records, one per run, compared against the six constraint targets. -- typeMeasurement: `[extension] interface Measurement { commit: string; backendVersion: string; convexVersion: string; configuration: "production" | "adjusted"; adjustments?: string; identitySource: "issuer" | "admin-acting-as"; dataset: string; command: "PlaceOrder" | "CancelOrder"; lines: number; contention: number; path: "healthy" | "retry"; topLevelCommits: number; functionCalls: number; componentCalls: number; documentsRead: number; documentsWritten: number; bytesRead: number; bytesWritten: number; rowsLeftBehind: number; occRetries: number | "not exposed"; occExhaustions: number; latencyMs: number }` where `contention` is the number of concurrent callers and `occExhaustions` counts commands the engine failed with its documents-changed error (E-47, E-13, First experiment) +- typeMeasurement: `[extension] interface Measurement { commit: string; backendVersion: string; convexVersion: string; configuration: "production" | "adjusted"; adjustments?: string; identitySource: "production-issuer" | "fixture-issuer"; dataset: string; command: "PlaceOrder" | "CancelOrder"; lines: number; contention: number; path: "healthy" | "retry"; topLevelCommits: number; functionCalls: number; componentCalls: number; documentsRead: number; documentsWritten: number; bytesRead: number; bytesWritten: number; rowsLeftBehind: number; occRetries: number | "not exposed"; occExhaustions: number; latencyMs: number }` where `contention` is the number of concurrent callers and `occExhaustions` counts commands the engine failed with its documents-changed error (E-47, E-13, First experiment) - captureCommits: top-level commits are the successful top-level mutation executions in the backend's function log for the run (E-47, First experiment) - captureCalls: function calls are every function execution the log shows; component calls are counted by a fixture wrapper around `ctx.runMutation` and `ctx.runQuery` on component references, and the two are reconciled (E-47, F4) - captureDocuments: documents and bytes read and written per function come from the backend's execution records where the local backend exposes them; where it does not, the record says `not exposed` and the dashboard's insights on a cloud run stand in (E-47, First experiment) diff --git a/design/specs/command/tenancy-and-authority.client-claims-system-namespace.sdp.md b/design/specs/command/tenancy-and-authority.client-claims-system-namespace.sdp.md index f29ebf8..a33c54f 100644 --- a/design/specs/command/tenancy-and-authority.client-claims-system-namespace.sdp.md +++ b/design/specs/command/tenancy-and-authority.client-claims-system-namespace.sdp.md @@ -31,4 +31,4 @@ And the namespace the server assigned is {namespace: "none"} - Runs in the native tier; every test owns its disposable backend. - The test asserts that no receipt, event or read-model row was written and that the error is not a `ConvexError` of the outcome boundary. -- The test also calls the internal entry from the client with namespace `agent` and asserts that Convex refuses the call, so both routes to a system namespace are closed. +- The test also calls the internal entry from the same ordinary client, which holds no admin key, with namespace `agent` and asserts that Convex refuses the call, so both routes to a system namespace are closed. diff --git a/design/specs/command/tenancy-and-authority.sdp.md b/design/specs/command/tenancy-and-authority.sdp.md index 99c13fa..aa8c388 100644 --- a/design/specs/command/tenancy-and-authority.sdp.md +++ b/design/specs/command/tenancy-and-authority.sdp.md @@ -71,7 +71,7 @@ Nothing here signs anything. The internal entry is unreachable by clients becaus - clientClaimRefused: a `namespace` or `actor` field sent to the public entry fails its `args` validator, and a client call to the internal entry is refused by Convex before any handler runs (D11, Sc L1-7) - workerModes: the two modes of the one `Authority` shape pinned in `spec:command.actor-and-scope`, never restated here: `recheckDelegator` re-reads the delegating user's grants at execution through `authorize` with the captured actor and scope, and `serviceAuthority` runs under the service actor's own grants with no recheck; both carry the obligation on the actor's `delegationRef` (D11, D13, E-6) - trustBoundary: one trusted deployment; component privacy and type brands, never a signed token, and no claim against a malicious administrator (D11) -- nativeTierIdentity: on the native tier the identity `ctx.auth.getUserIdentity()` returns comes from the local backend's admin key acting as an identity whose `issuer` and `subject` are the ones production's `auth.config.ts` names, so `establishActor`, the grants read and the namespaces run unchanged; the token's source is the one named difference from production, recorded under E-13 on `spec:platform.acceptance-contract` (E-13, S13, Sc L2-9) +- nativeTierIdentity: on the native tier the identity `ctx.auth.getUserIdentity()` returns comes from a token the harness signs for the fixture issuer that the deployment's environment variables name, carried by an ordinary client, so `establishActor`, the grants read, the namespaces and Convex's visibility check run unchanged; the issuer is the one named difference from production, recorded under E-13 on `spec:platform.acceptance-contract` (E-13, S13, Sc L2-9) - limitGrantsPerPrincipal: at most 500 grants read per principal per tenant per command; the read takes 501 and a 501st row throws a plain `Error`, a technical failure, never a silent truncation, because `.take(500)` alone cannot show that a 501st exists; a principal with more is a product problem the failure surfaces (F13, E-37) ## Example space diff --git a/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.sdp.md b/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.sdp.md new file mode 100644 index 0000000..b3c4865 --- /dev/null +++ b/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:facts.f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f04-nested-calls-cost-more-than-helpers + verifies: spec:facts.f04-nested-calls-cost-more-than-helpers +--- +# Probe 3: the cost of a component call from a mutation + +Probe 3 · native tier · fixture composition. + +## Intent + +- outcome: One component call costs more than a helper call, by an amount the run records. (Probe 3, F4) + +```gwt +Given a parent mutation that reads the same document {reads: 50} times in a row through one of three paths: a helper function, a nested query, a component query +When a client calls the parent mutation once for each path, several times over +Then the median execution time through the component is {componentAgainstHelper: "higher"} than through the helper +And the function log holds {recordsPerCall: 1} execution record for one parent call through the component +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The execution time is the one the backend's function log reports for the parent call; the test records the three medians and the cost per call in the run's evidence. +- The test records what the function log shows of the nested and the component calls, which is all a local backend can say about function-call quota. diff --git a/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md b/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md new file mode 100644 index 0000000..f16ca23 --- /dev/null +++ b/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md @@ -0,0 +1,28 @@ +--- +id: spec:facts.f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f04-nested-calls-cost-more-than-helpers + verifies: spec:facts.f04-nested-calls-cost-more-than-helpers +--- +# Probe 3: the cost of a component call from a query + +Probe 3 · native tier · fixture composition. + +## Intent + +- outcome: One component call costs more than a helper call, by an amount the run records. (Probe 3, F4) + +```gwt +Given a parent query that reads the same document {reads: 50} times in a row through one of three paths: a helper function, a nested query, a component query +When a client calls the parent query once for each path, several times over, with arguments that defeat the query cache +Then the median execution time through the component is {componentAgainstHelper: "higher"} than through the helper +And the function log holds {recordsPerCall: 1} execution record for one parent call through the component +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The execution time is the one the backend's function log reports for the parent call, because the clock inside a query does not advance; the test records the three medians and the cost per call in the run's evidence. diff --git a/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.sdp.md b/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.sdp.md new file mode 100644 index 0000000..7aef24f --- /dev/null +++ b/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.sdp.md @@ -0,0 +1,28 @@ +--- +id: spec:facts.f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f05-react-client-retries-until-confirmed + verifies: spec:facts.f05-react-client-retries-until-confirmed +--- +# Probe 1: a backend restart with a mutation in flight + +Probe 1 · native tier · fixture composition. + +## Intent + +- outcome: The client retries until confirmed and the backend executes the call once. (Probe 1, F5) + +```gwt +Given a WebSocket client that sends a mutation which inserts one marker row +When the backend process is killed and restarted on the same storage while the client stays open, in each of {trials: 10} trials +Then the mutation's promise resolves after the restart {resolves: true} +And every trial leaves exactly {markerRows: 1} marker row +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The kill is a SIGKILL sent a short random time after the send, so that some trials die before the commit and some after it, and the test records the split. diff --git a/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.sdp.md b/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.sdp.md new file mode 100644 index 0000000..62dfcbc --- /dev/null +++ b/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.sdp.md @@ -0,0 +1,27 @@ +--- +id: spec:facts.f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f05-react-client-retries-until-confirmed + verifies: spec:facts.f05-react-client-retries-until-confirmed +--- +# Probe 1: a client closed with a pending mutation + +Probe 1 · native tier · fixture composition. + +## Intent + +- outcome: The backend executes the call once or not at all, never more. (Probe 1, F5) + +```gwt +Given a WebSocket client that sends a mutation which inserts one marker row +When the client is closed before the mutation's result arrives, in each of {trials: 20} trials +Then no trial leaves more than {mostMarkerRows: 1} marker row +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The test records how many trials left no row and how many left one, which is the measure of what a closed tab can lose. diff --git a/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.sdp.md b/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.sdp.md new file mode 100644 index 0000000..71101e3 --- /dev/null +++ b/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.sdp.md @@ -0,0 +1,27 @@ +--- +id: spec:facts.f05-react-client-retries-until-confirmed.probe-1-http-client-retry +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f05-react-client-retries-until-confirmed + verifies: spec:facts.f05-react-client-retries-until-confirmed +--- +# Probe 1: the HTTP client and a caller's retry + +Probe 1 · native tier · fixture composition. + +## Intent + +- outcome: The backend executes each call once, and a caller's retry is a second call. (Probe 1, F5) + +```gwt +Given an HTTP client whose caller sends a mutation which inserts one marker row and discards the response +When the caller sends the same mutation again +Then the backend holds {markerRows: 2} marker rows +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The test asserts that one call with no retry leaves exactly one row, and records that `ConvexHttpClient` sends each mutation in one request with no retry of its own. diff --git a/design/specs/facts/f13-transactions-have-limits.probe-4-component-call-shares-limits.sdp.md b/design/specs/facts/f13-transactions-have-limits.probe-4-component-call-shares-limits.sdp.md new file mode 100644 index 0000000..146e81e --- /dev/null +++ b/design/specs/facts/f13-transactions-have-limits.probe-4-component-call-shares-limits.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:facts.f13-transactions-have-limits.probe-4-component-call-shares-limits +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f13-transactions-have-limits + verifies: spec:facts.f13-transactions-have-limits +--- +# Probe 4: a component call draws on the caller's transaction limits + +Probe 4 · native tier · fixture composition. + +## Intent + +- outcome: The limits are shared across the component boundary, not applied per call. (Probe 4, F13) + +```gwt +Given documents of {documentSize: "900 KiB"} each, {parentDocuments: 10} that a parent mutation reads and {childDocuments: 10} more that a component query reads +When a client calls the parent mutation, which reads its documents and then calls the component query +Then the call {together: "fails on the bytes-read limit"} +And the parent reading alone {parentAlone: "commits"} and the component query called alone {childAlone: "returns"} +And the same split of bytes written by a parent mutation and a component mutation {writesTogether: "fails on the bytes-written limit"} +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The test also reads `ctx.meta.getTransactionMetrics()` in the parent before and after a component read that stays under the limit and asserts that `bytesRead.used` and `documentsRead.used` grew by what the component read. diff --git a/design/specs/facts/f13-transactions-have-limits.probe-4-nested-call-shares-limits.sdp.md b/design/specs/facts/f13-transactions-have-limits.probe-4-nested-call-shares-limits.sdp.md new file mode 100644 index 0000000..da02347 --- /dev/null +++ b/design/specs/facts/f13-transactions-have-limits.probe-4-nested-call-shares-limits.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:facts.f13-transactions-have-limits.probe-4-nested-call-shares-limits +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f13-transactions-have-limits + verifies: spec:facts.f13-transactions-have-limits +--- +# Probe 4: a nested call draws on the caller's transaction limits + +Probe 4 · native tier · fixture composition. + +## Intent + +- outcome: The limits are shared across the nested call, not applied per call. (Probe 4, F13) + +```gwt +Given documents of {documentSize: "900 KiB"} each, {parentDocuments: 10} that a parent mutation reads and {childDocuments: 10} more that a nested query reads +When a client calls the parent mutation, which reads its documents and then calls the nested query through ctx.runQuery +Then the call {together: "fails on the bytes-read limit"} +And the parent reading alone {parentAlone: "commits"} and the nested query called alone {childAlone: "returns"} +And the same split of bytes written by a parent mutation and a nested mutation {writesTogether: "fails on the bytes-written limit"} +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The test also reads `ctx.meta.getTransactionMetrics()` in the parent before and after a nested read that stays under the limit and asserts that `bytesRead.used` and `documentsRead.used` grew by what the child read. diff --git a/design/specs/facts/f13-transactions-have-limits.probe-4-nesting-depth.sdp.md b/design/specs/facts/f13-transactions-have-limits.probe-4-nesting-depth.sdp.md new file mode 100644 index 0000000..424ae5d --- /dev/null +++ b/design/specs/facts/f13-transactions-have-limits.probe-4-nesting-depth.sdp.md @@ -0,0 +1,27 @@ +--- +id: spec:facts.f13-transactions-have-limits.probe-4-nesting-depth +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f13-transactions-have-limits + verifies: spec:facts.f13-transactions-have-limits +--- +# Probe 4: how deep nested calls may go + +Probe 4 · native tier · fixture composition. + +## Intent + +- outcome: Nested calls have a depth bound, and the run records it. (Probe 4, F13) + +```gwt +Given a mutation that calls itself through ctx.runMutation until its call stack holds a requested number of functions +When a client asks for each number from 1 upward +Then the deepest call stack that commits holds {deepestStack: 8} functions, the top-level mutation included +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The test records the error text of the first depth that fails. diff --git a/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.sdp.md b/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.sdp.md new file mode 100644 index 0000000..6679cbd --- /dev/null +++ b/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.sdp.md @@ -0,0 +1,30 @@ +--- +id: spec:facts.f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f14-convex-error-survives-nested-and-component-boundary + verifies: spec:facts.f14-convex-error-survives-nested-and-component-boundary +--- +# Probe 2: ConvexError data through a component boundary + +Probe 2 · native tier · fixture composition. + +## Intent + +- outcome: The parent and then the client read the same data. (Probe 2, F14) + +```gwt +Given a mutation inside a component that writes one row and then throws a ConvexError carrying structured data with code {code: "staleVersion"} +When a parent mutation calls it through the component API and a client calls the parent +Then the parent's catch reads the thrown data unchanged {parentReadsData: true} +And a client whose parent lets the error pass reads the thrown data unchanged {clientReadsData: true} +And the row the component's mutation wrote is stored after the parent caught the error and committed {childRowStored: false} +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The thrown data holds a string, a number, a boolean, a null, an array, a nested object and a 64-bit integer, and the test compares decoded values, not messages. +- The test also throws an ordinary `Error` with an added property through the same path and records whether the property arrives. diff --git a/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.sdp.md b/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.sdp.md new file mode 100644 index 0000000..c8f4b14 --- /dev/null +++ b/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.sdp.md @@ -0,0 +1,30 @@ +--- +id: spec:facts.f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f14-convex-error-survives-nested-and-component-boundary + verifies: spec:facts.f14-convex-error-survives-nested-and-component-boundary +--- +# Probe 2: ConvexError data through a nested mutation + +Probe 2 · native tier · fixture composition. + +## Intent + +- outcome: The parent and then the client read the same data. (Probe 2, F14) + +```gwt +Given a mutation that writes one row and then throws a ConvexError carrying structured data with code {code: "staleVersion"} +When a parent mutation calls it through ctx.runMutation and a client calls the parent +Then the parent's catch reads the thrown data unchanged {parentReadsData: true} +And a client whose parent lets the error pass reads the thrown data unchanged {clientReadsData: true} +And the row the throwing mutation wrote is stored after the parent caught the error and committed {childRowStored: false} +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The thrown data holds a string, a number, a boolean, a null, an array, a nested object and a 64-bit integer, and the test compares decoded values, not messages. +- The test also throws an ordinary `Error` with an added property through the same path and records whether the property arrives. diff --git a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.sdp.md b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.sdp.md new file mode 100644 index 0000000..ce8fa1e --- /dev/null +++ b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f15-parent-query-over-component-query-stays-reactive + verifies: spec:facts.f15-parent-query-over-component-query-stays-reactive +--- +# Probe 5: pages of a component list stay contiguous under live writes + +Probe 5 · native tier · fixture composition. + +## Intent + +- outcome: The pages stay contiguous. (Probe 5, F15, S4) + +```gwt +Given a component list of {rows: 30} rows that a parent query relays from paginator, read in pages of {pageSize: 10} +And a client subscribed to every page, each bounded by the end cursor its first load returned +When {inserted: 5} rows are inserted inside the first page's range +Then the subscribed pages together hold every row exactly once and in order {contiguous: true} +And the first page now holds {firstPageRows: 15} rows +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The test also deletes a row inside the second page's range and asserts the same contiguity. diff --git a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md new file mode 100644 index 0000000..6a24a8c --- /dev/null +++ b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md @@ -0,0 +1,28 @@ +--- +id: spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f15-parent-query-over-component-query-stays-reactive + verifies: spec:facts.f15-parent-query-over-component-query-stays-reactive +--- +# Probe 5: a page that outgrows its read cap splits across the boundary + +Probe 5 · native tier · fixture composition. + +## Intent + +- outcome: The split crosses the boundary without a gap. (Probe 5, F15, S4) + +```gwt +Given a subscribed page of a component list, bounded by an end cursor and read through the parent with maximumRowsRead {maximumRowsRead: 20} +When rows are inserted inside the page's range until it holds {rowsInRange: 25} rows +Then the page the parent relays carries pageStatus {pageStatus: "SplitRequired"} and a split cursor +And the two pages on either side of the split cursor together hold every row exactly once and in order {contiguous: true} +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The test also runs the `usePaginatedQuery` hook of `convex-helpers/react` against the same parent query and records whether it shows every row after the split; if the hook cannot run in the test process, the run records that gap. diff --git a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.sdp.md b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.sdp.md new file mode 100644 index 0000000..60c0260 --- /dev/null +++ b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.sdp.md @@ -0,0 +1,27 @@ +--- +id: spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f15-parent-query-over-component-query-stays-reactive + verifies: spec:facts.f15-parent-query-over-component-query-stays-reactive +--- +# Probe 5: a subscription to a parent query follows the component's data + +Probe 5 · native tier · fixture composition. + +## Intent + +- outcome: The subscription updates. (Probe 5, F15) + +```gwt +Given a client subscribed to a parent query that returns what a component query reads +When a mutation changes the document inside the component +Then the subscription delivers the changed value without the client asking again {updated: true} +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The client is a `ConvexClient` over a WebSocket, and the test fails if no update arrives within a stated wait. diff --git a/design/specs/foundation.pack.sdp.md b/design/specs/foundation.pack.sdp.md index 597d4c3..aab2ebf 100644 --- a/design/specs/foundation.pack.sdp.md +++ b/design/specs/foundation.pack.sdp.md @@ -6,6 +6,10 @@ specs: - spec:platform.decision-method - spec:platform.vocabulary - spec:platform.acceptance-contract + - spec:platform.acceptance-contract.fixture-issuer-token-yields-identity + - spec:platform.acceptance-contract.ordinary-client-refused-internal-function + - spec:platform.acceptance-contract.ordinary-client-refused-component-function + - spec:platform.acceptance-contract.admin-key-acting-as-identity-reaches-internal-function - spec:platform.existing-systems - spec:laws.law01-sanctioned-writes-only - spec:laws.law02-state-and-events-commit-together @@ -44,7 +48,12 @@ specs: - spec:facts.f02-component-calls-commit-with-caller - spec:facts.f03-nested-run-mutation-partial-rollback - spec:facts.f04-nested-calls-cost-more-than-helpers + - spec:facts.f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation + - spec:facts.f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query - spec:facts.f05-react-client-retries-until-confirmed + - spec:facts.f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation + - spec:facts.f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation + - spec:facts.f05-react-client-retries-until-confirmed.probe-1-http-client-retry - spec:facts.f06-client-mutations-run-in-order - spec:facts.f07-queries-reactive-not-durable-delivery - spec:facts.f08-scheduling-commits-with-mutation @@ -53,8 +62,16 @@ specs: - spec:facts.f11-components-have-no-ctx-auth - spec:facts.f12-backups-exclude-pending-scheduled-functions - spec:facts.f13-transactions-have-limits + - spec:facts.f13-transactions-have-limits.probe-4-nested-call-shares-limits + - spec:facts.f13-transactions-have-limits.probe-4-component-call-shares-limits + - spec:facts.f13-transactions-have-limits.probe-4-nesting-depth - spec:facts.f14-convex-error-survives-nested-and-component-boundary + - spec:facts.f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation + - spec:facts.f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary - spec:facts.f15-parent-query-over-component-query-stays-reactive + - spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates + - spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous + - spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed - spec:facts.f16-scheduled-functions-table-shows-failed-runs - spec:facts.f17-migrations-fits-generation-backfill modelRefs: @@ -64,4 +81,4 @@ modelRefs: Package A. The decisions document carried into the graph: the platform epic, the twelve laws, the nineteen decisions, the fact ledger and its probe plan, the decision method, layers and profiles, the acceptance contract, existing-systems rules and the vocabulary. Every other package cites these IDs; none of them redefines them. -Membership order follows the plan's inventory: the platform Specs, then the laws in number order, then the decisions in number order, then the fact ledger, the probe plan and the facts in number order. Fifty-six Specs. +Membership order follows the plan's inventory: the platform Specs, then the laws in number order, then the decisions in number order, then the fact ledger, the probe plan and the facts in number order. Each parent's examples follow it: the four harness examples after the acceptance contract, and each probe's examples after the fact they verify. Seventy-three Specs. diff --git a/design/specs/platform/acceptance-contract.admin-key-acting-as-identity-reaches-internal-function.sdp.md b/design/specs/platform/acceptance-contract.admin-key-acting-as-identity-reaches-internal-function.sdp.md new file mode 100644 index 0000000..0f1258b --- /dev/null +++ b/design/specs/platform/acceptance-contract.admin-key-acting-as-identity-reaches-internal-function.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:platform.acceptance-contract.admin-key-acting-as-identity-reaches-internal-function +kind: example +altitude: story +readiness: defined +relations: + refines: spec:platform.acceptance-contract + verifies: spec:platform.acceptance-contract +--- +# The admin key acting as an identity reaches an internal function + +E-13 · native tier · fixture composition. + +## Intent + +- outcome: The call runs, which is why no caller under test carries the admin key. (E-13) + +```gwt +Given a disposable backend running the fixture composition, which registers an internal mutation that writes one row +And a client holding the admin key and acting as the identity with subject {subject: "user-1"} +When the client calls the internal mutation by name +Then Convex runs the call {runs: true} +And the row the internal mutation writes is stored {stored: true} +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The test asserts that a public query called by the same client returns the identity it acts as, so the client is indistinguishable from an ordinary caller inside a handler. diff --git a/design/specs/platform/acceptance-contract.fixture-issuer-token-yields-identity.sdp.md b/design/specs/platform/acceptance-contract.fixture-issuer-token-yields-identity.sdp.md new file mode 100644 index 0000000..1052ca3 --- /dev/null +++ b/design/specs/platform/acceptance-contract.fixture-issuer-token-yields-identity.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:platform.acceptance-contract.fixture-issuer-token-yields-identity +kind: example +altitude: story +readiness: defined +relations: + refines: spec:platform.acceptance-contract + verifies: spec:platform.acceptance-contract +--- +# A token signed by the fixture issuer yields the caller's identity + +E-13 · native tier · fixture composition. + +## Intent + +- outcome: An ordinary client carrying a token the harness signed is the identity the token names, and a client with no token is nobody. (E-13) + +```gwt +Given a disposable backend whose environment variables name the fixture issuer {issuer: "https://fixture-issuer.test"} and its data-URI key set +And an ordinary client carrying a token the harness signed for subject {subject: "user-1"} +When the client calls a public query that returns the caller's identity +Then the identity names issuer {identityIssuer: "https://fixture-issuer.test"} and subject {identitySubject: "user-1"} +And the same query from a client with no token returns an identity {anonymousHasIdentity: false} +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The test asserts that `tokenIdentifier` is the issuer and the subject joined by a vertical bar, and that a token signed by a key outside the key set is refused. diff --git a/design/specs/platform/acceptance-contract.ordinary-client-refused-component-function.sdp.md b/design/specs/platform/acceptance-contract.ordinary-client-refused-component-function.sdp.md new file mode 100644 index 0000000..2609fd5 --- /dev/null +++ b/design/specs/platform/acceptance-contract.ordinary-client-refused-component-function.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:platform.acceptance-contract.ordinary-client-refused-component-function +kind: example +altitude: story +readiness: defined +relations: + refines: spec:platform.acceptance-contract + verifies: spec:platform.acceptance-contract +--- +# An ordinary client cannot call a component's function + +E-13, E-15 · native tier · fixture composition. + +## Intent + +- outcome: Convex refuses the call before a handler runs. (E-13, D2) + +```gwt +Given a disposable backend running the fixture composition, which mounts a component with a mutation that writes one row +And an ordinary client carrying a token the harness signed for subject {subject: "user-1"} +When the client calls the component's mutation directly by component path and name +Then Convex refuses the call {refused: true} +And the row the component's mutation writes is stored {stored: false} +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The test repeats the call from a client with no token and asserts the same refusal, and reads the component's table with the harness's admin access. diff --git a/design/specs/platform/acceptance-contract.ordinary-client-refused-internal-function.sdp.md b/design/specs/platform/acceptance-contract.ordinary-client-refused-internal-function.sdp.md new file mode 100644 index 0000000..2952453 --- /dev/null +++ b/design/specs/platform/acceptance-contract.ordinary-client-refused-internal-function.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:platform.acceptance-contract.ordinary-client-refused-internal-function +kind: example +altitude: story +readiness: defined +relations: + refines: spec:platform.acceptance-contract + verifies: spec:platform.acceptance-contract +--- +# An ordinary client cannot call an internal function + +E-13, E-15 · native tier · fixture composition. + +## Intent + +- outcome: Convex refuses the call before a handler runs. (E-13, Sc L1-7) + +```gwt +Given a disposable backend running the fixture composition, which registers an internal mutation that writes one row +And an ordinary client carrying a token the harness signed for subject {subject: "user-1"} +When the client calls the internal mutation by name +Then Convex refuses the call {refused: true} +And the row the internal mutation writes is stored {stored: false} +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The test repeats the call from a client with no token and asserts the same refusal and no row, read with the harness's admin access. diff --git a/design/specs/platform/acceptance-contract.sdp.md b/design/specs/platform/acceptance-contract.sdp.md index cd69387..17f5d90 100644 --- a/design/specs/platform/acceptance-contract.sdp.md +++ b/design/specs/platform/acceptance-contract.sdp.md @@ -8,7 +8,7 @@ relations: --- # The acceptance contract -Feature · Detail: full transcription · Traces: Acceptance scenarios (table, tiers, evidence), First experiment, S13, Sc L2-4, E-13, E-14. +Feature · Detail: full transcription · Traces: Acceptance scenarios (table, tiers, evidence), First experiment, S13, Sc L2-4, Sc L2-9, E-13, E-14, E-15. The doc's acceptance table is the contract. It lists forty-three scenario rows by layer with a pass condition each, and states how each row is tested (the tier), what a test run must record (the evidence) and how readiness is stated. This Spec carries those rules. The rows themselves become example Specs under the component that first provides the capability, one example per row and one sibling example per enumerated case, so that each pass condition is a bound point in that component's example space; the plan's section 4 gives the assignment and the example IDs. @@ -17,12 +17,13 @@ The doc's acceptance table is the contract. It lists forty-three scenario rows b - outcome: Every acceptance scenario is tested as behavior on the tier that can prove it, with recorded evidence, and readiness is stated in plain terms that a run can back (Acceptance scenarios) - value: The scenarios are the spine of the decision method: a mechanism is justified only by a scenario the do-nothing option fails, so the contract is also the refutation surface for every mechanism (Decision method rule 3) - risk: A native tier costs a disposable backend per test and a production-configuration run; skipping it leaves component, nested-mutation, scheduling and contention behavior proven only in a simulator, which is not native proof (Acceptance scenarios) -- assumption: Native tests run against a local Convex backend as S13 describes, and the testing guide S13 links obtains a test's identity through the backend's admin key acting as that identity (S13) +- assumption: Native tests run against a local Convex backend as S13 describes; the testing guide S13 links obtains a test's identity through the backend's admin key acting as that identity, which the harness does not use for a caller under test (S13, E-13) ### Open questions -- [non-blocking] Extension E-13: the doc says native acceptance runs with production authority and a disposable backend per test and does not say how a test obtains a signed identity, which a production issuer cannot mint for a throwaway backend; the option taken here is the local backend's admin key acting as an identity whose issuer and subject are the ones production's `auth.config.ts` names, the mechanism the guide S13 links uses, with the actor mapping, grants, namespaces and entry points unchanged and the identity source named on every evidence record as the one difference from production; the owner confirms, or supplies a test issuer with keys the harness holds, which would remove the difference at the cost of a JWKS endpoint per run (E-13, S13, Sc L2-9) +- [non-blocking] Extension E-13: the doc says native acceptance runs with production authority and a disposable backend per test and does not say how a test obtains a signed identity, which a production issuer cannot mint for a throwaway backend; the option taken here is a fixture issuer: the composition's `auth.config.ts` reads its issuer, application ID and key set from the deployment's environment variables, the harness sets them to an issuer name and a data-URI key set whose private key it holds, and each caller under test is an ordinary client carrying a token the harness signs, so the actor mapping, grants, namespaces, entry points and Convex's own visibility check run as in production and the issuer named by the environment is the one difference; the option first taken, the local backend's admin key acting as an identity, was dropped on 2026-10-01 because such a caller passes the visibility check for internal functions, which `spec:platform.acceptance-contract.admin-key-acting-as-identity-reaches-internal-function` shows on a native backend; the owner confirms (E-13, S13, Sc L2-9) - [non-blocking] Extension E-14: the doc's four tiers, domain, simulator, native and end to end, name no compile-time check, while Sc L2-4's pass condition is a build, type or registration check that fails before any traffic; the option taken here is a fifth tier, the build tier, a compile of the fixture app with the change applied that asserts a non-zero `tsc` exit or a failed registration check, run as a step of the fixture app's build before the native suite and recorded like any run; the alternative, folding it into the native tier as a compile step that precedes the native run, was not taken because a native run needs a backend and the check needs none; the owner confirms (E-14, Sc L2-4, Acceptance scenarios) +- [non-blocking] Extension E-15: the doc says the kernel's fixture app is separate from the example app, that test-only functions never ship and that every test owns its disposable backend, and does not say what the fixture app contains, how a fault enters a run, what a test may observe, or what a test controls on a backend that offers no clock and no scheduler switch; the option taken here is the harness rules below: two compositions, faults only through parts the fixture composition supplies, assertions only on what a client or the harness's admin access can read, and four controls, the backend process, the clients, the environment variables and fixture functions; it reads the production-configuration run of Sc L2-9 as the scenarios routed to the production composition, where the corpus earlier asked for the full suite on a release build; whether the durations Layer 3 pins become configuration, so that a test can outwait them, is left to Layer 3; the owner confirms (E-15, Acceptance scenarios, Sc L2-9) ## Rule @@ -43,7 +44,13 @@ The doc's acceptance table is the contract. It lists forty-three scenario rows b - Readiness is stated plainly as specified, implemented, tested under named conditions, or operationally accepted (Acceptance scenarios) - The first experiment passes when every Layer 0, 1 and 2 scenario passes on a native backend (First experiment) - The one all-layer scenario, breaking metrics and logging and separately breaking mandatory audit, applies to every installed layer (Sc ALL-1) -- [extension] Parity with a release for Sc L2-9 means the same code path, schema, concurrency and authority model, the grants, namespaces, entry points and actor mapping, while the identity's signature source is the one named difference; a native run authenticates through the local backend's admin key acting as an identity with the production issuer's name, and its evidence record says so (E-13, S13, Sc L2-9) +- [extension] Parity with a release for Sc L2-9 means the same code path, schema, concurrency and authority model: the grants, namespaces, entry points and actor mapping, and Convex's own visibility check on every call a caller under test makes; a native run authenticates each caller with a token signed by the fixture issuer that the deployment's environment variables name, and its evidence record names that issuer as the one difference from production (E-13, S13, Sc L2-9) +- [extension] The harness holds the disposable backend's admin key and uses it to deploy, to set environment variables, to read stored data and the function log, and to run fixture functions; a caller under test never carries it, because a call made with the admin key passes Convex's visibility check, and every assertion about authority, identity or visibility is made through an ordinary client (E-15, E-13) +- [extension] A native scenario runs on one of two compositions: the fixture composition, which mounts the shared libraries with fixture contexts, fixture functions and fixture-supplied parts, or the production composition, which is the example application with no test-only function registered; an example names its composition, and it runs on the production composition unless it needs a fixture-only state or operation, a fault or a fixture function (E-15, Acceptance scenarios) +- [extension] The shared libraries carry no test hook and read no test flag; a fault enters a native run only through a part the fixture composition supplies: a fixture context's own declaration, a fixture function, or a replaceable part it wires in place of the production one, such as the audit destination and the diagnostic sink (E-15, Acceptance scenarios) +- [extension] A native assertion reads only what a client or the harness's admin access can read: a returned value, the data of a thrown error, stored documents and the backend's function log; where a scenario asserts that something did not run, the fixture composition supplies a part that fails the call if it runs (E-15) +- [extension] The native tier controls a run through the backend process, which it starts on fresh storage, stops and restarts on the same storage; through its clients, which may close or discard a response; through the deployment's environment variables; and, on the fixture composition, through fixture functions; it cannot move the backend's clock or switch its scheduler off, so a scenario that needs time to pass waits in real time or shortens a duration that its owning Spec makes configurable, and that run is adjusted configuration (E-15, S13, Acceptance scenarios) +- [extension] The production-configuration run of Sc L2-9 is the scenarios routed to the production composition, deployed with no test-only function and no adjusted configuration; a scenario on the fixture composition runs the same libraries, schemas and authority model and is native evidence for its own row (E-15, Sc L2-9, First experiment) - [extension] A build-tier scenario passes when a compile of the fixture app with the change applied fails at `tsc` or at the registration check before any deployment; the build tier is the fifth tier, named by Sc L2-4 alone, and needs no backend (E-14, Sc L2-4) ## Design @@ -53,13 +60,14 @@ The corpus maps the doc's readiness words onto SDP's rungs without collapsing th - tierDomain: pure tests of `decide`, `evolve` and the fold with no I/O; Sc L0-1 and L0-2 (Acceptance scenarios) - tierSimulator: outcome combinations, serialization and classification of the four outcomes and the error codes (Acceptance scenarios, D4) - tierNative: a disposable local backend per test, production schemas and authority, proving component and nested-mutation behavior, scheduling, contention and deployment (Acceptance scenarios, S13) -- tierNativeIdentity: the harness holds the local backend's admin key and acts as the identity under test, setting `issuer` to the name production's `auth.config.ts` configures and `subject` to the test principal, so `ctx.auth.getUserIdentity()` in the public entry returns the same shape a production token yields and `establishActor`, the grants read and the namespace rules run unchanged; no production issuer is asked to sign for a disposable backend (E-13, S13, D11) +- tierNativeBackend: [extension] the disposable backend is the `convex-local-backend` binary of one pinned release, which the harness starts on free local ports with fresh storage, a generated instance secret and the admin key the binary derives from it; the harness sets the environment variables, deploys the composition's function directory and, when the test ends, stops the process and removes the storage (E-15, S13) +- tierNativeIdentity: [extension] each caller under test is an ordinary `ConvexHttpClient` or `ConvexClient` whose `setAuth` carries a token the harness signs; the composition's `auth.config.ts` declares one `customJwt` provider whose `issuer`, `applicationID` and `jwks` come from the deployment's environment variables, and the harness sets `jwks` to a data URI holding the fixture issuer's public key, so `ctx.auth.getUserIdentity()` in the public entry returns the shape a production token yields and `establishActor`, the grants read and the namespace rules run unchanged; no production issuer is asked to sign for a disposable backend (E-13, S13, D11) - tierEndToEnd: the production composition on a representative dataset; Sc L2-8, L2-9, L3-7, L4-3 (Acceptance scenarios) - tierBuild: [extension] a compile of the kernel's fixture app with the change under test applied, asserting a non-zero `tsc` exit naming the caller or a failed registration check, run as a build step before the native suite with no backend; Sc L2-4 (E-14, Sc L2-4) -- evidenceRecord: commit, installed layers, backend and dependency versions, configuration, dataset, command and result, plus the difference from production when configuration was adjusted, and the identity source, issuer-signed or admin acting-as, which on the native tier is the one difference a production-configuration run names (Acceptance scenarios, E-13) +- evidenceRecord: commit, installed layers, backend and dependency versions, configuration, dataset, command and result, plus the difference from production when configuration was adjusted, and the identity source, which on the native tier is the fixture issuer the deployment's environment names and is the one difference a production-configuration run names (Acceptance scenarios, E-13) - readinessWords: specified, implemented, tested under named conditions, operationally accepted; never a percentage or a score (Acceptance scenarios) ## Verification — reviewed -- A reviewer confirms that all forty-three scenario rows have example Specs, that enumerated cases are split into sibling examples, and that each example names its tier and states the doc's pass condition as its outcome. +- A reviewer confirms that all forty-three scenario rows have example Specs, that each case a row enumerates and each alternative a scenario's text joins with "or" has its own sibling example, that a verification bullet adds assertions to a case and never stands in for one, and that each example names its tier and states the doc's pass condition as its outcome. - A reviewer confirms that no example claims a passing run; the graph records a verifier's existence, never its result. diff --git a/design/tools/check.py b/design/tools/check.py index cb3fd4c..55c94f8 100644 --- a/design/tools/check.py +++ b/design/tools/check.py @@ -3,7 +3,11 @@ Exits 0 when the corpus, README.md and the review ledger agree; prints each failure and exits 1 otherwise. SESSIONS.md says when to run it. -`python3 design/tools/check.py --slice S1` lists the findings a slice takes and exits. +`python3 design/tools/check.py --slice S1` lists the findings a slice takes and exits. It reads the +ledger only and writes nothing, so a read-only job can run it. + +The Protocol's root is the repository root: the graph holds the Specs under design/specs and the +test anchors that verify them, which live outside design/. Output goes to generated/ at the root. The Protocol's CLI is the pinned dependency in the repository's package.json; run `npm ci` once. What `sdp validate` checks is the Protocol's. What this script adds is this project's policy: @@ -39,19 +43,27 @@ def fail(msg): def run(args): - return subprocess.run(["node", SDP] + args, capture_output=True, text=True, cwd=DESIGN) + return subprocess.run(["node", SDP] + args, capture_output=True, text=True, cwd=ROOT) + + +if "--slice" in sys.argv: + wanted = sys.argv[sys.argv.index("--slice") + 1] + for item in json.load(open(os.path.join(DESIGN, "reviews", "consensus-ledger.json"), encoding="utf-8"))["ledger"]: + if item.get("slice") == wanted and item["status"] in ("open", "partially-fixed", "owner"): + print(f"{item['id']} | {item['severity']} | {item['status']} | caught by: {item.get('detector', '?')} | {', '.join(item['files'])}") + sys.exit(0) # 1. The graph: validate and readiness divergence. out = run(["validate", "."]) text = out.stdout + out.stderr -graph = re.search(r"(\d+) specs · (\d+) packs · \d+ anchors → (\d+) nodes · (\d+) edges", text) +graph = re.search(r"(\d+) specs · (\d+) packs · (\d+) anchors → (\d+) nodes · (\d+) edges", text) verdict = re.search(r"validate: (\d+) errors · (\d+) warnings", text) if not graph or not verdict: fail("validate printed no summary line; run `npm ci` at the repository root, then check " + SDP) print("\n".join(failures)) sys.exit(1) -n_specs, n_packs, n_nodes, n_edges = (int(x) for x in graph.groups()) +n_specs, n_packs, n_anchors, n_nodes, n_edges = (int(x) for x in graph.groups()) n_errors, n_warnings = (int(x) for x in verdict.groups()) warnings = [l for l in text.splitlines() if "[warning]" in l] if n_errors: @@ -115,7 +127,7 @@ def run(args): # 3. README.md against the Specs. readme = open(os.path.join(DESIGN, "README.md"), encoding="utf-8").read() -expected_line = f"{n_specs} specs · {n_packs} packs · 0 anchors → {n_nodes} nodes · {n_edges} edges (0 errors, 0 warnings)" +expected_line = f"{n_specs} specs · {n_packs} packs · {n_anchors} anchors → {n_nodes} nodes · {n_edges} edges (0 errors, 0 warnings)" if expected_line not in readme: fail("README's expected validate output is stale; it should read: " + expected_line) if "validate: 0 errors · 0 warnings" not in readme: @@ -139,12 +151,6 @@ def run(args): fail(f"ledger item {item['id']} has status {item['status']!r}; allowed: {sorted(LEDGER_STATUSES)}") by_status[item["status"]] += 1 -if "--slice" in sys.argv: - wanted = sys.argv[sys.argv.index("--slice") + 1] - for item in ledger: - if item.get("slice") == wanted and item["status"] in ("open", "partially-fixed", "owner"): - print(f"{item['id']} | {item['severity']} | {item['status']} | caught by: {item.get('detector', '?')} | {', '.join(item['files'])}") - sys.exit(0) by_slice = collections.Counter(item.get("slice", "none") for item in ledger if item["status"] == "open") digest = hashlib.sha256() From 951874d2c91f4cccecbcf0d9c20956c46aec70ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Thu, 1 Oct 2026 16:10:21 +0200 Subject: [PATCH 02/28] build: S0 foundation, the toolchain, the fixture app, four tiers and the harness - Toolchain pinned to releases current on 2026-10-01: TypeScript 7.0.2 as the compiler with the TypeScript 6 package for typescript-eslint, Convex 1.46.0, convex-test 0.0.60, convex-helpers 0.1.124, Vitest 5.0.3, ESLint 10.11.0, Prettier 3.9.9, Node 24. - fixture/ is the kernel's fixture app: one app that mounts one component. - harness/ runs a disposable convex-local-backend per test from a pinned, hash-checked release, deploys the fixture app, signs tokens for the fixture issuer and writes an evidence record per native run. - Four tiers, one command each: types, pure, simulator (convex-test), native. - Four native tests bind the acceptance contract's examples through their generated step contracts. Passed on 2026-10-01: compiled (tsc 7.0.2, 1 type test), pure (1), convex-test (3), native backend precompiled-2026-09-28-5c7cb5b (5). Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 58 + .gitignore | 6 +- .prettierignore | 7 + .prettierrc.json | 1 + README.md | 15 +- eslint.config.js | 15 + evidence/README.md | 5 + fixture/convex/_generated/api.d.ts | 51 + fixture/convex/_generated/api.js | 23 + fixture/convex/_generated/dataModel.d.ts | 60 + fixture/convex/_generated/server.d.ts | 162 + fixture/convex/_generated/server.js | 101 + fixture/convex/auth.config.ts | 17 + fixture/convex/convex.config.ts | 5 + fixture/convex/inspection.ts | 52 + fixture/convex/probe/_generated/api.ts | 50 + fixture/convex/probe/_generated/component.ts | 31 + fixture/convex/probe/_generated/dataModel.ts | 60 + fixture/convex/probe/_generated/server.ts | 169 + fixture/convex/probe/convex.config.ts | 2 + fixture/convex/probe/inspection.ts | 26 + fixture/convex/probe/schema.ts | 3 + fixture/convex/schema.ts | 3 + fixture/package.json | 8 + harness/backend-release.json | 9 + harness/backend.ts | 380 ++ harness/clients.ts | 37 + harness/evidence.ts | 125 + harness/identity.ts | 34 + package-lock.json | 4503 ++++++++++++++++- package.json | 39 +- scripts/codegen.mjs | 10 + scripts/codegen.ts | 4 + ...identity-reaches-internal-function.test.ts | 67 + .../native/component-auth.observation.test.ts | 21 + ...xture-issuer-token-yields-identity.test.ts | 61 + ...-client-refused-component-function.test.ts | 52 + ...y-client-refused-internal-function.test.ts | 58 + tests/native/world.ts | 29 + tests/pure/identity.test.ts | 31 + .../simulator/boundaries.observation.test.ts | 39 + tests/types/api.test-d.ts | 16 + tsconfig.json | 23 + vitest.config.ts | 43 + 44 files changed, 6412 insertions(+), 99 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 eslint.config.js create mode 100644 evidence/README.md create mode 100644 fixture/convex/_generated/api.d.ts create mode 100644 fixture/convex/_generated/api.js create mode 100644 fixture/convex/_generated/dataModel.d.ts create mode 100644 fixture/convex/_generated/server.d.ts create mode 100644 fixture/convex/_generated/server.js create mode 100644 fixture/convex/auth.config.ts create mode 100644 fixture/convex/convex.config.ts create mode 100644 fixture/convex/inspection.ts create mode 100644 fixture/convex/probe/_generated/api.ts create mode 100644 fixture/convex/probe/_generated/component.ts create mode 100644 fixture/convex/probe/_generated/dataModel.ts create mode 100644 fixture/convex/probe/_generated/server.ts create mode 100644 fixture/convex/probe/convex.config.ts create mode 100644 fixture/convex/probe/inspection.ts create mode 100644 fixture/convex/probe/schema.ts create mode 100644 fixture/convex/schema.ts create mode 100644 fixture/package.json create mode 100644 harness/backend-release.json create mode 100644 harness/backend.ts create mode 100644 harness/clients.ts create mode 100644 harness/evidence.ts create mode 100644 harness/identity.ts create mode 100644 scripts/codegen.mjs create mode 100644 scripts/codegen.ts create mode 100644 tests/native/admin-key-acting-as-identity-reaches-internal-function.test.ts create mode 100644 tests/native/component-auth.observation.test.ts create mode 100644 tests/native/fixture-issuer-token-yields-identity.test.ts create mode 100644 tests/native/ordinary-client-refused-component-function.test.ts create mode 100644 tests/native/ordinary-client-refused-internal-function.test.ts create mode 100644 tests/native/world.ts create mode 100644 tests/pure/identity.test.ts create mode 100644 tests/simulator/boundaries.observation.test.ts create mode 100644 tests/types/api.test-d.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8af4b24 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI +on: + pull_request: + push: + branches: [main] +jobs: + design: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + - run: npm ci + - run: python3 design/tools/check.py + compiled: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run test:types + - run: npm run lint + - run: npm run format:check + pure-and-simulator: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run test:pure + - run: npm run test:simulator + native: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run test:native + - uses: actions/upload-artifact@v4 + if: always() + with: + name: native-evidence + path: evidence/runs/*.json diff --git a/.gitignore b/.gitignore index 646ac51..541528d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ -.DS_Store node_modules/ +.DS_Store +generated/ +.cache/ +evidence/runs/ +fixture/.env.local diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..32c9e15 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +design/ +docs/ +vendor/ +generated/ +**/_generated/ +evidence/ +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..e4774d7 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +{ "singleQuote": false, "trailingComma": "all" } diff --git a/README.md b/README.md index 181d083..dc05a43 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ This repository holds the design and, from the first build slice on, the code bu ```sh npm ci -npx sdp view design -open design/generated/design-review/index.md +npx sdp view +open generated/design-review/index.md ``` `python3 design/tools/check.py` validates the corpus and checks that the Specs, the index and the review ledger agree. @@ -23,3 +23,14 @@ open design/generated/design-review/index.md ## Status The design has been through three review rounds and carries open findings, all listed in `design/reviews/consensus-ledger.json`. It is being finished by building it, one slice at a time, starting with the repository layout and the probes. + +## Running the code + +Use Node 24 and run `npm ci` in the repository root. Each tier has its own command: + +- Compiled: `npm run typecheck` and `npm run test:types`. +- Pure: `npm run test:pure`. +- Simulator: `npm run test:simulator`, using `convex-test`. +- Native backend: `npm run test:native`, with a disposable backend per test. + +`npm test` runs the three tiers that need no backend. `npm run codegen` starts a disposable backend, deploys the fixture app, regenerates both Convex API directories and stops the backend. Native commands download the pinned backend into `.cache/`, or use the binary named by `CONVEX_BACKEND_BINARY`. The Linux asset hash must be filled in `harness/backend-release.json` before its first download. Native runs write evidence under `evidence/runs/`. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..8dc60b9 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,15 @@ +import tseslint from "typescript-eslint"; +import convex from "@convex-dev/eslint-plugin"; +export default tseslint.config( + { ignores: ["**/_generated/**", "generated/**"] }, + ...tseslint.configs.recommended, + ...convex.configs.recommended.map((config) => ({ + ...config, + files: ["fixture/convex/**/*.ts"], + })), + // The acceptance contract requires this deployment-time config to read process.env. + { + files: ["fixture/convex/auth.config.ts"], + rules: { "@convex-dev/no-process-env": "off" }, + }, +); diff --git a/evidence/README.md b/evidence/README.md new file mode 100644 index 0000000..e386e49 --- /dev/null +++ b/evidence/README.md @@ -0,0 +1,5 @@ +# Run evidence + +The native Vitest reporter writes one JSON record per invocation to `runs/`. These files are ignored by git. Each record names the commit, dirty tree, fixture composition, versions, configuration, dataset, identity source, command and test results. + +A test calls `recordMeasurement(testName, measurementName, value)` from `harness/evidence.ts` to add a measurement to its result. Use the Spec ID for a bound example, or the full test name for an observation. Big integers are stored as an object with a `bigint` string. diff --git a/fixture/convex/_generated/api.d.ts b/fixture/convex/_generated/api.d.ts new file mode 100644 index 0000000..e8c1d09 --- /dev/null +++ b/fixture/convex/_generated/api.d.ts @@ -0,0 +1,51 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as inspection from "../inspection.js"; + +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; + +declare const fullApi: ApiFromModules<{ + inspection: typeof inspection; +}>; + +/** + * A utility for referencing Convex functions in your app's public API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export declare const api: FilterApi< + typeof fullApi, + FunctionReference +>; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ +export declare const internal: FilterApi< + typeof fullApi, + FunctionReference +>; + +export declare const components: { + probe: import("../probe/_generated/component.js").ComponentApi<"probe">; +}; diff --git a/fixture/convex/_generated/api.js b/fixture/convex/_generated/api.js new file mode 100644 index 0000000..44bf985 --- /dev/null +++ b/fixture/convex/_generated/api.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { anyApi, componentsGeneric } from "convex/server"; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api = anyApi; +export const internal = anyApi; +export const components = componentsGeneric(); diff --git a/fixture/convex/_generated/dataModel.d.ts b/fixture/convex/_generated/dataModel.d.ts new file mode 100644 index 0000000..f97fd19 --- /dev/null +++ b/fixture/convex/_generated/dataModel.d.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; diff --git a/fixture/convex/_generated/server.d.ts b/fixture/convex/_generated/server.d.ts new file mode 100644 index 0000000..f235db4 --- /dev/null +++ b/fixture/convex/_generated/server.d.ts @@ -0,0 +1,162 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +type Env = { + readonly CONVEX_CLOUD_URL: string; + readonly CONVEX_SITE_URL: string; +}; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const query: QueryBuilder; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const internalQuery: QueryBuilder; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const mutation: MutationBuilder; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const internalMutation: MutationBuilder; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export declare const action: ActionBuilder; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export declare const internalAction: ActionBuilder; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export declare const httpAction: HttpActionBuilder; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +export declare const env: Env; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * This differs from the {@link MutationCtx} because all of the services are + * read-only. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/fixture/convex/_generated/server.js b/fixture/convex/_generated/server.js new file mode 100644 index 0000000..2dbe0db --- /dev/null +++ b/fixture/convex/_generated/server.js @@ -0,0 +1,101 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery = internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation = internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction = internalActionGeneric; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export const httpAction = httpActionGeneric; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +export const env = process.env; diff --git a/fixture/convex/auth.config.ts b/fixture/convex/auth.config.ts new file mode 100644 index 0000000..235ed84 --- /dev/null +++ b/fixture/convex/auth.config.ts @@ -0,0 +1,17 @@ +import type { AuthConfig } from "convex/server"; +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name}`); + return value; +} +export default { + providers: [ + { + type: "customJwt", + algorithm: "ES256", + issuer: required("AUTH_ISSUER"), + applicationID: required("AUTH_APPLICATION_ID"), + jwks: required("AUTH_JWKS"), + }, + ], +} satisfies AuthConfig; diff --git a/fixture/convex/convex.config.ts b/fixture/convex/convex.config.ts new file mode 100644 index 0000000..75f31ca --- /dev/null +++ b/fixture/convex/convex.config.ts @@ -0,0 +1,5 @@ +import { defineApp } from "convex/server"; +import probe from "./probe/convex.config.js"; +const app = defineApp(); +app.use(probe); +export default app; diff --git a/fixture/convex/inspection.ts b/fixture/convex/inspection.ts new file mode 100644 index 0000000..4fe3e11 --- /dev/null +++ b/fixture/convex/inspection.ts @@ -0,0 +1,52 @@ +import { ConvexError, v } from "convex/values"; +import { components, internal } from "./_generated/api.js"; +import { internalMutation, mutation, query } from "./_generated/server.js"; +export const identity = query({ + args: {}, + returns: v.any(), + handler: (ctx) => ctx.auth.getUserIdentity(), +}); +export const componentIdentity = query({ + args: {}, + returns: v.any(), + handler: (ctx) => ctx.runQuery(components.probe.inspection.identity, {}), +}); +export const writeInternal = internalMutation({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + await ctx.db.insert("writes", { marker: "internal" }); + return null; + }, +}); +export const failInternal = internalMutation({ + args: {}, + returns: v.null(), + handler: async () => { + throw new ConvexError({ + code: "fixtureFailure", + details: { expected: 2, current: 3 }, + }); + }, +}); +export const catchFailure = mutation({ + args: { boundary: v.union(v.literal("nested"), v.literal("component")) }, + returns: v.any(), + handler: async (ctx, { boundary }) => { + try { + await ctx.runMutation( + boundary === "nested" + ? internal.inspection.failInternal + : components.probe.inspection.fail, + {}, + ); + } catch (error) { + return { + isConvexError: error instanceof ConvexError, + data: error instanceof ConvexError ? error.data : null, + message: String(error), + }; + } + return null; + }, +}); diff --git a/fixture/convex/probe/_generated/api.ts b/fixture/convex/probe/_generated/api.ts new file mode 100644 index 0000000..d3c4d01 --- /dev/null +++ b/fixture/convex/probe/_generated/api.ts @@ -0,0 +1,50 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as inspection from "../inspection.js"; + +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; +import { anyApi, componentsGeneric } from "convex/server"; + +const fullApi: ApiFromModules<{ + inspection: typeof inspection; +}> = anyApi as any; + +/** + * A utility for referencing Convex functions in your app's public API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api: FilterApi< + typeof fullApi, + FunctionReference +> = anyApi as any; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ +export const internal: FilterApi< + typeof fullApi, + FunctionReference +> = anyApi as any; + +export const components = componentsGeneric() as unknown as {}; diff --git a/fixture/convex/probe/_generated/component.ts b/fixture/convex/probe/_generated/component.ts new file mode 100644 index 0000000..2491b10 --- /dev/null +++ b/fixture/convex/probe/_generated/component.ts @@ -0,0 +1,31 @@ +/* eslint-disable */ +/** + * Generated `ComponentApi` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { FunctionReference } from "convex/server"; + +/** + * A utility for referencing a Convex component's exposed API. + * + * Useful when expecting a parameter like `components.myComponent`. + * Usage: + * ```ts + * async function myFunction(ctx: QueryCtx, component: ComponentApi) { + * return ctx.runQuery(component.someFile.someQuery, { ...args }); + * } + * ``` + */ +export type ComponentApi = + { + inspection: { + fail: FunctionReference<"mutation", "internal", {}, null, Name>; + identity: FunctionReference<"query", "internal", {}, any, Name>; + write: FunctionReference<"mutation", "internal", {}, null, Name>; + }; + }; diff --git a/fixture/convex/probe/_generated/dataModel.ts b/fixture/convex/probe/_generated/dataModel.ts new file mode 100644 index 0000000..f97fd19 --- /dev/null +++ b/fixture/convex/probe/_generated/dataModel.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; diff --git a/fixture/convex/probe/_generated/server.ts b/fixture/convex/probe/_generated/server.ts new file mode 100644 index 0000000..1a7688a --- /dev/null +++ b/fixture/convex/probe/_generated/server.ts @@ -0,0 +1,169 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} from "convex/server"; +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +type Env = { + readonly CONVEX_CLOUD_URL: string; + readonly CONVEX_SITE_URL: string; +}; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query: QueryBuilder = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery: QueryBuilder = + internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation: MutationBuilder = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation: MutationBuilder = + internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action: ActionBuilder = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction: ActionBuilder = + internalActionGeneric; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export const httpAction: HttpActionBuilder = httpActionGeneric; +export const env: Env = (globalThis as unknown as { process: { env: Env } }) + .process.env; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + * + * If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/fixture/convex/probe/convex.config.ts b/fixture/convex/probe/convex.config.ts new file mode 100644 index 0000000..284b25d --- /dev/null +++ b/fixture/convex/probe/convex.config.ts @@ -0,0 +1,2 @@ +import { defineComponent } from "convex/server"; +export default defineComponent("probe"); diff --git a/fixture/convex/probe/inspection.ts b/fixture/convex/probe/inspection.ts new file mode 100644 index 0000000..2cf75c9 --- /dev/null +++ b/fixture/convex/probe/inspection.ts @@ -0,0 +1,26 @@ +import { ConvexError, v } from "convex/values"; +import { mutation, query } from "./_generated/server.js"; +export const write = mutation({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + await ctx.db.insert("writes", { marker: "component" }); + return null; + }, +}); +export const fail = mutation({ + args: {}, + returns: v.null(), + handler: async () => { + throw new ConvexError({ + code: "fixtureFailure", + details: { expected: 2, current: 3 }, + }); + }, +}); +// An observation on one release, not a platform promise about component auth. +export const identity = query({ + args: {}, + returns: v.any(), + handler: (ctx) => ctx.auth.getUserIdentity(), +}); diff --git a/fixture/convex/probe/schema.ts b/fixture/convex/probe/schema.ts new file mode 100644 index 0000000..efe95f4 --- /dev/null +++ b/fixture/convex/probe/schema.ts @@ -0,0 +1,3 @@ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; +export default defineSchema({ writes: defineTable({ marker: v.string() }) }); diff --git a/fixture/convex/schema.ts b/fixture/convex/schema.ts new file mode 100644 index 0000000..efe95f4 --- /dev/null +++ b/fixture/convex/schema.ts @@ -0,0 +1,3 @@ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; +export default defineSchema({ writes: defineTable({ marker: v.string() }) }); diff --git a/fixture/package.json b/fixture/package.json new file mode 100644 index 0000000..5d36fc1 --- /dev/null +++ b/fixture/package.json @@ -0,0 +1,8 @@ +{ + "name": "libar-platform-fixture", + "private": true, + "type": "module", + "devDependencies": { + "convex": "1.46.0" + } +} diff --git a/harness/backend-release.json b/harness/backend-release.json new file mode 100644 index 0000000..dcc81cb --- /dev/null +++ b/harness/backend-release.json @@ -0,0 +1,9 @@ +{ + "release": "precompiled-2026-09-28-5c7cb5b", + "assets": { + "convex-local-backend-aarch64-apple-darwin.zip": "c1613bb4a3ac884871184020662c865a045cde95ad781fff002a6bd25f2f2890", + "convex-local-backend-x86_64-unknown-linux-gnu.zip": "5b4adbe9fc2f9985c66eb12b19dadedf2700d8796c839ddba09802196099f241", + "convex-local-backend-aarch64-unknown-linux-gnu.zip": "d13679e5abb77637218547b8109ec903ae5e9a276addb2bec438d54457a6772c", + "convex-local-backend-x86_64-apple-darwin.zip": "8ca9b573069b504ce533394bb126ba2c88529f260138f54171d8f4b39b9ebe3b" + } +} diff --git a/harness/backend.ts b/harness/backend.ts new file mode 100644 index 0000000..3e646aa --- /dev/null +++ b/harness/backend.ts @@ -0,0 +1,380 @@ +import { spawn, execFile as execFileCallback } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import { promisify } from "node:util"; +import { createServer } from "node:net"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, + access, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; +import { createIdentity } from "./identity.js"; +import type { FixtureIdentity } from "./identity.js"; + +const execFile = promisify(execFileCallback); +export const root = fileURLToPath(new URL("../", import.meta.url)); +const cli = join(root, "node_modules/convex/bin/main.js"); +export interface BackendRelease { + release: string; + assets: Record; +} +export async function backendRelease(): Promise { + return JSON.parse( + await readFile(join(root, "harness/backend-release.json"), "utf8"), + ) as BackendRelease; +} +export async function backendBinary(): Promise { + if (process.env.CONVEX_BACKEND_BINARY) + return resolve(process.env.CONVEX_BACKEND_BINARY); + const asset = + process.platform === "darwin" && process.arch === "arm64" + ? "convex-local-backend-aarch64-apple-darwin.zip" + : process.platform === "linux" && process.arch === "x64" + ? "convex-local-backend-x86_64-unknown-linux-gnu.zip" + : undefined; + if (!asset) + throw new Error( + `Unsupported backend platform: ${process.platform}/${process.arch}`, + ); + const release = await backendRelease(); + const hash = release.assets[asset]; + if (!hash) + throw new Error( + `Missing SHA-256 for backend asset ${asset}; the orchestrator must supply it in harness/backend-release.json`, + ); + const cache = join(root, ".cache", release.release, asset); + const binary = join(cache, "convex-local-backend"); + try { + await access(binary); + return binary; + } catch { + /* download below */ + } + await mkdir(cache, { recursive: true }); + // Each downloader extracts in its own directory. Parallel tests never see a partial binary. + const work = await mkdtemp(join(cache, "download-")); + try { + const response = await fetch( + `https://github.com/get-convex/convex-backend/releases/download/${release.release}/${asset}`, + ); + if (!response.ok) + throw new Error(`Backend download failed: ${response.status} ${asset}`); + const bytes = Buffer.from(await response.arrayBuffer()); + if (createHash("sha256").update(bytes).digest("hex") !== hash) + throw new Error(`SHA-256 mismatch for ${asset}`); + const zip = join(work, "backend.zip"); + await writeFile(zip, bytes); + await execFile("unzip", ["-q", zip, "-d", work]); + const extracted = join(work, "convex-local-backend"); + await chmod(extracted, 0o755); + const { copyFile, rename } = await import("node:fs/promises"); + const staging = join(cache, `binary-${randomUUID()}`); + await copyFile(extracted, staging); + await chmod(staging, 0o755); + await rename(staging, binary); + return binary; + } finally { + await rm(work, { recursive: true, force: true }); + } +} +async function freePort(): Promise { + const server = createServer(); + await new Promise((ok, fail) => { + server.once("error", fail); + server.listen(0, "127.0.0.1", ok); + }); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("No allocated TCP port"); + await new Promise((ok, fail) => + server.close((error) => (error ? fail(error) : ok())), + ); + return address.port; +} +function exited(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) + return Promise.resolve(); + return new Promise((ok, fail) => { + child.once("exit", () => ok()); + child.once("error", fail); + }); +} +export interface FunctionLogRecord { + identifier?: string; + componentPath?: string; + executionTime?: number; + usageStats?: unknown; + [key: string]: unknown; +} +export class Backend { + private child?: ChildProcess; + private output = ""; + private logProcesses = new Set(); + private constructor( + readonly binary: string, + readonly directory: string, + readonly port: number, + readonly sitePort: number, + readonly instanceName: string, + private readonly secret: string, + readonly adminKey: string, + readonly identity: FixtureIdentity, + ) {} + get url() { + return `http://127.0.0.1:${this.port}`; + } + get environment() { + return { + ...process.env, + CONVEX_SELF_HOSTED_URL: this.url, + CONVEX_SELF_HOSTED_ADMIN_KEY: this.adminKey, + }; + } + static async start(issuer?: string): Promise { + const binary = await backendBinary(); + const directory = await mkdtemp(join(tmpdir(), "libar-backend-")); + try { + const port = await freePort(); + let sitePort = await freePort(); + while (sitePort === port) sitePort = await freePort(); + const name = `fixture-${randomUUID()}`; + const secret = randomBytes(32).toString("hex"); + const { stdout } = await execFile(binary, [ + "keygen", + "admin-key", + "--instance-name", + name, + "--instance-secret", + secret, + ]); + const backend = new Backend( + binary, + directory, + port, + sitePort, + name, + secret, + stdout.trim(), + await createIdentity(issuer), + ); + try { + await backend.restart(); + return backend; + } catch (error) { + await backend.stop(); + throw error; + } + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } + } + async restart(): Promise { + if ( + this.child && + this.child.exitCode === null && + this.child.signalCode === null + ) + throw new Error("Backend is already running"); + this.output = ""; + const child = spawn( + this.binary, + [ + "--port", + String(this.port), + "--site-proxy-port", + String(this.sitePort), + "--interface", + "127.0.0.1", + "--instance-name", + this.instanceName, + "--instance-secret", + this.secret, + "--local-storage", + join(this.directory, "storage"), + join(this.directory, "backend.sqlite3"), + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + this.child = child; + let spawnError: Error | undefined; + child.on("error", (error) => { + spawnError = error; + }); + child.stdout?.on("data", (chunk: Buffer) => { + this.output = (this.output + chunk.toString()).slice(-16000); + }); + child.stderr?.on("data", (chunk: Buffer) => { + this.output = (this.output + chunk.toString()).slice(-16000); + }); + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + if (spawnError) throw spawnError; + if (child.exitCode !== null || child.signalCode !== null) + throw new Error(`Backend exited before /version: ${this.output}`); + try { + if ( + ( + await fetch(`${this.url}/version`, { + signal: AbortSignal.timeout(1000), + }) + ).ok + ) + return; + } catch { + /* not ready */ + } + await delay(50); + } + throw new Error(`Backend /version timed out: ${this.output}`); + } + async command(args: string[]): Promise { + const { stdout } = await execFile(process.execPath, [cli, ...args], { + cwd: join(root, "fixture"), + env: this.environment, + maxBuffer: 16 * 1024 * 1024, + }); + return stdout; + } + async deploy(): Promise { + const envFile = join(this.directory, "auth.env"); + await writeFile( + envFile, + `AUTH_ISSUER=${this.identity.issuer}\nAUTH_APPLICATION_ID=${this.identity.applicationID}\nAUTH_JWKS=${this.identity.jwks}\n`, + { mode: 0o600 }, + ); + await this.command(["env", "set", "--from-file", envFile]); + await this.command([ + "deploy", + "-y", + "--codegen", + "disable", + "--typecheck", + "disable", + ]); + } + async readTable(table: string, componentPath?: string): Promise { + const component = componentPath ? ["--component", componentPath] : []; + const output = await this.command([ + "data", + table, + "--format", + "json", + ...component, + ]); + if (output.trim()) return JSON.parse(output) as unknown[]; + // The CLI prints nothing for an empty table and nothing for a table that does not exist. + const tables = (await this.command(["data", ...component])).split("\n"); + if (!tables.includes(table)) + throw new Error( + `No table ${table} in ${componentPath ?? "the app"}: ${tables.join(" ")}`, + ); + return []; + } + logs() { + const records: FunctionLogRecord[] = []; + const errors: string[] = []; + const child = spawn( + process.execPath, + [cli, "logs", "--success", "--jsonl"], + { + cwd: join(root, "fixture"), + env: this.environment, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + this.logProcesses.add(child); + let pending = ""; + child.on("error", (error) => errors.push(String(error))); + child.stderr?.on("data", (chunk: Buffer) => errors.push(chunk.toString())); + child.stdout?.on("data", (chunk: Buffer) => { + pending += chunk.toString(); + const lines = pending.split("\n"); + pending = lines.pop() ?? ""; + for (const line of lines) { + if (!line.trim()) continue; + try { + records.push(JSON.parse(line) as FunctionLogRecord); + } catch { + errors.push(`Invalid function log JSON: ${line}`); + } + } + }); + return { + records, + errors, + async waitFor( + predicate: (record: FunctionLogRecord) => boolean, + timeout = 10000, + ) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const record = records.find(predicate); + if (record) return record; + if (child.exitCode !== null || child.signalCode !== null) + throw new Error(`Function log stream exited: ${errors.join("\n")}`); + await delay(25); + } + throw new Error(`Function log record timed out: ${errors.join("\n")}`); + }, + stop: async () => { + child.kill("SIGINT"); + await exited(child); + this.logProcesses.delete(child); + }, + }; + } + async kill(): Promise { + if (this.child) { + this.child.kill("SIGKILL"); + await exited(this.child); + } + } + async stop(): Promise { + for (const child of this.logProcesses) { + child.kill("SIGINT"); + await exited(child); + } + this.logProcesses.clear(); + if (this.child) { + const child = this.child; + child.kill("SIGINT"); + const timer = setTimeout(() => child.kill("SIGKILL"), 5000); + try { + await exited(child); + } finally { + clearTimeout(timer); + } + } + await rm(this.directory, { recursive: true, force: true }); + } +} +export async function startFixture(issuer?: string): Promise { + const backend = await Backend.start(issuer); + try { + await backend.deploy(); + return backend; + } catch (error) { + await backend.stop(); + throw error; + } +} +export async function withBackend( + run: (backend: Backend) => Promise, + issuer?: string, +): Promise { + const backend = await startFixture(issuer); + try { + return await run(backend); + } finally { + await backend.stop(); + } +} diff --git a/harness/clients.ts b/harness/clients.ts new file mode 100644 index 0000000..f874aea --- /dev/null +++ b/harness/clients.ts @@ -0,0 +1,37 @@ +import { ConvexClient, ConvexHttpClient } from "convex/browser"; +import type { UserIdentity } from "convex/server"; +// Convex 1.46.0 implements these methods but strips them from its public declarations. +// Keep the escape hatch limited to the native harness. +import type { + FunctionReference, + FunctionArgs, + FunctionReturnType, + UserIdentityAttributes, +} from "convex/server"; +export type HarnessHttpClient = ConvexHttpClient & { + setAdminAuth(key: string, identity?: UserIdentityAttributes): void; + function>( + name: F | string, + componentPath: string | undefined, + args: FunctionArgs, + ): Promise>; +}; +export function httpClient(url: string, token?: string) { + const client = new ConvexHttpClient(url) as HarnessHttpClient; + if (token) client.setAuth(token); + return client; +} +export function websocketClient(url: string, token?: string) { + const client = new ConvexClient(url); + if (token) client.setAuth(async () => token); + return client; +} +export function adminClient( + url: string, + adminKey: string, + identity?: UserIdentity, +) { + const client = new ConvexHttpClient(url) as HarnessHttpClient; + client.setAdminAuth(adminKey, identity); + return client; +} diff --git a/harness/evidence.ts b/harness/evidence.ts new file mode 100644 index 0000000..9cc67d8 --- /dev/null +++ b/harness/evidence.ts @@ -0,0 +1,125 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import type { Reporter, TestCase } from "vitest/node"; +import { backendRelease, root } from "./backend.js"; +import { fixtureIssuer } from "./identity.js"; +const runID = process.env.NATIVE_RUN_ID ?? randomUUID(); +process.env.NATIVE_RUN_ID = runID; +function encode(_key: string, value: unknown) { + return typeof value === "bigint" ? { bigint: value.toString() } : value; +} +export async function recordMeasurement( + test: string, + name: string, + value: unknown, +): Promise { + const directory = join( + root, + "evidence/runs", + `${process.env.NATIVE_RUN_ID ?? runID}.measurements`, + ); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, `${randomUUID()}.json`), + JSON.stringify({ test, name, value }, encode), + ); +} +export default class EvidenceReporter implements Reporter { + private nativeRun = false; + onTestRunStart(...args: Parameters>) { + this.nativeRun = args[0].some( + (specification) => specification.project.name === "native", + ); + } + private tests: { name: string; result: unknown }[] = []; + onTestCaseResult(test: TestCase) { + if (test.project.name === "native") + this.tests.push({ name: test.fullName, result: test.result() }); + } + async onTestRunEnd( + ...args: Parameters> + ) { + if (!this.nativeRun) return; + const [, unhandledErrors, reason] = args; + const directory = join(root, "evidence/runs"); + await mkdir(directory, { recursive: true }); + const measurementsDirectory = join(directory, `${runID}.measurements`); + let measurements: { test: string; name: string; value: unknown }[] = []; + try { + measurements = await Promise.all( + (await readdir(measurementsDirectory)).map( + async (file) => + JSON.parse( + await readFile(join(measurementsDirectory, file), "utf8"), + ) as { test: string; name: string; value: unknown }, + ), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const packages = [ + "convex", + "convex-test", + "convex-helpers", + "vitest", + "@typescript/native", + ]; + const versions = Object.fromEntries( + await Promise.all( + packages.map(async (name) => { + const metadata = JSON.parse( + await readFile( + join(root, "node_modules", name, "package.json"), + "utf8", + ), + ) as { version: string }; + return [ + name === "@typescript/native" ? "TypeScript" : name, + metadata.version, + ]; + }), + ), + ); + const record = { + tier: "native backend", + reason, + unhandledErrors, + commit: execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + }).trim(), + dirty: !!execFileSync("git", ["status", "--porcelain"], { + cwd: root, + encoding: "utf8", + }).trim(), + installedLayers: [], + composition: "fixture", + backend: { + ...(await backendRelease()), + binaryOverride: process.env.CONVEX_BACKEND_BINARY ?? null, + }, + versions: { ...versions, Node: process.version }, + configuration: { + adjustments: { identitySource: fixtureIssuer }, + description: + "Fixture composition only; no platform layer or production composition exists yet.", + }, + dataset: "Fresh empty storage per test; only rows written by that test.", + identitySource: fixtureIssuer, + command: process.env.npm_lifecycle_script ?? process.argv.join(" "), + tests: this.tests.map((test) => ({ + ...test, + measurements: measurements.filter((measurement) => + test.name.includes(measurement.test), + ), + })), + }; + await writeFile( + join(directory, `${runID}.json`), + JSON.stringify(record, encode, 2) + "\n", + ); + await rm(measurementsDirectory, { recursive: true, force: true }); + } +} diff --git a/harness/identity.ts b/harness/identity.ts new file mode 100644 index 0000000..b219738 --- /dev/null +++ b/harness/identity.ts @@ -0,0 +1,34 @@ +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +export const fixtureIssuer = "https://fixture-issuer.test"; +export const applicationID = "fixture-app"; +export async function createIdentity(issuer = fixtureIssuer) { + const { publicKey, privateKey } = await generateKeyPair("ES256", { + extractable: true, + }); + const kid = "fixture-key-1"; + const jwk = { + ...(await exportJWK(publicKey)), + kid, + alg: "ES256", + use: "sig", + }; + const jwks = + "data:text/plain;charset=utf-8;base64," + + Buffer.from(JSON.stringify({ keys: [jwk] })).toString("base64"); + return { + issuer, + applicationID, + jwks, + async token(subject: string) { + return new SignJWT({}) + .setProtectedHeader({ kid, alg: "ES256", typ: "JWT" }) + .setIssuer(issuer) + .setAudience(applicationID) + .setSubject(subject) + .setIssuedAt() + .setExpirationTime("10m") + .sign(privateKey); + }, + }; +} +export type FixtureIdentity = Awaited>; diff --git a/package-lock.json b/package-lock.json index ab6f273..4a4e91e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,104 @@ "name": "libar-platform", "version": "0.0.0", "devDependencies": { - "@libar-dev/software-delivery-protocol": "file:vendor/libar-dev-software-delivery-protocol-0.0.0-5993da7.tgz" + "@convex-dev/eslint-plugin": "5.0.0", + "@edge-runtime/vm": "5.0.0", + "@eslint/js": "10.0.1", + "@libar-dev/software-delivery-protocol": "file:vendor/libar-dev-software-delivery-protocol-0.0.0-5993da7.tgz", + "@testing-library/react": "16.3.3", + "@types/node": "24.19.0", + "@types/react": "19.3.0", + "@typescript/native": "npm:typescript@7.0.2", + "convex": "1.46.0", + "convex-helpers": "0.1.124", + "convex-test": "0.0.60", + "eslint": "10.11.0", + "happy-dom": "20.14.5", + "jose": "6.2.12", + "prettier": "3.9.9", + "react": "19.3.0", + "react-dom": "19.3.0", + "typescript": "npm:@typescript/typescript6@6.0.2", + "typescript-eslint": "8.71.0", + "vitest": "5.0.3" }, "engines": { - "node": ">=22" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@convex-dev/eslint-plugin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@convex-dev/eslint-plugin/-/eslint-plugin-5.0.0.tgz", + "integrity": "sha512-UFhZ7bJBo4mTfa829/mntoAwyC9aKwXe6dxCV7PianSdAfFIbdCwCjWPBmhqn1hNlpqhVU385C/vjL8CXfxA2Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@typescript-eslint/types": "~8.58.0", + "@typescript-eslint/utils": "~8.58.0" + }, + "peerDependencies": { + "convex": "^1.43.0" } }, "node_modules/@cucumber/gherkin": { @@ -21,165 +115,4379 @@ "dev": true, "license": "MIT", "dependencies": { - "@cucumber/messages": ">=34.0.0 <35" + "@cucumber/messages": ">=34.0.0 <35" + } + }, + "node_modules/@cucumber/messages": { + "version": "34.2.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-34.2.1.tgz", + "integrity": "sha512-aj2iCAG9ZOpMVcMxcgShifv5fbR96b7SMgdWcPXXh2QbFp7OEcAnQ5MC5YfWUDSDasmw7FU5NJEsOrXdTQOU1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@edge-runtime/primitives": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-6.0.0.tgz", + "integrity": "sha512-FqoxaBT+prPBHBwE1WXS1ocnu/VLTQyZ6NMUBAdbP7N2hsFTTxMC/jMu2D/8GAlMQfxeuppcPuCUk/HO3fpIvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@edge-runtime/vm": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/vm/-/vm-5.0.0.tgz", + "integrity": "sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@edge-runtime/primitives": "6.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@libar-dev/software-delivery-protocol": { + "version": "0.0.0", + "resolved": "file:vendor/libar-dev-software-delivery-protocol-0.0.0-5993da7.tgz", + "integrity": "sha512-NR0Cyq7AIepjToh1uR/2D4BwmCGDDWeIHXDh2yb60+sBvdRpdYZTrOmBs/p0S2HPxnpZzSLxtP/fkfoLoW48ng==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@cucumber/gherkin": "42.0.1", + "@cucumber/messages": "34.2.1", + "ts-morph": "~28.0.0", + "yaml": "2.9.0" + }, + "bin": { + "sdp": "dist/cli/sdp.js" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "vitest": ">=2" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@ts-morph/common": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz", + "integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript/native": { + "name": "typescript", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/@typescript/old": { + "name": "typescript", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convex": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.46.0.tgz", + "integrity": "sha512-dhBwDQTZDOhi5SUGAX17EWNehCdNq6/UFrSL0eLZuGfMkdsQbCVWVRZCzbZ5CF+/pgvoGxtApbEoTSipqL4JTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esbuild": "0.27.0", + "prettier": "^3.0.0", + "ws": "8.21.0" + }, + "bin": { + "convex": "bin/main.js" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@auth0/auth0-react": "^2.0.1", + "@clerk/clerk-react": "^4.12.8 || ^5.0.0", + "@clerk/react": "^6.4.3", + "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@auth0/auth0-react": { + "optional": true + }, + "@clerk/clerk-react": { + "optional": true + }, + "@clerk/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/convex-helpers": { + "version": "0.1.124", + "resolved": "https://registry.npmjs.org/convex-helpers/-/convex-helpers-0.1.124.tgz", + "integrity": "sha512-Gbu/jq6I3x5Y51cXP5c7+CI3t15vX5Xi2G0tOym3rxpBtYqrI+qWwxkqZgJYrHHccY42wZudB8h6kS3fak8Aeg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "convex-helpers": "bin.cjs" + }, + "peerDependencies": { + "@standard-schema/spec": "^1.0.0", + "convex": "^1.43.0", + "hono": "^4.0.5", + "react": "^17.0.2 || ^18.0.0 || ^19.0.0", + "typescript": "^5.5 || ^6.0.0 || ^7.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@standard-schema/spec": { + "optional": true + }, + "hono": { + "optional": true + }, + "react": { + "optional": true + }, + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/convex-test": { + "version": "0.0.60", + "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.60.tgz", + "integrity": "sha512-qCAFx+CO6bmpXNc9s1jZvw8NJPS4yM074ib87PUY675FsiaRRW4ALotEB+RbVCYDRMYxh623KMnYZBlrMJIv1Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "convex": "^1.43.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/happy-dom": { + "version": "20.14.5", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz", + "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/@cucumber/messages": { - "version": "34.2.1", - "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-34.2.1.tgz", - "integrity": "sha512-aj2iCAG9ZOpMVcMxcgShifv5fbR96b7SMgdWcPXXh2QbFp7OEcAnQ5MC5YfWUDSDasmw7FU5NJEsOrXdTQOU1A==", + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } }, - "node_modules/@libar-dev/software-delivery-protocol": { - "version": "0.0.0", - "resolved": "file:vendor/libar-dev-software-delivery-protocol-0.0.0-5993da7.tgz", - "integrity": "sha512-NR0Cyq7AIepjToh1uR/2D4BwmCGDDWeIHXDh2yb60+sBvdRpdYZTrOmBs/p0S2HPxnpZzSLxtP/fkfoLoW48ng==", + "node_modules/ts-morph": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.29.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "name": "@typescript/typescript6", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript6/-/typescript6-6.0.2.tgz", + "integrity": "sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@cucumber/gherkin": "42.0.1", - "@cucumber/messages": "34.2.1", - "ts-morph": "~28.0.0", - "yaml": "2.9.0" + "@typescript/old": "npm:typescript@^6" }, "bin": { - "sdp": "dist/cli/sdp.js" + "tsc6": "bin/tsc6" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" }, "engines": { - "node": ">=20" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependencies": { - "vitest": ">=2" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "vitest": { - "optional": true - } + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@ts-morph/common": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", - "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", + "node_modules/typescript-eslint/node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", "dev": true, "license": "MIT", "dependencies": { - "minimatch": "^10.0.1", - "path-browserify": "^1.0.1", - "tinyglobby": "^0.2.14" + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, "engines": { - "node": "18 || 20 || >=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "node_modules/typescript-eslint/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, "engines": { - "node": "20 || >=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/code-block-writer": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", - "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": ">=12.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "picomatch": "^3 || ^4" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.8" + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" }, "engines": { - "node": ">=12.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } } }, - "node_modules/ts-morph": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", - "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", "dependencies": { - "@ts-morph/common": "~0.29.0", - "code-block-writer": "^13.0.3" + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/yaml": { @@ -197,6 +4505,19 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 885f062..9ae9d41 100644 --- a/package.json +++ b/package.json @@ -5,16 +5,45 @@ "type": "module", "description": "The Convex transactional domain platform: its design as a Software Delivery Protocol corpus, and the code built from it.", "engines": { - "node": ">=22" + "node": "^24.0.0" }, "scripts": { "sdp": "sdp", "check": "python3 design/tools/check.py", - "validate": "sdp validate design", - "view": "sdp view design", - "census": "sdp census design" + "validate": "sdp validate", + "view": "sdp view", + "census": "sdp census", + "sdp:build": "npx sdp build", + "typecheck": "npm run sdp:build && tsc", + "lint": "eslint fixture harness tests scripts vitest.config.ts eslint.config.js", + "format:check": "prettier --check fixture harness tests scripts evidence README.md package.json tsconfig.json vitest.config.ts eslint.config.js .github/workflows/ci.yml .prettierrc.json", + "test:types": "npm run sdp:build && vitest run --project types", + "test:pure": "vitest run --project pure", + "test:simulator": "vitest run --project simulator", + "test:native": "npm run sdp:build && vitest run --project native", + "codegen": "node scripts/codegen.mjs", + "test": "npm run sdp:build && vitest run --project types --project pure --project simulator" }, "devDependencies": { - "@libar-dev/software-delivery-protocol": "file:vendor/libar-dev-software-delivery-protocol-0.0.0-5993da7.tgz" + "@convex-dev/eslint-plugin": "5.0.0", + "@edge-runtime/vm": "5.0.0", + "@eslint/js": "10.0.1", + "@libar-dev/software-delivery-protocol": "file:vendor/libar-dev-software-delivery-protocol-0.0.0-5993da7.tgz", + "@testing-library/react": "16.3.3", + "@types/node": "24.19.0", + "@types/react": "19.3.0", + "@typescript/native": "npm:typescript@7.0.2", + "convex": "1.46.0", + "convex-helpers": "0.1.124", + "convex-test": "0.0.60", + "eslint": "10.11.0", + "happy-dom": "20.14.5", + "jose": "6.2.12", + "prettier": "3.9.9", + "react": "19.3.0", + "react-dom": "19.3.0", + "typescript": "npm:@typescript/typescript6@6.0.2", + "typescript-eslint": "8.71.0", + "vitest": "5.0.3" } } diff --git a/scripts/codegen.mjs b/scripts/codegen.mjs new file mode 100644 index 0000000..fcafe31 --- /dev/null +++ b/scripts/codegen.mjs @@ -0,0 +1,10 @@ +import { createServer } from "vite"; +const server = await createServer({ + configFile: false, + server: { middlewareMode: true }, +}); +try { + await server.ssrLoadModule("/scripts/codegen.ts"); +} finally { + await server.close(); +} diff --git a/scripts/codegen.ts b/scripts/codegen.ts new file mode 100644 index 0000000..0f2b109 --- /dev/null +++ b/scripts/codegen.ts @@ -0,0 +1,4 @@ +import { withBackend } from "../harness/backend.js"; +await withBackend(async (backend) => { + await backend.command(["codegen", "--typecheck", "disable"]); +}); diff --git a/tests/native/admin-key-acting-as-identity-reaches-internal-function.test.ts b/tests/native/admin-key-acting-as-identity-reaches-internal-function.test.ts new file mode 100644 index 0000000..142e568 --- /dev/null +++ b/tests/native/admin-key-acting-as-identity-reaches-internal-function.test.ts @@ -0,0 +1,67 @@ +import { makeFunctionReference } from "convex/server"; +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { adminClient } from "../../harness/clients.js"; +import { backend, client, fixture } from "./world.js"; +import type { World } from "./world.js"; +import { adminKeyActingAsIdentityReachesInternalFunctionContract } from "../../generated/contracts/platform.acceptance-contract.admin-key-acting-as-identity-reaches-internal-function.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:platform.admin-key-acting-as-identity-reaches-internal-function", + ), + verifies: ref( + "spec:platform.acceptance-contract.admin-key-acting-as-identity-reaches-internal-function", + ), +}); +void anchor; +bindExample( + adminKeyActingAsIdentityReachesInternalFunctionContract, + (): World => ({}), + { + "a disposable backend running the fixture composition, which registers an internal mutation that writes one row": + (world) => fixture(world), + "a client holding the admin key and acting as the identity with subject {subject}": + (world, { subject }) => { + const issuer = backend(world).identity.issuer; + world.identity = { + issuer, + subject, + tokenIdentifier: `${issuer}|${subject}`, + }; + world.client = adminClient( + backend(world).url, + backend(world).adminKey, + world.identity, + ); + }, + "the client calls the internal mutation by name": async (world) => { + try { + world.result = await client(world).mutation( + makeFunctionReference<"mutation">("inspection:writeInternal"), + {}, + ); + } catch (error) { + world.error = error; + } + }, + "Convex runs the call {runs}": async (world, { runs }) => { + expect(world.error === undefined && world.result === null).toBe(runs); + const identity = await client(world).query(api.inspection.identity, {}); + expect(identity).toMatchObject(world.identity!); + }, + "the row the internal mutation writes is stored {stored}": async ( + world, + { stored }, + ) => { + const rows = await backend(world).readTable("writes"); + expect(rows.length === 1).toBe(stored); + expect(rows[0]).toMatchObject({ marker: "internal" }); + }, + }, +); diff --git a/tests/native/component-auth.observation.test.ts b/tests/native/component-auth.observation.test.ts new file mode 100644 index 0000000..6e932b1 --- /dev/null +++ b/tests/native/component-auth.observation.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { withBackend } from "../../harness/backend.js"; +import { httpClient } from "../../harness/clients.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { api } from "../../fixture/convex/_generated/api.js"; +const name = + "native observation on precompiled-2026-09-28-5c7cb5b: component auth returns the caller identity"; +test(name, async () => { + await withBackend(async (backend) => { + const client = httpClient( + backend.url, + await backend.identity.token("observation-user"), + ); + const identity = await client.query(api.inspection.componentIdentity, {}); + await recordMeasurement(name, "component auth identity", identity); + expect(identity).toMatchObject({ + issuer: backend.identity.issuer, + subject: "observation-user", + }); + }); +}); diff --git a/tests/native/fixture-issuer-token-yields-identity.test.ts b/tests/native/fixture-issuer-token-yields-identity.test.ts new file mode 100644 index 0000000..a55f196 --- /dev/null +++ b/tests/native/fixture-issuer-token-yields-identity.test.ts @@ -0,0 +1,61 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { httpClient } from "../../harness/clients.js"; +import { backend, client, fixture } from "./world.js"; +import type { World } from "./world.js"; +import { createIdentity } from "../../harness/identity.js"; +import { fixtureIssuerTokenYieldsIdentityContract } from "../../generated/contracts/platform.acceptance-contract.fixture-issuer-token-yields-identity.contract.js"; +const anchor = specTest({ + id: testAnchorId("test:platform.fixture-issuer-token-yields-identity"), + verifies: ref( + "spec:platform.acceptance-contract.fixture-issuer-token-yields-identity", + ), +}); +void anchor; +bindExample(fixtureIssuerTokenYieldsIdentityContract, (): World => ({}), { + "a disposable backend whose environment variables name the fixture issuer {issuer} and its data-URI key set": + (world, { issuer }) => fixture(world, issuer), + "an ordinary client carrying a token the harness signed for subject {subject}": + async (world, { subject }) => { + world.subject = subject; + world.client = httpClient( + backend(world).url, + await backend(world).identity.token(subject), + ); + }, + "the client calls a public query that returns the caller's identity": async ( + world, + ) => { + world.identity = await client(world).query(api.inspection.identity, {}); + }, + "the identity names issuer {identityIssuer} and subject {identitySubject}": + async (world, { identityIssuer, identitySubject }) => { + expect(world.identity?.issuer).toBe(identityIssuer); + expect(world.identity?.subject).toBe(identitySubject); + expect(world.identity?.tokenIdentifier).toBe( + `${identityIssuer}|${identitySubject}`, + ); + const outsider = await createIdentity(backend(world).identity.issuer); + const invalid = httpClient( + backend(world).url, + await outsider.token(world.subject!), + ); + await expect( + invalid.query(api.inspection.identity, {}), + ).rejects.toThrow(); + }, + "the same query from a client with no token returns an identity {anonymousHasIdentity}": + async (world, { anonymousHasIdentity }) => { + const identity = await httpClient(backend(world).url).query( + api.inspection.identity, + {}, + ); + expect(identity !== null).toBe(anonymousHasIdentity); + }, +}); diff --git a/tests/native/ordinary-client-refused-component-function.test.ts b/tests/native/ordinary-client-refused-component-function.test.ts new file mode 100644 index 0000000..f07e00c --- /dev/null +++ b/tests/native/ordinary-client-refused-component-function.test.ts @@ -0,0 +1,52 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { httpClient } from "../../harness/clients.js"; +import { backend, client, fixture } from "./world.js"; +import type { World } from "./world.js"; +import { ordinaryClientRefusedComponentFunctionContract } from "../../generated/contracts/platform.acceptance-contract.ordinary-client-refused-component-function.contract.js"; +const anchor = specTest({ + id: testAnchorId("test:platform.ordinary-client-refused-component-function"), + verifies: ref( + "spec:platform.acceptance-contract.ordinary-client-refused-component-function", + ), +}); +void anchor; +bindExample(ordinaryClientRefusedComponentFunctionContract, (): World => ({}), { + "a disposable backend running the fixture composition, which mounts a component with a mutation that writes one row": + (world) => fixture(world), + "an ordinary client carrying a token the harness signed for subject {subject}": + async (world, { subject }) => { + world.client = httpClient( + backend(world).url, + await backend(world).identity.token(subject), + ); + }, + "the client calls the component's mutation directly by component path and name": + async (world) => { + try { + await client(world).function("inspection:write", "probe", {}); + } catch (error) { + world.error = error; + } + }, + "Convex refuses the call {refused}": async (world, { refused }) => { + expect(world.error !== undefined).toBe(refused); + expect(String(world.error)).toContain("BadDeployKey"); + await expect( + httpClient(backend(world).url).function("inspection:write", "probe", {}), + ).rejects.toThrow("BadDeployKey"); + }, + "the row the component's mutation writes is stored {stored}": async ( + world, + { stored }, + ) => { + const rows = await backend(world).readTable("writes", "probe"); + expect(rows.length > 0).toBe(stored); + expect(rows).toHaveLength(0); + }, +}); diff --git a/tests/native/ordinary-client-refused-internal-function.test.ts b/tests/native/ordinary-client-refused-internal-function.test.ts new file mode 100644 index 0000000..bfb5934 --- /dev/null +++ b/tests/native/ordinary-client-refused-internal-function.test.ts @@ -0,0 +1,58 @@ +import { makeFunctionReference } from "convex/server"; +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { httpClient } from "../../harness/clients.js"; +import { backend, client, fixture } from "./world.js"; +import type { World } from "./world.js"; +import { ordinaryClientRefusedInternalFunctionContract } from "../../generated/contracts/platform.acceptance-contract.ordinary-client-refused-internal-function.contract.js"; +const anchor = specTest({ + id: testAnchorId("test:platform.ordinary-client-refused-internal-function"), + verifies: ref( + "spec:platform.acceptance-contract.ordinary-client-refused-internal-function", + ), +}); +void anchor; +bindExample(ordinaryClientRefusedInternalFunctionContract, (): World => ({}), { + "a disposable backend running the fixture composition, which registers an internal mutation that writes one row": + (world) => fixture(world), + "an ordinary client carrying a token the harness signed for subject {subject}": + async (world, { subject }) => { + world.client = httpClient( + backend(world).url, + await backend(world).identity.token(subject), + ); + }, + "the client calls the internal mutation by name": async (world) => { + try { + await client(world).mutation( + makeFunctionReference<"mutation">("inspection:writeInternal"), + {}, + ); + } catch (error) { + world.error = error; + } + }, + "Convex refuses the call {refused}": async (world, { refused }) => { + expect(world.error !== undefined).toBe(refused); + expect(String(world.error)).toContain("Could not find public function"); + await expect( + httpClient(backend(world).url).mutation( + makeFunctionReference<"mutation">("inspection:writeInternal"), + {}, + ), + ).rejects.toThrow("Could not find public function"); + }, + "the row the internal mutation writes is stored {stored}": async ( + world, + { stored }, + ) => { + const rows = await backend(world).readTable("writes"); + expect(rows.length > 0).toBe(stored); + expect(rows).toHaveLength(0); + }, +}); diff --git a/tests/native/world.ts b/tests/native/world.ts new file mode 100644 index 0000000..1f074ed --- /dev/null +++ b/tests/native/world.ts @@ -0,0 +1,29 @@ +import { onTestFinished } from "vitest"; +import { startFixture } from "../../harness/backend.js"; +import type { Backend } from "../../harness/backend.js"; +import type { HarnessHttpClient } from "../../harness/clients.js"; +import type { UserIdentity } from "convex/server"; +export interface World { + backend?: Backend; + client?: HarnessHttpClient; + identity?: UserIdentity | null; + error?: unknown; + result?: unknown; + subject?: string; +} +export async function fixture(world: World, issuer?: string) { + const backend = await startFixture(issuer); + world.backend = backend; + // bindExample's optional after callback runs only on success. Vitest cleanup also runs on failure. + onTestFinished(() => backend.stop()); +} +export function backend(world: World) { + if (!world.backend) + throw new Error("The Given step has not started the fixture"); + return world.backend; +} +export function client(world: World) { + if (!world.client) + throw new Error("The Given step has not created the client"); + return world.client; +} diff --git a/tests/pure/identity.test.ts b/tests/pure/identity.test.ts new file mode 100644 index 0000000..30139fc --- /dev/null +++ b/tests/pure/identity.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "vitest"; +import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from "jose"; +import { + createIdentity, + fixtureIssuer, + applicationID, +} from "../../harness/identity.js"; +test("pure: fixture token verifies against its data-URI ES256 key set", async () => { + const identity = await createIdentity(); + expect(identity.jwks).toMatch(/^data:text\/plain;charset=utf-8;base64,/); + const keys = JSON.parse( + Buffer.from(identity.jwks.split(",")[1]!, "base64").toString(), + ) as Parameters[0]; + const token = await identity.token("pure-user"); + const { payload } = await jwtVerify(token, createLocalJWKSet(keys), { + issuer: fixtureIssuer, + audience: applicationID, + }); + expect(payload.sub).toBe("pure-user"); + expect(payload.iat).toBeTypeOf("number"); + expect(payload.exp! - payload.iat!).toBe(600); + expect(decodeProtectedHeader(token)).toEqual({ + alg: "ES256", + kid: "fixture-key-1", + typ: "JWT", + }); + const outsider = await createIdentity(); + await expect( + jwtVerify(await outsider.token("pure-user"), createLocalJWKSet(keys)), + ).rejects.toThrow(); +}); diff --git a/tests/simulator/boundaries.observation.test.ts b/tests/simulator/boundaries.observation.test.ts new file mode 100644 index 0000000..0746f1b --- /dev/null +++ b/tests/simulator/boundaries.observation.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "vitest"; +import { convexTest } from "convex-test"; +import schema from "../../fixture/convex/schema.js"; +import probeSchema from "../../fixture/convex/probe/schema.js"; +import { api } from "../../fixture/convex/_generated/api.js"; +function simulator() { + const t = convexTest( + schema, + import.meta.glob("../../fixture/convex/**/*.ts"), + ); + t.registerComponent( + "probe", + probeSchema, + import.meta.glob("../../fixture/convex/probe/**/*.ts"), + ); + return t; +} +test.each(["nested", "component"] as const)( + "convex-test observation: parent's catch sees structured ConvexError across %s mutation boundary", + async (boundary) => { + const t = simulator(); + const result = await t.mutation(api.inspection.catchFailure, { boundary }); + expect(result).toMatchObject({ + isConvexError: true, + data: { code: "fixtureFailure", details: { expected: 2, current: 3 } }, + }); + }, +); +test("convex-test observation: component auth under withIdentity returns null, unlike the native spike", async () => { + const t = simulator().withIdentity({ + issuer: "https://fixture-issuer.test", + subject: "sim-user", + }); + expect(await t.query(api.inspection.identity, {})).toMatchObject({ + issuer: "https://fixture-issuer.test", + subject: "sim-user", + }); + expect(await t.query(api.inspection.componentIdentity, {})).toBeNull(); +}); diff --git a/tests/types/api.test-d.ts b/tests/types/api.test-d.ts new file mode 100644 index 0000000..442d4a0 --- /dev/null +++ b/tests/types/api.test-d.ts @@ -0,0 +1,16 @@ +import { expectTypeOf, test } from "vitest"; +import { api, internal } from "../../fixture/convex/_generated/api.js"; +import type { FunctionReference } from "convex/server"; +test("compiled: generated API exposes public fixture functions and excludes internal functions", () => { + expectTypeOf(api.inspection.identity).toExtend< + FunctionReference<"query", "public"> + >(); + expectTypeOf(api.inspection.catchFailure).toExtend< + FunctionReference<"mutation", "public"> + >(); + expectTypeOf(internal.inspection.writeInternal).toExtend< + FunctionReference<"mutation", "internal"> + >(); + expectTypeOf<"writeInternal">().not.toExtend(); + expectTypeOf<"failInternal">().not.toExtend(); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..49663bb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node", "vite/client"] + }, + "include": [ + "fixture/**/*.ts", + "harness/**/*.ts", + "scripts/**/*.ts", + "tests/**/*.ts", + "vitest.config.ts" + ], + "exclude": ["node_modules", "generated", "vendor"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5bade6d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ + test: { + reporters: ["default", "./harness/evidence.ts"], + projects: [ + { + test: { + name: "types", + include: [], + typecheck: { + enabled: true, + only: true, + checker: "tsc", + include: ["tests/types/**/*.test-d.ts"], + }, + }, + }, + { + test: { + name: "pure", + environment: "node", + include: ["tests/pure/**/*.test.ts"], + }, + }, + { + test: { + name: "simulator", + environment: "edge-runtime", + include: ["tests/simulator/**/*.test.ts"], + }, + }, + { + test: { + name: "native", + environment: "node", + include: ["tests/native/**/*.test.ts"], + testTimeout: 120000, + hookTimeout: 120000, + }, + }, + ], + }, +}); From e75b484ad7b6402e94f32f2aa77845503d200b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Thu, 1 Oct 2026 16:48:32 +0200 Subject: [PATCH 03/28] wip: S0 probes as build job 2 left them, and the handover inside the unit The probe code is unreviewed and partly red. The job that wrote it died with the conversation and left no report. First run on 2026-10-01, backend precompiled-2026-09-28-5c7cb5b: codegen, lint and format pass; tsc fails on four errors in convex-helpers' shipped source under exactOptionalPropertyTypes; native 11 pass, 7 fail, one file does not load. Green: probe 2 on both boundaries, the subscription half of probe 5, all three probe 1 cases. Red: probe 3 (log capture), probe 4 (seeding hits the backend's write rate limit; the depth is 9 where the example binds 8), the pagination half of probe 5. SESSIONS.md gains the design step before a build, the owner's three rulings of 2026-10-01 on who designs, who reviews taste and how a job is sized, and the procedure for handing over inside a unit. STATE.md gains an In progress block that points at the handover note. Co-Authored-By: Claude Fable 5.1 --- design/README.md | 2 +- design/SESSIONS.md | 26 ++- design/STATE.md | 11 ++ fixture/convex/_generated/api.d.ts | 10 + fixture/convex/probe/_generated/api.ts | 8 + fixture/convex/probe/_generated/component.ts | 76 ++++++++ fixture/convex/probe/probe2.ts | 16 ++ fixture/convex/probe/probe3.ts | 17 ++ fixture/convex/probe/probe4.ts | 51 +++++ fixture/convex/probe/probe5.ts | 45 +++++ fixture/convex/probe/schema.ts | 14 +- fixture/convex/probe1.ts | 10 + fixture/convex/probe2.ts | 53 +++++ fixture/convex/probe3.ts | 62 ++++++ fixture/convex/probe4.ts | 121 ++++++++++++ fixture/convex/probe5.ts | 42 ++++ fixture/convex/schema.ts | 11 +- harness/wait.ts | 38 ++++ ...obe-3-component-call-from-mutation.test.ts | 141 ++++++++++++++ ....probe-3-component-call-from-query.test.ts | 141 ++++++++++++++ ...kend-restart-with-pending-mutation.test.ts | 141 ++++++++++++++ ...lient-closed-with-pending-mutation.test.ts | 122 ++++++++++++ ...onfirmed.probe-1-http-client-retry.test.ts | 80 ++++++++ ...obe-4-component-call-shares-limits.test.ts | 170 ++++++++++++++++ ....probe-4-nested-call-shares-limits.test.ts | 170 ++++++++++++++++ ...-have-limits.probe-4-nesting-depth.test.ts | 69 +++++++ ...oundary.probe-2-component-boundary.test.ts | 127 ++++++++++++ ...t-boundary.probe-2-nested-mutation.test.ts | 127 ++++++++++++ ...tive.probe-5-pages-stay-contiguous.test.ts | 99 ++++++++++ ...ive.probe-5-split-required-relayed.test.ts | 118 ++++++++++++ ...ctive.probe-5-subscription-updates.test.ts | 71 +++++++ tests/native/probe1-transport.ts | 77 ++++++++ tests/native/probe5-hook.test.ts | 182 ++++++++++++++++++ tests/native/probe5-support.ts | 107 ++++++++++ tests/simulator/probe1-transport.test.ts | 62 ++++++ 35 files changed, 2607 insertions(+), 10 deletions(-) create mode 100644 fixture/convex/probe/probe2.ts create mode 100644 fixture/convex/probe/probe3.ts create mode 100644 fixture/convex/probe/probe4.ts create mode 100644 fixture/convex/probe/probe5.ts create mode 100644 fixture/convex/probe1.ts create mode 100644 fixture/convex/probe2.ts create mode 100644 fixture/convex/probe3.ts create mode 100644 fixture/convex/probe4.ts create mode 100644 fixture/convex/probe5.ts create mode 100644 harness/wait.ts create mode 100644 tests/native/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.test.ts create mode 100644 tests/native/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.test.ts create mode 100644 tests/native/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.test.ts create mode 100644 tests/native/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.test.ts create mode 100644 tests/native/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.test.ts create mode 100644 tests/native/f13-transactions-have-limits.probe-4-component-call-shares-limits.test.ts create mode 100644 tests/native/f13-transactions-have-limits.probe-4-nested-call-shares-limits.test.ts create mode 100644 tests/native/f13-transactions-have-limits.probe-4-nesting-depth.test.ts create mode 100644 tests/native/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.test.ts create mode 100644 tests/native/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.test.ts create mode 100644 tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.test.ts create mode 100644 tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.test.ts create mode 100644 tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.test.ts create mode 100644 tests/native/probe1-transport.ts create mode 100644 tests/native/probe5-hook.test.ts create mode 100644 tests/native/probe5-support.ts create mode 100644 tests/simulator/probe1-transport.test.ts diff --git a/design/README.md b/design/README.md index bd4e0a6..aacc271 100644 --- a/design/README.md +++ b/design/README.md @@ -45,7 +45,7 @@ python3 design/tools/check.py Expected result on the integrated corpus: ``` -185 specs · 5 packs · 4 anchors → 194 nodes · 936 edges (0 errors, 0 warnings) +185 specs · 5 packs · 17 anchors → 207 nodes · 949 edges (0 errors, 0 warnings) validate: 0 errors · 0 warnings (conformance + honesty over the one graph) ``` diff --git a/design/SESSIONS.md b/design/SESSIONS.md index c035d74..1aa8cf9 100644 --- a/design/SESSIONS.md +++ b/design/SESSIONS.md @@ -28,10 +28,10 @@ The owner ruled on 2026-10-01 that the session takes tactical decisions on its o | Read-only scout before a unit; reviews that run things | `gpt-6-astra` | high | | Bounded building from a Spec; mechanical edits from an exact text; the mechanical review lane | `gpt-6.1-sol` | medium | | Review of balance and of how a reader will take the text | Claude agent, Opus 5.5 | default | -| A design pass over interfaces that several Specs share | Claude agent, Fable 5.1 | default | +| The design of a solution before an implementer writes it: interfaces, names, shapes, exact text | Claude agent, Fable 5.1 | default | | Whole-branch review before a hand-over | `gpt-6-astra` | xhigh | -GPT capacity is the abundant side, so reading, checking and building go there. Claude agents are the scarce side: start one only for a role in the table. Design judgment and the project's vocabulary stay with Claude. The mechanics of reaching GPT models, briefing each family and calibrating a reviewer are in the orchestration guide, and this project's commands, brief fragments and job log are in its notes file. `AGENTS.md` names both. +GPT capacity is the abundant side, so reading, checking and building go there. Claude agents are the scarce side: start one only for a role in the table. Design judgment and the project's vocabulary stay with Claude. The owner ruled three things on 2026-10-01. A Fable agent, not the main thread inside a brief, designs a solution before any implementer writes it, whichever model implements. Taste review goes to a Claude model only, and solution design to Fable only. And a job is sized to its model: sol takes what an Opus agent would take, astra what a Fable agent would, in width and in depth. The mechanics of reaching GPT models, briefing each family and calibrating a reviewer are in the orchestration guide, and this project's commands, brief fragments and job log are in its notes file. `AGENTS.md` names both. Four rules from the stop of 2026-09-30, when ten parallel Fable agents hit the usage limit and one left 109 unreported edits: @@ -55,10 +55,11 @@ One slice of the build, as `STATE.md` cuts them. `python3 design/tools/check.py 1. **Scout.** One read-only astra job reports, per finding of the slice: the Spec that owns the concept, every other place that restates it, found by searching the whole corpus for each name and signature, and each conflict with the doc. Its report is a list of leads. 2. **Settle.** For each ledger finding and each scout lead, look for what would refute it, then trace it to the scenario it breaks. Rule one of three: fix on paper now, let the build decide and say what the build must show, or the owner. A `fix` field in the ledger is a lead; five were found wrong on 2026-10-01. 3. **Author.** Change the Spec that owns the concept, then every Spec that restates it. A Spec that names another in prose declares the relation. Fix what the slice cannot be built without and leave the rest open with the note that the build decides. Set each finding's `status` and `note` before starting the next. -4. **Build.** One sol job per Spec, or one job for several when an exact design exists. The job writes code and tests, binds each example it realizes to its test, changes no Spec and no stated readiness, and lists everything the Spec did not say that it had to decide. -5. **Commit, then review a frozen copy.** Three lanes read the commit, each with one criterion. Sol reads every changed line, test and pinned value. Astra runs the code against the Spec and looks for consequences elsewhere. An Opus agent asks whether the change added mechanism the scenario does not need and how a reader will misread it. A number in a Claude finding is re-run before anyone acts on it. -6. **Rule on each gap.** Code wrong becomes one numbered fold-in job. Spec wrong becomes a Spec edit by the main thread and a ledger entry. A platform decision goes to the owner queue with its provisional reading. -7. **Review the fixes.** One more read-only pass over the last fold-in. A fix is new work. +4. **Design.** A Fable agent reads the Specs the slice builds and writes one design file: interfaces, names, shapes, the order of work and exact text where text matters. It changes no file in the repository. The main thread rules on the design before anyone builds from it. +5. **Build.** One sol job per Spec, or one job for several, applying the design. The job writes code and tests, binds each example it realizes to its test, changes no Spec and no stated readiness, and lists everything the design did not say that it had to decide. A GPT job cannot resolve a host, listen on a local port or reach Docker, so the main thread runs installs, codegen and the native tier and hands the output back. +6. **Commit, then review a frozen copy.** Three lanes read the commit, each with one criterion. Sol reads every changed line, test and pinned value. Astra runs the code against the Spec and looks for consequences elsewhere. An Opus agent asks whether the change added mechanism the scenario does not need and how a reader will misread it. A number in a Claude finding is re-run before anyone acts on it. +7. **Rule on each gap.** Code wrong becomes one numbered fold-in job, designed first when it reshapes an interface. Spec wrong becomes a Spec edit by the main thread and a ledger entry. A platform decision goes to the owner queue with its provisional reading. +8. **Review the fixes.** One more read-only pass over the last fold-in. A fix is new work. Done when the slice's tests pass at the tier each claims, the check prints `OK`, every finding the slice took has a status other than `open` or a note saying what the build still has to show, and every gap is ruled. @@ -110,6 +111,17 @@ The owner rules. The session applies each ruling at the Spec that owns the quest Consensus holds for Layers 0 to 2 when the first experiment passes on a native backend and each of the five lenses has `approve` as its latest verdict covering `HEAD`. `STATE.md` then names `owner` as the next unit. Layers 3 to 6 are written from what the experiment shows and reach consensus the same way afterwards. +## Handing over inside a unit + +A build slice is too long for one conversation. The main thread hands over to a fresh main thread at a seam, before its own context gets in the way: after a commit, with no delegated job running and every report saved. + +1. Reach the seam. Wait for or stop every delegated job. A job that dies with the conversation leaves no report, and the Codex job index does not follow a restarted conversation: read the job's log under the plugin's state directory. +2. Commit what is in the tree on the unit's branch, as work in progress if it is not reviewed, and say so in the message. +3. Write the handover note beside the unit's reports, in the folder the project notes name. It says what is committed, what each tier showed and at which commit, which findings are ruled and where the rulings are, what is drafted and not applied, the next steps in order, and what waits for the owner. It lists every instruction the owner gave during the conversation, in the owner's words. +4. Put a short "In progress" block at the top of `STATE.md` that names the unit, the seam and the note. The rest of `STATE.md` waits for the close. +5. Start the next main thread in a fresh conversation in the repository, Fable 5.1 at high effort, and check both in its status line before prompting it. The prompt names the unit, the note, and the files to read first. A cleared conversation is required: a main thread that inherits a summary of this one inherits its weight. +6. The next main thread reads `AGENTS.md`, `STATE.md`, this file and the note, runs the check, and confirms with `git status` and `git log` that the tree is what the note says before it does anything else. + ## When a session runs short -The ledger is at most one finding behind. Stop, write `STATE.md` with what is done and what is not, and let the next session open with `recover`. +The ledger is at most one finding behind. Stop, write `STATE.md` with what is done and what is not, and let the next session open with `recover`. Hand over as above when there is still room to write the note. diff --git a/design/STATE.md b/design/STATE.md index e5961b2..b600eed 100644 --- a/design/STATE.md +++ b/design/STATE.md @@ -2,6 +2,17 @@ Written on 2026-10-01 at the close of the `adopt` unit. `SESSIONS.md` says how to use this file. Every close rewrites it. +## In progress + +Slice S0 is open on branch `slice/s0` and was handed over inside the unit on 2026-10-01, as `SESSIONS.md` describes. Everything below this block describes the corpus before S0 and waits for the close. + +- Done and committed: the four harness decisions on the acceptance contract (E-13 changed, E-15 new), seventeen example Specs for the harness and for probes 1 to 5, the Protocol's root moved to the repository root, the toolchain, the fixture app, the native harness, four test tiers and CI. +- Committed as work in progress, unreviewed and partly red: the thirteen probe tests. +- Not done: the fold-in of three reviews of the foundation, the reviews of the probes, the probe results recorded on the facts, the close. +- The seam: after the first native run of the probes, before the design of the fold-in. +- The handover note is `HANDOVER.md` in `~/dev-libar/calibration/application-platform-s0/`, with the reports, the rulings and the drafts. The project notes file named in `AGENTS.md` lists that folder. +- The summary lines under "Measured at close" are the `adopt` unit's. Run the check for today's. + ## Measured at close ``` diff --git a/fixture/convex/_generated/api.d.ts b/fixture/convex/_generated/api.d.ts index e8c1d09..ea9cbba 100644 --- a/fixture/convex/_generated/api.d.ts +++ b/fixture/convex/_generated/api.d.ts @@ -9,6 +9,11 @@ */ import type * as inspection from "../inspection.js"; +import type * as probe1 from "../probe1.js"; +import type * as probe2 from "../probe2.js"; +import type * as probe3 from "../probe3.js"; +import type * as probe4 from "../probe4.js"; +import type * as probe5 from "../probe5.js"; import type { ApiFromModules, @@ -18,6 +23,11 @@ import type { declare const fullApi: ApiFromModules<{ inspection: typeof inspection; + probe1: typeof probe1; + probe2: typeof probe2; + probe3: typeof probe3; + probe4: typeof probe4; + probe5: typeof probe5; }>; /** diff --git a/fixture/convex/probe/_generated/api.ts b/fixture/convex/probe/_generated/api.ts index d3c4d01..1562d07 100644 --- a/fixture/convex/probe/_generated/api.ts +++ b/fixture/convex/probe/_generated/api.ts @@ -9,6 +9,10 @@ */ import type * as inspection from "../inspection.js"; +import type * as probe2 from "../probe2.js"; +import type * as probe3 from "../probe3.js"; +import type * as probe4 from "../probe4.js"; +import type * as probe5 from "../probe5.js"; import type { ApiFromModules, @@ -19,6 +23,10 @@ import { anyApi, componentsGeneric } from "convex/server"; const fullApi: ApiFromModules<{ inspection: typeof inspection; + probe2: typeof probe2; + probe3: typeof probe3; + probe4: typeof probe4; + probe5: typeof probe5; }> = anyApi as any; /** diff --git a/fixture/convex/probe/_generated/component.ts b/fixture/convex/probe/_generated/component.ts index 2491b10..e5dec05 100644 --- a/fixture/convex/probe/_generated/component.ts +++ b/fixture/convex/probe/_generated/component.ts @@ -28,4 +28,80 @@ export type ComponentApi = identity: FunctionReference<"query", "internal", {}, any, Name>; write: FunctionReference<"mutation", "internal", {}, null, Name>; }; + probe2: { + fail: FunctionReference< + "mutation", + "internal", + { data: any; ordinary: boolean }, + null, + Name + >; + }; + probe3: { + read: FunctionReference< + "query", + "internal", + { id: string; nonce: number }, + any, + Name + >; + seed: FunctionReference<"mutation", "internal", {}, string, Name>; + }; + probe4: { + read: FunctionReference< + "query", + "internal", + { count: number; group: string }, + any, + Name + >; + seed: FunctionReference< + "mutation", + "internal", + { count: number; group: string; size: number }, + null, + Name + >; + writtenCount: FunctionReference<"query", "internal", {}, number, Name>; + }; + probe5: { + change: FunctionReference< + "mutation", + "internal", + { id: string; value: string }, + null, + Name + >; + first: FunctionReference<"query", "internal", {}, any, Name>; + page: FunctionReference< + "query", + "internal", + { + paginationOpts: { + cursor: string | null; + endCursor?: string | null; + id?: number; + maximumBytesRead?: number; + maximumRowsRead?: number; + numItems: number; + }; + }, + any, + Name + >; + remove: FunctionReference< + "mutation", + "internal", + { id: string }, + null, + Name + >; + write: FunctionReference< + "mutation", + "internal", + { rows: Array<{ position: number; value: string }> }, + null, + Name + >; + }; }; diff --git a/fixture/convex/probe/probe2.ts b/fixture/convex/probe/probe2.ts new file mode 100644 index 0000000..4ec9b93 --- /dev/null +++ b/fixture/convex/probe/probe2.ts @@ -0,0 +1,16 @@ +import { ConvexError, v } from "convex/values"; +import { mutation } from "./_generated/server.js"; +export const fail = mutation({ + args: { data: v.any(), ordinary: v.boolean() }, + returns: v.null(), + handler: async (ctx, { data, ordinary }) => { + await ctx.db.insert("probe2Writes", { marker: "child" }); + if (ordinary) { + const error = Object.assign(new Error("ordinary probe error"), { + probeProperty: "present", + }); + throw error; + } + throw new ConvexError(data); + }, +}); diff --git a/fixture/convex/probe/probe3.ts b/fixture/convex/probe/probe3.ts new file mode 100644 index 0000000..85d2061 --- /dev/null +++ b/fixture/convex/probe/probe3.ts @@ -0,0 +1,17 @@ +import { v } from "convex/values"; +import { query, mutation } from "./_generated/server.js"; +import type { QueryCtx } from "./_generated/server.js"; +import type { Id } from "./_generated/dataModel.js"; +export const seed = mutation({ + args: {}, + returns: v.id("probe3Docs"), + handler: (ctx) => ctx.db.insert("probe3Docs", { value: 7 }), +}); +async function helper(ctx: QueryCtx, id: Id<"probe3Docs">) { + return ctx.db.get("probe3Docs", id); +} +export const read = query({ + args: { id: v.id("probe3Docs"), nonce: v.number() }, + returns: v.any(), + handler: (ctx, { id }) => helper(ctx, id), +}); diff --git a/fixture/convex/probe/probe4.ts b/fixture/convex/probe/probe4.ts new file mode 100644 index 0000000..c155987 --- /dev/null +++ b/fixture/convex/probe/probe4.ts @@ -0,0 +1,51 @@ +import type { TransactionMetrics } from "convex/server"; +export interface ReadResult { + before: TransactionMetrics; + after: TransactionMetrics; + count: number; + payloadBytes: number; +} +import { v } from "convex/values"; +import { query, mutation } from "./_generated/server.js"; +export const seed = mutation({ + args: { group: v.string(), count: v.number(), size: v.number() }, + returns: v.null(), + handler: async (ctx, a) => { + for (let i = 0; i < a.count; i++) + await ctx.db.insert("blobs", { + group: a.group, + bytes: new ArrayBuffer(a.size), + }); + return null; + }, +}); +export const read = query({ + args: { group: v.string(), count: v.number() }, + returns: v.any(), + handler: async (ctx, a): Promise => { + const before = await ctx.meta.getTransactionMetrics(); + const docs = await ctx.db + .query("blobs") + .withIndex("by_group", (q) => q.eq("group", a.group)) + .take(a.count); + const after = await ctx.meta.getTransactionMetrics(); + return { + before, + after, + count: docs.length, + payloadBytes: docs.reduce((n, d) => n + d.bytes.byteLength, 0), + }; + }, +}); + +export const writtenCount = query({ + args: {}, + returns: v.number(), + handler: async (ctx) => + ( + await ctx.db + .query("blobs") + .withIndex("by_group", (q) => q.eq("group", "written")) + .take(100) + ).length, +}); diff --git a/fixture/convex/probe/probe5.ts b/fixture/convex/probe/probe5.ts new file mode 100644 index 0000000..d0f0b25 --- /dev/null +++ b/fixture/convex/probe/probe5.ts @@ -0,0 +1,45 @@ +import { v } from "convex/values"; +import { paginationOptsValidator } from "convex/server"; +import { paginator } from "convex-helpers/server/pagination"; +import { query, mutation } from "./_generated/server.js"; +import schema from "./schema.js"; +export const page = query({ + args: { paginationOpts: paginationOptsValidator }, + returns: v.any(), + handler: (ctx, a) => + paginator(ctx.db, schema) + .query("rows") + .withIndex("by_position") + .paginate(a.paginationOpts), +}); +export const write = mutation({ + args: { + rows: v.array(v.object({ position: v.number(), value: v.string() })), + }, + returns: v.null(), + handler: async (ctx, a) => { + for (const row of a.rows) await ctx.db.insert("rows", row); + return null; + }, +}); +export const remove = mutation({ + args: { id: v.id("rows") }, + returns: v.null(), + handler: async (ctx, a) => { + await ctx.db.delete("rows", a.id); + return null; + }, +}); +export const change = mutation({ + args: { id: v.id("rows"), value: v.string() }, + returns: v.null(), + handler: async (ctx, a) => { + await ctx.db.patch("rows", a.id, { value: a.value }); + return null; + }, +}); +export const first = query({ + args: {}, + returns: v.any(), + handler: (ctx) => ctx.db.query("rows").withIndex("by_position").first(), +}); diff --git a/fixture/convex/probe/schema.ts b/fixture/convex/probe/schema.ts index efe95f4..b51c2cd 100644 --- a/fixture/convex/probe/schema.ts +++ b/fixture/convex/probe/schema.ts @@ -1,3 +1,15 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; -export default defineSchema({ writes: defineTable({ marker: v.string() }) }); +export default defineSchema({ + writes: defineTable({ marker: v.string() }), + probe2Writes: defineTable({ marker: v.string() }), + probe3Docs: defineTable({ value: v.number() }), + blobs: defineTable({ bytes: v.bytes(), group: v.string() }).index( + "by_group", + ["group"], + ), + rows: defineTable({ position: v.number(), value: v.string() }).index( + "by_position", + ["position"], + ), +}); diff --git a/fixture/convex/probe1.ts b/fixture/convex/probe1.ts new file mode 100644 index 0000000..177a6dd --- /dev/null +++ b/fixture/convex/probe1.ts @@ -0,0 +1,10 @@ +import { v } from "convex/values"; +import { mutation } from "./_generated/server.js"; +export const marker = mutation({ + args: { trial: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.insert("markers", args); + return null; + }, +}); diff --git a/fixture/convex/probe2.ts b/fixture/convex/probe2.ts new file mode 100644 index 0000000..4c1c5f9 --- /dev/null +++ b/fixture/convex/probe2.ts @@ -0,0 +1,53 @@ +import { ConvexError, v } from "convex/values"; +import { mutation } from "./_generated/server.js"; +import { api, components } from "./_generated/api.js"; +export const fail = mutation({ + args: { data: v.any(), ordinary: v.boolean() }, + returns: v.null(), + handler: async (ctx, { data, ordinary }) => { + await ctx.db.insert("probe2Writes", { marker: "child" }); + if (ordinary) { + const error = Object.assign(new Error("ordinary probe error"), { + probeProperty: "present", + }); + throw error; + } + throw new ConvexError(data); + }, +}); +const parentArgs = { + data: v.any(), + ordinary: v.boolean(), + component: v.boolean(), +}; +export const catching = mutation({ + args: parentArgs, + returns: v.any(), + handler: async (ctx, args) => { + let caught = null; + try { + await ctx.runMutation( + args.component ? components.probe.probe2.fail : api.probe2.fail, + { data: args.data, ordinary: args.ordinary }, + ); + } catch (error) { + caught = { + isConvexError: error instanceof ConvexError, + data: error instanceof ConvexError ? error.data : null, + message: String(error), + property: (error as { probeProperty?: string }).probeProperty ?? null, + }; + } + await ctx.db.insert("probe2Writes", { marker: "parent" }); + return caught; + }, +}); +export const passing = mutation({ + args: parentArgs, + returns: v.null(), + handler: async (ctx, args): Promise => + ctx.runMutation( + args.component ? components.probe.probe2.fail : api.probe2.fail, + { data: args.data, ordinary: args.ordinary }, + ), +}); diff --git a/fixture/convex/probe3.ts b/fixture/convex/probe3.ts new file mode 100644 index 0000000..25c0df6 --- /dev/null +++ b/fixture/convex/probe3.ts @@ -0,0 +1,62 @@ +import { v } from "convex/values"; +import { query, mutation } from "./_generated/server.js"; +import type { QueryCtx } from "./_generated/server.js"; +import type { Id } from "./_generated/dataModel.js"; +import { api, components } from "./_generated/api.js"; +export const seed = mutation({ + args: {}, + returns: v.id("probe3Docs"), + handler: (ctx) => ctx.db.insert("probe3Docs", { value: 7 }), +}); +async function helper(ctx: QueryCtx, id: Id<"probe3Docs">) { + return ctx.db.get("probe3Docs", id); +} +export const read = query({ + args: { id: v.id("probe3Docs"), nonce: v.number() }, + returns: v.any(), + handler: (ctx, { id }) => helper(ctx, id), +}); +const args = { + id: v.id("probe3Docs"), + componentId: v.string(), + reads: v.number(), + nonce: v.number(), + path: v.union( + v.literal("helper"), + v.literal("nested"), + v.literal("component"), + ), +}; +async function work( + ctx: QueryCtx, + a: { + id: Id<"probe3Docs">; + componentId: string; + reads: number; + nonce: number; + path: "helper" | "nested" | "component"; + }, +): Promise { + for (let i = 0; i < a.reads; i++) { + if (a.path === "helper") await helper(ctx, a.id); + else if (a.path === "nested") + await ctx.runQuery(api.probe3.read, { id: a.id, nonce: a.nonce + i }); + else + await ctx.runQuery(components.probe.probe3.read, { + id: a.componentId, + nonce: a.nonce + i, + }); + } + return null; +} +export const parentQuery = query({ args, returns: v.null(), handler: work }); +export const parentMutation = mutation({ + args, + returns: v.null(), + handler: work, +}); +export const seedComponent = mutation({ + args: {}, + returns: v.string(), + handler: (ctx) => ctx.runMutation(components.probe.probe3.seed, {}), +}); diff --git a/fixture/convex/probe4.ts b/fixture/convex/probe4.ts new file mode 100644 index 0000000..856bd98 --- /dev/null +++ b/fixture/convex/probe4.ts @@ -0,0 +1,121 @@ +import type { TransactionMetrics } from "convex/server"; +import type { ReadResult } from "./probe/probe4.js"; +import { v } from "convex/values"; +import { query, mutation } from "./_generated/server.js"; +import { api, components } from "./_generated/api.js"; +export const seed = mutation({ + args: { group: v.string(), count: v.number(), size: v.number() }, + returns: v.null(), + handler: async (ctx, a) => { + for (let i = 0; i < a.count; i++) + await ctx.db.insert("blobs", { + group: a.group, + bytes: new ArrayBuffer(a.size), + }); + return null; + }, +}); +export const read = query({ + args: { group: v.string(), count: v.number() }, + returns: v.any(), + handler: async (ctx, a) => { + const before = await ctx.meta.getTransactionMetrics(); + const docs = await ctx.db + .query("blobs") + .withIndex("by_group", (q) => q.eq("group", a.group)) + .take(a.count); + const after = await ctx.meta.getTransactionMetrics(); + return { + before, + after, + count: docs.length, + payloadBytes: docs.reduce((n, d) => n + d.bytes.byteLength, 0), + }; + }, +}); +const splitArgs = { + component: v.boolean(), + parent: v.number(), + child: v.number(), +}; +export const seedComponent = mutation({ + args: { group: v.string(), count: v.number(), size: v.number() }, + returns: v.null(), + handler: (ctx, a) => ctx.runMutation(components.probe.probe4.seed, a), +}); +export const parentRead = mutation({ + args: splitArgs, + returns: v.any(), + handler: async ( + ctx, + a, + ): Promise<{ + parentCount: number; + before: TransactionMetrics; + after: TransactionMetrics; + child: ReadResult; + }> => { + const parent = await ctx.db + .query("blobs") + .withIndex("by_group", (q) => q.eq("group", "parent")) + .take(a.parent); + const before = await ctx.meta.getTransactionMetrics(); + const child = await ctx.runQuery( + a.component ? components.probe.probe4.read : api.probe4.read, + { group: "child", count: a.child }, + ); + const after = await ctx.meta.getTransactionMetrics(); + return { parentCount: parent.length, before, after, child }; + }, +}); +export const childAlone = query({ + args: { component: v.boolean(), count: v.number() }, + returns: v.any(), + handler: (ctx, a): Promise => + ctx.runQuery(a.component ? components.probe.probe4.read : api.probe4.read, { + group: "child", + count: a.count, + }), +}); +export const parentWrite = mutation({ + args: { ...splitArgs, size: v.number() }, + returns: v.null(), + handler: async (ctx, a) => { + for (let i = 0; i < a.parent; i++) + await ctx.db.insert("blobs", { + group: "written", + bytes: new ArrayBuffer(a.size), + }); + await ctx.runMutation( + a.component ? components.probe.probe4.seed : api.probe4.seed, + { group: "written", count: a.child, size: a.size }, + ); + return null; + }, +}); +export const depth = mutation({ + args: { depth: v.number() }, + returns: v.null(), + handler: async (ctx, { depth }): Promise => { + if (depth > 1) + await ctx.runMutation(api.probe4.depth, { depth: depth - 1 }); + await ctx.db.insert("writes", { marker: `depth-${depth}` }); + return null; + }, +}); + +export const writtenCount = query({ + args: { component: v.boolean() }, + returns: v.number(), + handler: async (ctx, { component }): Promise => { + const count = ( + await ctx.db + .query("blobs") + .withIndex("by_group", (q) => q.eq("group", "written")) + .take(100) + ).length; + return component + ? count + (await ctx.runQuery(components.probe.probe4.writtenCount, {})) + : count; + }, +}); diff --git a/fixture/convex/probe5.ts b/fixture/convex/probe5.ts new file mode 100644 index 0000000..017d351 --- /dev/null +++ b/fixture/convex/probe5.ts @@ -0,0 +1,42 @@ +import { v } from "convex/values"; +import { paginationOptsValidator } from "convex/server"; +import { query, mutation } from "./_generated/server.js"; +import { components } from "./_generated/api.js"; +export const page = query({ + args: { + paginationOpts: paginationOptsValidator, + maximumRowsRead: v.optional(v.number()), + }, + returns: v.any(), + handler: (ctx, a) => + ctx.runQuery(components.probe.probe5.page, { + paginationOpts: { + ...a.paginationOpts, + ...(a.maximumRowsRead === undefined + ? {} + : { maximumRowsRead: a.maximumRowsRead }), + }, + }), +}); +export const write = mutation({ + args: { + rows: v.array(v.object({ position: v.number(), value: v.string() })), + }, + returns: v.null(), + handler: (ctx, a) => ctx.runMutation(components.probe.probe5.write, a), +}); +export const remove = mutation({ + args: { id: v.string() }, + returns: v.null(), + handler: (ctx, a) => ctx.runMutation(components.probe.probe5.remove, a), +}); +export const change = mutation({ + args: { id: v.string(), value: v.string() }, + returns: v.null(), + handler: (ctx, a) => ctx.runMutation(components.probe.probe5.change, a), +}); +export const first = query({ + args: {}, + returns: v.any(), + handler: (ctx) => ctx.runQuery(components.probe.probe5.first, {}), +}); diff --git a/fixture/convex/schema.ts b/fixture/convex/schema.ts index efe95f4..c62e35f 100644 --- a/fixture/convex/schema.ts +++ b/fixture/convex/schema.ts @@ -1,3 +1,12 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; -export default defineSchema({ writes: defineTable({ marker: v.string() }) }); +export default defineSchema({ + writes: defineTable({ marker: v.string() }), + probe2Writes: defineTable({ marker: v.string() }), + probe3Docs: defineTable({ value: v.number() }), + blobs: defineTable({ bytes: v.bytes(), group: v.string() }).index( + "by_group", + ["group"], + ), + markers: defineTable({ trial: v.string() }).index("by_trial", ["trial"]), +}); diff --git a/harness/wait.ts b/harness/wait.ts new file mode 100644 index 0000000..9b7a334 --- /dev/null +++ b/harness/wait.ts @@ -0,0 +1,38 @@ +import { setTimeout as delay } from "node:timers/promises"; +export async function waitUntil( + description: string, + condition: () => boolean | Promise, + timeoutMs = 5000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) return; + await delay(10); + } + throw new Error(`Timed out after ${timeoutMs} ms waiting for ${description}`); +} +export async function within( + promise: Promise, + description: string, + timeoutMs = 10000, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `Timed out after ${timeoutMs} ms waiting for ${description}`, + ), + ), + timeoutMs, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/tests/native/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.test.ts b/tests/native/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.test.ts new file mode 100644 index 0000000..49c13af --- /dev/null +++ b/tests/native/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.test.ts @@ -0,0 +1,141 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { backend, client, fixture } from "./world.js"; +import type { World } from "./world.js"; +import { httpClient } from "../../harness/clients.js"; +import type { Id } from "../../fixture/convex/_generated/dataModel.js"; +import type { FunctionLogRecord } from "../../harness/backend.js"; +import { probe3ComponentCallFromMutationContract as contract } from "../../generated/contracts/facts.f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation", + ), + verifies: ref( + "spec:facts.f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation", + ), +}); +void anchor; +interface ProbeWorld extends World { + reads?: number; + id?: Id<"probe3Docs">; + componentId?: string; + medians?: Record; + counts?: number[]; + log?: ReturnType["logs"]>; +} +const kind: string = "mutation"; +const parentName = + kind === "mutation" ? "probe3:parentMutation" : "probe3:parentQuery"; +const completion = (r: FunctionLogRecord) => r.kind === "Completion"; +async function measure(name: string, value: unknown) { + await recordMeasurement(contract.title, `probe3.${kind}.${name}`, value); +} +function median(values: number[]) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]!; +} +bindExample(contract, (): ProbeWorld => ({}), { + "a parent mutation that reads the same document {reads} times in a row through one of three paths: a helper function, a nested query, a component query": + async (w, { reads }) => { + await fixture(w); + w.client = httpClient(backend(w).url); + w.reads = reads; + w.id = await client(w).mutation(api.probe3.seed, {}); + w.componentId = await client(w).mutation(api.probe3.seedComponent, {}); + w.log = backend(w).logs(); + await measure("readsPerCall", reads); + }, + "a client calls the parent mutation once for each path, several times over": + async (w) => { + const samples: Record = { + helper: [], + nested: [], + component: [], + }; + w.counts = []; + for (let trial = 0; trial < 9; trial++) + for (const path of ["helper", "nested", "component"] as const) { + const offset = w.log!.records.length; + const args = { + id: w.id!, + componentId: w.componentId!, + reads: w.reads!, + nonce: + trial * 10000 + + { helper: 0, nested: 1000, component: 2000 }[path], + path, + }; + if (kind === "mutation") + await client(w).mutation(api.probe3.parentMutation, args); + else await client(w).query(api.probe3.parentQuery, args); + const record = await w.log!.waitFor( + (r) => + w.log!.records.indexOf(r) >= offset && + completion(r) && + r.identifier === parentName, + 5000, + ); + await measure("completion", { path, trial, record }); + if (typeof record.executionTime !== "number") + throw new Error( + `Parent completion has no executionTime: ${JSON.stringify(record)}`, + ); + if (typeof record.requestId !== "string") + throw new Error( + `Parent completion has no requestId: ${JSON.stringify(record)}`, + ); + samples[path]!.push(record.executionTime * 1000); + const records = w.log!.records.filter( + (r) => completion(r) && r.requestId === record.requestId, + ); + if (path === "component") w.counts.push(records.length); + await measure("callLog", { + path, + trial, + records, + nestedVisible: records.some( + (r) => r.identifier === "probe3:read" && !r.componentPath, + ), + componentVisible: records.some( + (r) => + r.identifier === "probe3:read" && r.componentPath === "probe", + ), + }); + } + w.medians = Object.fromEntries( + Object.entries(samples).map(([path, times]) => [path, median(times)]), + ); + for (const path of ["helper", "nested", "component"]) + await measure(`${path}MedianMs`, w.medians[path]); + await measure( + "nestedCostPerCallMs", + (w.medians.nested! - w.medians.helper!) / w.reads!, + ); + await measure( + "componentCostPerCallMs", + (w.medians.component! - w.medians.helper!) / w.reads!, + ); + await measure("completionRecordsPerComponentParent", w.counts); + }, + "the median execution time through the component is {componentAgainstHelper} than through the helper": + (w, { componentAgainstHelper }) => { + const observed = + w.medians!.component! > w.medians!.helper! + ? "higher" + : w.medians!.component! < w.medians!.helper! + ? "lower" + : "equal"; + expect(observed, JSON.stringify(w.medians)).toBe(componentAgainstHelper); + }, + "the function log holds {recordsPerCall} execution record for one parent call through the component": + (w, { recordsPerCall }) => { + for (const count of w.counts!) expect(count).toBe(recordsPerCall); + }, +}); diff --git a/tests/native/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.test.ts b/tests/native/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.test.ts new file mode 100644 index 0000000..78878f6 --- /dev/null +++ b/tests/native/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.test.ts @@ -0,0 +1,141 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { backend, client, fixture } from "./world.js"; +import type { World } from "./world.js"; +import { httpClient } from "../../harness/clients.js"; +import type { Id } from "../../fixture/convex/_generated/dataModel.js"; +import type { FunctionLogRecord } from "../../harness/backend.js"; +import { probe3ComponentCallFromQueryContract as contract } from "../../generated/contracts/facts.f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query", + ), + verifies: ref( + "spec:facts.f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query", + ), +}); +void anchor; +interface ProbeWorld extends World { + reads?: number; + id?: Id<"probe3Docs">; + componentId?: string; + medians?: Record; + counts?: number[]; + log?: ReturnType["logs"]>; +} +const kind: string = "query"; +const parentName = + kind === "mutation" ? "probe3:parentMutation" : "probe3:parentQuery"; +const completion = (r: FunctionLogRecord) => r.kind === "Completion"; +async function measure(name: string, value: unknown) { + await recordMeasurement(contract.title, `probe3.${kind}.${name}`, value); +} +function median(values: number[]) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]!; +} +bindExample(contract, (): ProbeWorld => ({}), { + "a parent query that reads the same document {reads} times in a row through one of three paths: a helper function, a nested query, a component query": + async (w, { reads }) => { + await fixture(w); + w.client = httpClient(backend(w).url); + w.reads = reads; + w.id = await client(w).mutation(api.probe3.seed, {}); + w.componentId = await client(w).mutation(api.probe3.seedComponent, {}); + w.log = backend(w).logs(); + await measure("readsPerCall", reads); + }, + "a client calls the parent query once for each path, several times over, with arguments that defeat the query cache": + async (w) => { + const samples: Record = { + helper: [], + nested: [], + component: [], + }; + w.counts = []; + for (let trial = 0; trial < 9; trial++) + for (const path of ["helper", "nested", "component"] as const) { + const offset = w.log!.records.length; + const args = { + id: w.id!, + componentId: w.componentId!, + reads: w.reads!, + nonce: + trial * 10000 + + { helper: 0, nested: 1000, component: 2000 }[path], + path, + }; + if (kind === "mutation") + await client(w).mutation(api.probe3.parentMutation, args); + else await client(w).query(api.probe3.parentQuery, args); + const record = await w.log!.waitFor( + (r) => + w.log!.records.indexOf(r) >= offset && + completion(r) && + r.identifier === parentName, + 5000, + ); + await measure("completion", { path, trial, record }); + if (typeof record.executionTime !== "number") + throw new Error( + `Parent completion has no executionTime: ${JSON.stringify(record)}`, + ); + if (typeof record.requestId !== "string") + throw new Error( + `Parent completion has no requestId: ${JSON.stringify(record)}`, + ); + samples[path]!.push(record.executionTime * 1000); + const records = w.log!.records.filter( + (r) => completion(r) && r.requestId === record.requestId, + ); + if (path === "component") w.counts.push(records.length); + await measure("callLog", { + path, + trial, + records, + nestedVisible: records.some( + (r) => r.identifier === "probe3:read" && !r.componentPath, + ), + componentVisible: records.some( + (r) => + r.identifier === "probe3:read" && r.componentPath === "probe", + ), + }); + } + w.medians = Object.fromEntries( + Object.entries(samples).map(([path, times]) => [path, median(times)]), + ); + for (const path of ["helper", "nested", "component"]) + await measure(`${path}MedianMs`, w.medians[path]); + await measure( + "nestedCostPerCallMs", + (w.medians.nested! - w.medians.helper!) / w.reads!, + ); + await measure( + "componentCostPerCallMs", + (w.medians.component! - w.medians.helper!) / w.reads!, + ); + await measure("completionRecordsPerComponentParent", w.counts); + }, + "the median execution time through the component is {componentAgainstHelper} than through the helper": + (w, { componentAgainstHelper }) => { + const observed = + w.medians!.component! > w.medians!.helper! + ? "higher" + : w.medians!.component! < w.medians!.helper! + ? "lower" + : "equal"; + expect(observed, JSON.stringify(w.medians)).toBe(componentAgainstHelper); + }, + "the function log holds {recordsPerCall} execution record for one parent call through the component": + (w, { recordsPerCall }) => { + for (const count of w.counts!) expect(count).toBe(recordsPerCall); + }, +}); diff --git a/tests/native/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.test.ts b/tests/native/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.test.ts new file mode 100644 index 0000000..e8f00fd --- /dev/null +++ b/tests/native/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.test.ts @@ -0,0 +1,141 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { backend, fixture } from "./world.js"; +import type { World } from "./world.js"; + +import { ConvexClient } from "convex/browser"; +import { onTestFinished } from "vitest"; +import { randomInt } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; +import { waitUntil, within } from "../../harness/wait.js"; +import { controlledTransport } from "./probe1-transport.js"; + +import { probe1BackendRestartWithPendingMutationContract as contract } from "../../generated/contracts/facts.f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation", + ), + verifies: ref( + "spec:facts.f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation", + ), +}); +void anchor; +interface ProbeWorld extends World { + trials?: { + key: string; + count: number; + resolved: boolean; + existedBeforeReplay?: boolean; + gapMs: number; + error?: string; + }[]; +} +async function measure(name: string, value: unknown) { + await recordMeasurement(contract.title, `probe1.${name}`, value); +} +async function count(w: ProbeWorld, key: string) { + return (await backend(w).readTable("markers")).filter( + (row) => (row as { trial: string }).trial === key, + ).length; +} +async function connected(ws: ConvexClient) { + await waitUntil( + "WebSocket connected before marker send", + () => ws.connectionState().isWebSocketConnected, + 5000, + ); +} + +bindExample(contract, (): ProbeWorld => ({}), { + "a WebSocket client that sends a mutation which inserts one marker row": + async (w) => { + await fixture(w); + }, + "the backend process is killed and restarted on the same storage while the client stays open, in each of {trials} trials": + async (w, { trials }) => { + w.trials = []; + for (let i = 0; i < trials; i++) { + const transport = controlledTransport(); + const ws = new ConvexClient(backend(w).url, { + webSocketConstructor: transport.constructor, + }); + onTestFinished(() => ws.close()); + await connected(ws); + if (i === 0) + await measure("restart.transportTiming", { + responsesHeld: true, + reconnectionsPausedUntilAdminSnapshot: true, + promiseTimeoutMs: 5000, + }); + transport.holdResponses(); + transport.pauseReconnections(); + const key = `restart-${i}`; + let resolved = false; + const pending = ws + .mutation(api.probe1.marker, { trial: key }) + .then(() => { + resolved = true; + }); + void pending.catch(() => {}); + const gapMs = randomInt(0, 31); + if (gapMs > 0) await delay(gapMs); + await backend(w).kill(); + await backend(w).restart(); + const existedBeforeReplay = (await count(w, key)) > 0; + await measure("restart.beforeReplay", { + key, + gapMs, + existedBeforeReplay, + }); + transport.resume(); + let error: string | undefined; + try { + await within(pending, `marker ${key} confirmed after restart`, 5000); + } catch (caught) { + error = String(caught); + } + + const observation = { + key, + gapMs, + existedBeforeReplay, + resolved, + count: await count(w, key), + ...(error === undefined ? {} : { error }), + }; + w.trials.push(observation); + await measure("restart.trial", observation); + await ws.close(); + } + await measure( + "restart.trialsKilledBeforeCommit", + w.trials.filter((t) => !t.existedBeforeReplay).length, + ); + await measure( + "restart.trialsKilledAfterCommit", + w.trials.filter((t) => t.existedBeforeReplay).length, + ); + await measure( + "restart.distribution", + w.trials.map((t) => t.count), + ); + }, + "the mutation's promise resolves after the restart {resolves}": ( + w, + { resolves }, + ) => { + for (const trial of w.trials!) + expect(trial.resolved, JSON.stringify(trial)).toBe(resolves); + }, + "every trial leaves exactly {markerRows} marker row": (w, { markerRows }) => { + for (const trial of w.trials!) + expect(trial.count, JSON.stringify(trial)).toBe(markerRows); + }, +}); diff --git a/tests/native/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.test.ts b/tests/native/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.test.ts new file mode 100644 index 0000000..72e9c6e --- /dev/null +++ b/tests/native/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.test.ts @@ -0,0 +1,122 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { backend, fixture } from "./world.js"; +import type { World } from "./world.js"; + +import { ConvexClient } from "convex/browser"; +import { onTestFinished } from "vitest"; + +import { setTimeout as delay } from "node:timers/promises"; +import { waitUntil } from "../../harness/wait.js"; +import { controlledTransport } from "./probe1-transport.js"; + +import { probe1ClientClosedWithPendingMutationContract as contract } from "../../generated/contracts/facts.f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation", + ), + verifies: ref( + "spec:facts.f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation", + ), +}); +void anchor; +interface ProbeWorld extends World { + trials?: { + key: string; + count: number; + resolved: boolean; + existedBeforeReplay?: boolean; + gapMs: number; + }[]; +} +async function measure(name: string, value: unknown) { + await recordMeasurement(contract.title, `probe1.${name}`, value); +} +async function count(w: ProbeWorld, key: string) { + return (await backend(w).readTable("markers")).filter( + (row) => (row as { trial: string }).trial === key, + ).length; +} +async function connected(ws: ConvexClient) { + await waitUntil( + "WebSocket connected before marker send", + () => ws.connectionState().isWebSocketConnected, + 5000, + ); +} + +bindExample(contract, (): ProbeWorld => ({}), { + "a WebSocket client that sends a mutation which inserts one marker row": + async (w) => { + await fixture(w); + }, + "the client is closed before the mutation's result arrives, in each of {trials} trials": + async (w, { trials }) => { + w.trials = []; + for (let i = 0; i < trials; i++) { + const transport = controlledTransport(); + const ws = new ConvexClient(backend(w).url, { + webSocketConstructor: transport.constructor, + }); + onTestFinished(() => ws.close()); + await connected(ws); + if (i === 0) + await measure("closed.transportTiming", { + responsesHeld: true, + settleWindowMs: 100, + gapsMs: [0, 1, 2, 3, 4, 5], + }); + const key = `closed-${i}`; + transport.holdResponses(); + let resolved = false; + void ws.mutation(api.probe1.marker, { trial: key }).then( + () => { + resolved = true; + }, + () => {}, + ); + const gapMs = i % 6; + if (gapMs > 0) await delay(gapMs); + expect( + resolved, + "mutation must still be pending when client closes", + ).toBe(false); + await ws.close(); + // With no client left to confirm an uncommitted send, absence has no event. + // Observe for a bounded 100 ms settle window, then take the admin snapshot. + await delay(100); + const rows = await count(w, key); + const observation = { key, count: rows, resolved, gapMs }; + w.trials.push(observation); + await measure("closed.trial", observation); + } + await measure( + "closed.trialsWithNoRow", + w.trials.filter((t) => t.count === 0).length, + ); + await measure( + "closed.trialsWithOneRow", + w.trials.filter((t) => t.count === 1).length, + ); + await measure( + "closed.distribution", + w.trials.map((t) => t.count), + ); + }, + "no trial leaves more than {mostMarkerRows} marker row": ( + w, + { mostMarkerRows }, + ) => { + for (const trial of w.trials!) + expect(trial.count, JSON.stringify(trial)).toBeLessThanOrEqual( + mostMarkerRows, + ); + }, +}); diff --git a/tests/native/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.test.ts b/tests/native/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.test.ts new file mode 100644 index 0000000..4f2c45e --- /dev/null +++ b/tests/native/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.test.ts @@ -0,0 +1,80 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { backend, client, fixture } from "./world.js"; +import type { World } from "./world.js"; + +import type { HarnessHttpClient } from "../../harness/clients.js"; +import { ConvexHttpClient } from "convex/browser"; + +import { probe1HttpClientRetryContract as contract } from "../../generated/contracts/facts.f05-react-client-retries-until-confirmed.probe-1-http-client-retry.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f05-react-client-retries-until-confirmed.probe-1-http-client-retry", + ), + verifies: ref( + "spec:facts.f05-react-client-retries-until-confirmed.probe-1-http-client-retry", + ), +}); +void anchor; +interface ProbeWorld extends World { + requests?: number; + trials?: { + key: string; + count: number; + resolved: boolean; + existedBeforeReplay?: boolean; + gapMs: number; + }[]; +} +async function measure(name: string, value: unknown) { + await recordMeasurement(contract.title, `probe1.${name}`, value); +} +async function count(w: ProbeWorld, key: string) { + return (await backend(w).readTable("markers")).filter( + (row) => (row as { trial: string }).trial === key, + ).length; +} + +bindExample(contract, (): ProbeWorld => ({}), { + "an HTTP client whose caller sends a mutation which inserts one marker row and discards the response": + async (w) => { + await fixture(w); + let requests = 0; + const ordinary = new ConvexHttpClient(backend(w).url, { + fetch: async (input, init) => { + requests++; + w.requests = requests; + await measure("http.request", { + number: requests, + url: String(input), + }); + return fetch(input, init); + }, + }); + w.client = ordinary as HarnessHttpClient; + await ordinary.mutation(api.probe1.marker, { trial: "single" }); + const single = await count(w, "single"); + await measure("http.singleCall", { requests, rows: single }); + expect(single).toBe(1); + expect(requests).toBe(1); + await ordinary.mutation(api.probe1.marker, { trial: "retry" }); + await measure("http.discardedResponseRequests", requests); + }, + "the caller sends the same mutation again": async (w) => { + await client(w).mutation(api.probe1.marker, { trial: "retry" }); + }, + "the backend holds {markerRows} marker rows": async (w, { markerRows }) => { + const rows = await count(w, "retry"); + await measure("http.retriedRows", rows); + expect(rows).toBe(markerRows); + expect(w.requests).toBe(markerRows + 1); + await measure("http.totalRequests", w.requests); + }, +}); diff --git a/tests/native/f13-transactions-have-limits.probe-4-component-call-shares-limits.test.ts b/tests/native/f13-transactions-have-limits.probe-4-component-call-shares-limits.test.ts new file mode 100644 index 0000000..d4237da --- /dev/null +++ b/tests/native/f13-transactions-have-limits.probe-4-component-call-shares-limits.test.ts @@ -0,0 +1,170 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { backend, client, fixture } from "./world.js"; +import type { World } from "./world.js"; +import { httpClient } from "../../harness/clients.js"; +import type { ReadResult } from "../../fixture/convex/probe/probe4.js"; +import type { TransactionMetrics } from "convex/server"; +import { probe4ComponentCallSharesLimitsContract as contract } from "../../generated/contracts/facts.f13-transactions-have-limits.probe-4-component-call-shares-limits.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f13-transactions-have-limits.probe-4-component-call-shares-limits", + ), + verifies: ref( + "spec:facts.f13-transactions-have-limits.probe-4-component-call-shares-limits", + ), +}); +void anchor; +interface ProbeWorld extends World { + size?: number; + parent?: number; + child?: number; + together?: { error?: string }; + parentResult?: { + parentCount: number; + before: TransactionMetrics; + after: TransactionMetrics; + child: ReadResult; + }; + childResult?: ReadResult; +} +const component = true; +async function measure(name: string, value: unknown) { + await recordMeasurement( + contract.title, + `probe4.${component ? "component" : "nested"}.${name}`, + value, + ); +} +async function outcome(call: Promise) { + try { + await call; + return {}; + } catch (error) { + return { error: String(error) }; + } +} +function verdict( + result: { error?: string }, + fragment: string, + success: string, +) { + return result.error?.includes(fragment) + ? `fails on the ${fragment === "Too many bytes read" ? "bytes-read" : "bytes-written"} limit` + : (result.error ?? success); +} +bindExample(contract, (): ProbeWorld => ({}), { + "documents of {documentSize} each, {parentDocuments} that a parent mutation reads and {childDocuments} more that a component query reads": + async (w, { documentSize, parentDocuments, childDocuments }) => { + await fixture(w); + w.client = httpClient(backend(w).url); + const match = /^(\d+) KiB$/.exec(documentSize); + if (!match) throw new Error(`Unsupported document size ${documentSize}`); + w.size = Number(match[1]) * 1024; + w.parent = parentDocuments; + w.child = childDocuments; + for (let i = 0; i < parentDocuments; i++) + await client(w).mutation(api.probe4.seed, { + group: "parent", + count: 1, + size: w.size, + }); + for (let i = 0; i < childDocuments; i++) + await client(w).mutation( + component ? api.probe4.seedComponent : api.probe4.seed, + { group: "child", count: 1, size: w.size }, + ); + await measure("dataset", { + documentSize, + parentDocuments, + childDocuments, + bytes: w.size, + }); + }, + "a client calls the parent mutation, which reads its documents and then calls the component query": + async (w) => { + w.together = await outcome( + client(w).mutation(api.probe4.parentRead, { + component, + parent: w.parent!, + child: w.child!, + }), + ); + await measure("combinedRead", w.together); + }, + "the call {together}": (w, { together }) => { + expect(verdict(w.together!, "Too many bytes read", "commits")).toBe( + together, + ); + }, + "the parent reading alone {parentAlone} and the component query called alone {childAlone}": + async (w, { parentAlone, childAlone }) => { + const parent = await outcome( + client(w).mutation(api.probe4.parentRead, { + component, + parent: w.parent!, + child: 0, + }), + ); + await measure("parentAlone", parent); + expect(verdict(parent, "Too many bytes read", "commits")).toBe( + parentAlone, + ); + const child = await outcome( + client(w).query(api.probe4.childAlone, { component, count: w.child! }), + ); + await measure("childAlone", child); + expect(verdict(child, "Too many bytes read", "returns")).toBe(childAlone); + const metrics = await client(w).mutation(api.probe4.parentRead, { + component, + parent: 0, + child: w.child!, + }); + await measure("metrics", metrics); + expect(metrics.child.count).toBe(w.child); + expect(metrics.child.payloadBytes).toBe(w.child! * w.size!); + expect( + metrics.after.documentsRead.used - metrics.before.documentsRead.used, + ).toBe( + metrics.child.after.documentsRead.used - + metrics.child.before.documentsRead.used, + ); + expect(metrics.after.bytesRead.used - metrics.before.bytesRead.used).toBe( + metrics.child.after.bytesRead.used - + metrics.child.before.bytesRead.used, + ); + expect( + metrics.after.documentsRead.used - metrics.before.documentsRead.used, + ).toBe(w.child); + expect( + metrics.after.bytesRead.used - metrics.before.bytesRead.used, + ).toBeGreaterThanOrEqual(metrics.child.payloadBytes); + }, + "the same split of bytes written by a parent mutation and a component mutation {writesTogether}": + async (w, { writesTogether }) => { + const result = await outcome( + client(w).mutation(api.probe4.parentWrite, { + component, + parent: w.parent!, + child: w.child!, + size: w.size!, + }), + ); + await measure("combinedWrite", result); + expect(verdict(result, "Too many bytes written", "commits")).toBe( + writesTogether, + ); + const written = await client(w).query(api.probe4.writtenCount, { + component, + }); + await measure("writtenRowsAfterFailure", written); + expect(written).toBe(0); + }, +}); diff --git a/tests/native/f13-transactions-have-limits.probe-4-nested-call-shares-limits.test.ts b/tests/native/f13-transactions-have-limits.probe-4-nested-call-shares-limits.test.ts new file mode 100644 index 0000000..e13e445 --- /dev/null +++ b/tests/native/f13-transactions-have-limits.probe-4-nested-call-shares-limits.test.ts @@ -0,0 +1,170 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { backend, client, fixture } from "./world.js"; +import type { World } from "./world.js"; +import { httpClient } from "../../harness/clients.js"; +import type { ReadResult } from "../../fixture/convex/probe/probe4.js"; +import type { TransactionMetrics } from "convex/server"; +import { probe4NestedCallSharesLimitsContract as contract } from "../../generated/contracts/facts.f13-transactions-have-limits.probe-4-nested-call-shares-limits.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f13-transactions-have-limits.probe-4-nested-call-shares-limits", + ), + verifies: ref( + "spec:facts.f13-transactions-have-limits.probe-4-nested-call-shares-limits", + ), +}); +void anchor; +interface ProbeWorld extends World { + size?: number; + parent?: number; + child?: number; + together?: { error?: string }; + parentResult?: { + parentCount: number; + before: TransactionMetrics; + after: TransactionMetrics; + child: ReadResult; + }; + childResult?: ReadResult; +} +const component = false; +async function measure(name: string, value: unknown) { + await recordMeasurement( + contract.title, + `probe4.${component ? "component" : "nested"}.${name}`, + value, + ); +} +async function outcome(call: Promise) { + try { + await call; + return {}; + } catch (error) { + return { error: String(error) }; + } +} +function verdict( + result: { error?: string }, + fragment: string, + success: string, +) { + return result.error?.includes(fragment) + ? `fails on the ${fragment === "Too many bytes read" ? "bytes-read" : "bytes-written"} limit` + : (result.error ?? success); +} +bindExample(contract, (): ProbeWorld => ({}), { + "documents of {documentSize} each, {parentDocuments} that a parent mutation reads and {childDocuments} more that a nested query reads": + async (w, { documentSize, parentDocuments, childDocuments }) => { + await fixture(w); + w.client = httpClient(backend(w).url); + const match = /^(\d+) KiB$/.exec(documentSize); + if (!match) throw new Error(`Unsupported document size ${documentSize}`); + w.size = Number(match[1]) * 1024; + w.parent = parentDocuments; + w.child = childDocuments; + for (let i = 0; i < parentDocuments; i++) + await client(w).mutation(api.probe4.seed, { + group: "parent", + count: 1, + size: w.size, + }); + for (let i = 0; i < childDocuments; i++) + await client(w).mutation( + component ? api.probe4.seedComponent : api.probe4.seed, + { group: "child", count: 1, size: w.size }, + ); + await measure("dataset", { + documentSize, + parentDocuments, + childDocuments, + bytes: w.size, + }); + }, + "a client calls the parent mutation, which reads its documents and then calls the nested query through ctx.runQuery": + async (w) => { + w.together = await outcome( + client(w).mutation(api.probe4.parentRead, { + component, + parent: w.parent!, + child: w.child!, + }), + ); + await measure("combinedRead", w.together); + }, + "the call {together}": (w, { together }) => { + expect(verdict(w.together!, "Too many bytes read", "commits")).toBe( + together, + ); + }, + "the parent reading alone {parentAlone} and the nested query called alone {childAlone}": + async (w, { parentAlone, childAlone }) => { + const parent = await outcome( + client(w).mutation(api.probe4.parentRead, { + component, + parent: w.parent!, + child: 0, + }), + ); + await measure("parentAlone", parent); + expect(verdict(parent, "Too many bytes read", "commits")).toBe( + parentAlone, + ); + const child = await outcome( + client(w).query(api.probe4.childAlone, { component, count: w.child! }), + ); + await measure("childAlone", child); + expect(verdict(child, "Too many bytes read", "returns")).toBe(childAlone); + const metrics = await client(w).mutation(api.probe4.parentRead, { + component, + parent: 0, + child: w.child!, + }); + await measure("metrics", metrics); + expect(metrics.child.count).toBe(w.child); + expect(metrics.child.payloadBytes).toBe(w.child! * w.size!); + expect( + metrics.after.documentsRead.used - metrics.before.documentsRead.used, + ).toBe( + metrics.child.after.documentsRead.used - + metrics.child.before.documentsRead.used, + ); + expect(metrics.after.bytesRead.used - metrics.before.bytesRead.used).toBe( + metrics.child.after.bytesRead.used - + metrics.child.before.bytesRead.used, + ); + expect( + metrics.after.documentsRead.used - metrics.before.documentsRead.used, + ).toBe(w.child); + expect( + metrics.after.bytesRead.used - metrics.before.bytesRead.used, + ).toBeGreaterThanOrEqual(metrics.child.payloadBytes); + }, + "the same split of bytes written by a parent mutation and a nested mutation {writesTogether}": + async (w, { writesTogether }) => { + const result = await outcome( + client(w).mutation(api.probe4.parentWrite, { + component, + parent: w.parent!, + child: w.child!, + size: w.size!, + }), + ); + await measure("combinedWrite", result); + expect(verdict(result, "Too many bytes written", "commits")).toBe( + writesTogether, + ); + const written = await client(w).query(api.probe4.writtenCount, { + component, + }); + await measure("writtenRowsAfterFailure", written); + expect(written).toBe(0); + }, +}); diff --git a/tests/native/f13-transactions-have-limits.probe-4-nesting-depth.test.ts b/tests/native/f13-transactions-have-limits.probe-4-nesting-depth.test.ts new file mode 100644 index 0000000..3029c4f --- /dev/null +++ b/tests/native/f13-transactions-have-limits.probe-4-nesting-depth.test.ts @@ -0,0 +1,69 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { backend, client, fixture } from "./world.js"; +import type { World } from "./world.js"; +import { httpClient } from "../../harness/clients.js"; + +import { probe4NestingDepthContract as contract } from "../../generated/contracts/facts.f13-transactions-have-limits.probe-4-nesting-depth.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f13-transactions-have-limits.probe-4-nesting-depth", + ), + verifies: ref( + "spec:facts.f13-transactions-have-limits.probe-4-nesting-depth", + ), +}); +void anchor; +interface ProbeWorld extends World { + deepest?: number; + outcomes?: { depth: number; committed: boolean; error?: string }[]; +} + +bindExample(contract, (): ProbeWorld => ({}), { + "a mutation that calls itself through ctx.runMutation until its call stack holds a requested number of functions": + async (w) => { + await fixture(w); + w.client = httpClient(backend(w).url); + }, + "a client asks for each number from 1 upward": async (w) => { + w.deepest = 0; + w.outcomes = []; + for (let depth = 1; depth <= 12; depth++) { + let error: string | undefined; + try { + await client(w).mutation(api.probe4.depth, { depth }); + w.deepest = depth; + } catch (caught) { + error = String(caught); + } + const result = { + depth, + committed: error === undefined, + ...(error === undefined ? {} : { error }), + }; + w.outcomes.push(result); + await recordMeasurement(contract.title, "probe4.depth.trial", result); + } + await recordMeasurement( + contract.title, + "probe4.depth.firstFailure", + w.outcomes.find((r) => !r.committed) ?? null, + ); + await recordMeasurement( + contract.title, + "probe4.depth.deepestCommitted", + w.deepest, + ); + }, + "the deepest call stack that commits holds {deepestStack} functions, the top-level mutation included": + (w, { deepestStack }) => { + expect(w.deepest, JSON.stringify(w.outcomes)).toBe(deepestStack); + }, +}); diff --git a/tests/native/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.test.ts b/tests/native/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.test.ts new file mode 100644 index 0000000..b4b49c2 --- /dev/null +++ b/tests/native/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.test.ts @@ -0,0 +1,127 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { backend, client, fixture } from "./world.js"; +import type { World } from "./world.js"; +import { httpClient } from "../../harness/clients.js"; +import { ConvexError } from "convex/values"; +import { isDeepStrictEqual } from "node:util"; +import { probe2ComponentBoundaryContract as contract } from "../../generated/contracts/facts.f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary", + ), + verifies: ref( + "spec:facts.f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary", + ), +}); +void anchor; +interface ProbeWorld extends World { + data?: { + code: string; + number: number; + boolean: boolean; + nil: null; + array: (number | string)[]; + nested: { value: string }; + integer: bigint; + }; + caught?: { + isConvexError: boolean; + data: unknown; + message: string; + property: unknown; + } | null; +} +const component = true; +async function measure(name: string, value: unknown) { + await recordMeasurement( + contract.title, + `probe2.${component ? "component" : "nested"}.${name}`, + value, + ); +} +function args(w: ProbeWorld, ordinary = false) { + return { component, data: w.data!, ordinary }; +} +bindExample(contract, (): ProbeWorld => ({}), { + "a mutation inside a component that writes one row and then throws a ConvexError carrying structured data with code {code}": + async (w, { code }) => { + await fixture(w); + w.client = httpClient(backend(w).url); + w.data = { + code, + number: 12.5, + boolean: true, + nil: null, + array: [1, "two"], + nested: { value: "inside" }, + integer: 9223372036854775807n, + }; + }, + "a parent mutation calls it through the component API and a client calls the parent": + async (w) => { + w.caught = await client(w).mutation(api.probe2.catching, args(w)); + await measure("parentCaught", w.caught); + }, + "the parent's catch reads the thrown data unchanged {parentReadsData}": + async (w, { parentReadsData }) => { + expect(w.caught?.isConvexError).toBe(parentReadsData); + expect(isDeepStrictEqual(w.caught?.data, w.data)).toBe(parentReadsData); + }, + "a client whose parent lets the error pass reads the thrown data unchanged {clientReadsData}": + async (w, { clientReadsData }) => { + let caught: unknown; + try { + await client(w).mutation(api.probe2.passing, args(w)); + } catch (error) { + caught = error; + } + const data = caught instanceof ConvexError ? caught.data : null; + await measure("clientCaught", { + isConvexError: caught instanceof ConvexError, + data, + message: String(caught), + }); + expect(caught instanceof ConvexError).toBe(clientReadsData); + expect(isDeepStrictEqual(data, w.data)).toBe(clientReadsData); + }, + "the row the component's mutation wrote is stored after the parent caught the error and committed {childRowStored}": + async (w, { childRowStored }) => { + const rows = await backend(w).readTable( + "probe2Writes", + component ? "probe" : undefined, + ); + await measure("storedChildRows", rows); + expect( + rows.some((row) => (row as { marker: string }).marker === "child"), + ).toBe(childRowStored); + const parent = await backend(w).readTable("probe2Writes"); + expect( + parent.some((row) => (row as { marker: string }).marker === "parent"), + ).toBe(true); + const ordinaryParent = await client(w).mutation( + api.probe2.catching, + args(w, true), + ); + await measure("ordinaryParent", ordinaryParent); + let ordinary: unknown; + try { + await client(w).mutation(api.probe2.passing, args(w, true)); + } catch (error) { + ordinary = error; + } + await measure("ordinaryClient", { + message: String(ordinary), + property: + (ordinary as { probeProperty?: unknown } | undefined) + ?.probeProperty ?? null, + }); + }, +}); diff --git a/tests/native/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.test.ts b/tests/native/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.test.ts new file mode 100644 index 0000000..6847e92 --- /dev/null +++ b/tests/native/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.test.ts @@ -0,0 +1,127 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { backend, client, fixture } from "./world.js"; +import type { World } from "./world.js"; +import { httpClient } from "../../harness/clients.js"; +import { ConvexError } from "convex/values"; +import { isDeepStrictEqual } from "node:util"; +import { probe2NestedMutationContract as contract } from "../../generated/contracts/facts.f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation", + ), + verifies: ref( + "spec:facts.f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation", + ), +}); +void anchor; +interface ProbeWorld extends World { + data?: { + code: string; + number: number; + boolean: boolean; + nil: null; + array: (number | string)[]; + nested: { value: string }; + integer: bigint; + }; + caught?: { + isConvexError: boolean; + data: unknown; + message: string; + property: unknown; + } | null; +} +const component = false; +async function measure(name: string, value: unknown) { + await recordMeasurement( + contract.title, + `probe2.${component ? "component" : "nested"}.${name}`, + value, + ); +} +function args(w: ProbeWorld, ordinary = false) { + return { component, data: w.data!, ordinary }; +} +bindExample(contract, (): ProbeWorld => ({}), { + "a mutation that writes one row and then throws a ConvexError carrying structured data with code {code}": + async (w, { code }) => { + await fixture(w); + w.client = httpClient(backend(w).url); + w.data = { + code, + number: 12.5, + boolean: true, + nil: null, + array: [1, "two"], + nested: { value: "inside" }, + integer: 9223372036854775807n, + }; + }, + "a parent mutation calls it through ctx.runMutation and a client calls the parent": + async (w) => { + w.caught = await client(w).mutation(api.probe2.catching, args(w)); + await measure("parentCaught", w.caught); + }, + "the parent's catch reads the thrown data unchanged {parentReadsData}": + async (w, { parentReadsData }) => { + expect(w.caught?.isConvexError).toBe(parentReadsData); + expect(isDeepStrictEqual(w.caught?.data, w.data)).toBe(parentReadsData); + }, + "a client whose parent lets the error pass reads the thrown data unchanged {clientReadsData}": + async (w, { clientReadsData }) => { + let caught: unknown; + try { + await client(w).mutation(api.probe2.passing, args(w)); + } catch (error) { + caught = error; + } + const data = caught instanceof ConvexError ? caught.data : null; + await measure("clientCaught", { + isConvexError: caught instanceof ConvexError, + data, + message: String(caught), + }); + expect(caught instanceof ConvexError).toBe(clientReadsData); + expect(isDeepStrictEqual(data, w.data)).toBe(clientReadsData); + }, + "the row the throwing mutation wrote is stored after the parent caught the error and committed {childRowStored}": + async (w, { childRowStored }) => { + const rows = await backend(w).readTable( + "probe2Writes", + component ? "probe" : undefined, + ); + await measure("storedChildRows", rows); + expect( + rows.some((row) => (row as { marker: string }).marker === "child"), + ).toBe(childRowStored); + const parent = await backend(w).readTable("probe2Writes"); + expect( + parent.some((row) => (row as { marker: string }).marker === "parent"), + ).toBe(true); + const ordinaryParent = await client(w).mutation( + api.probe2.catching, + args(w, true), + ); + await measure("ordinaryParent", ordinaryParent); + let ordinary: unknown; + try { + await client(w).mutation(api.probe2.passing, args(w, true)); + } catch (error) { + ordinary = error; + } + await measure("ordinaryClient", { + message: String(ordinary), + property: + (ordinary as { probeProperty?: unknown } | undefined) + ?.probeProperty ?? null, + }); + }, +}); diff --git a/tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.test.ts b/tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.test.ts new file mode 100644 index 0000000..288bc12 --- /dev/null +++ b/tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.test.ts @@ -0,0 +1,99 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { client } from "./world.js"; + +import { + setup, + seed, + page, + loaded, + joined, + insertInside, + consistent, +} from "./probe5-support.js"; +import type { Probe5World } from "./probe5-support.js"; + +import { probe5PagesStayContiguousContract as contract } from "../../generated/contracts/facts.f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous", + ), + verifies: ref( + "spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous", + ), +}); +void anchor; +type ProbeWorld = Probe5World; +async function measure(name: string, value: unknown) { + await recordMeasurement(contract.title, `probe5.${name}`, value); +} + +bindExample(contract, (): ProbeWorld => ({}), { + "a component list of {rows} rows that a parent query relays from paginator, read in pages of {pageSize}": + async (w, { rows, pageSize }) => { + await setup(w); + w.rows = rows; + w.size = pageSize; + await seed(w, rows); + }, + "a client subscribed to every page, each bounded by the end cursor its first load returned": + async (w) => { + w.pages = []; + let cursor: string | null = null; + for (let i = 0; i < Math.ceil(w.rows! / w.size!); i++) { + const initial = page(w, { cursor, numItems: w.size! }); + const value = await loaded(initial, "initial page with end cursor"); + initial.stop(); + const bounded = page(w, { + cursor, + numItems: w.size!, + endCursor: value.continueCursor, + }); + await loaded(bounded, "bounded page"); + w.pages.push(bounded); + cursor = value.continueCursor; + } + await measure("pages.initial", joined(w)); + }, + "{inserted} rows are inserted inside the first page's range": async ( + w, + { inserted }, + ) => { + await insertInside(w, inserted); + }, + "the subscribed pages together hold every row exactly once and in order {contiguous}": + async (w, { contiguous }) => { + const result = await consistent( + w, + "contiguous subscribed pages after insertion", + ); + await measure("pages.afterInsert", result); + expect( + JSON.stringify(result.actual.map((r) => r._id)) === + JSON.stringify(result.expected.map((r) => r._id)), + ).toBe(contiguous); + }, + "the first page now holds {firstPageRows} rows": async ( + w, + { firstPageRows }, + ) => { + expect(w.pages![0]!.value!.page).toHaveLength(firstPageRows); + const deleted = w.pages![1]!.value!.page[0]!; + await client(w).mutation(api.probe5.remove, { id: deleted._id }); + const result = await consistent( + w, + "contiguous subscribed pages after deletion in second page", + ); + await measure("pages.afterDelete", { deleted, ...result }); + expect(result.actual.map((r) => r._id)).toEqual( + result.expected.map((r) => r._id), + ); + }, +}); diff --git a/tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.test.ts b/tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.test.ts new file mode 100644 index 0000000..d6ee0ef --- /dev/null +++ b/tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.test.ts @@ -0,0 +1,118 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; + +import { recordMeasurement } from "../../harness/evidence.js"; + +import { waitUntil } from "../../harness/wait.js"; +import { + setup, + seed, + page, + loaded, + whole, + joined, + insertInside, +} from "./probe5-support.js"; +import type { Probe5World } from "./probe5-support.js"; + +import { probe5SplitRequiredRelayedContract as contract } from "../../generated/contracts/facts.f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed", + ), + verifies: ref( + "spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed", + ), +}); +void anchor; +type ProbeWorld = Probe5World; +async function measure(name: string, value: unknown) { + await recordMeasurement(contract.title, `probe5.${name}`, value); +} + +bindExample(contract, (): ProbeWorld => ({}), { + "a subscribed page of a component list, bounded by an end cursor and read through the parent with maximumRowsRead {maximumRowsRead}": + async (w, { maximumRowsRead }) => { + await setup(w); + w.cap = maximumRowsRead; + w.size = Math.floor(maximumRowsRead / 2); + await seed(w, maximumRowsRead + 10); + const initial = page(w, { cursor: null, numItems: w.size }); + const result = await loaded(initial, "initial split range"); + initial.stop(); + w.cursor = result.continueCursor; + w.pages = [ + page(w, { + cursor: null, + endCursor: w.cursor, + numItems: w.size, + maximumRowsRead, + }), + ]; + await loaded(w.pages[0]!, "bounded capped page"); + w.rangeRows = w.pages[0]!.value!.page.length; + }, + "rows are inserted inside the page's range until it holds {rowsInRange} rows": + async (w, { rowsInRange }) => { + await insertInside(w, rowsInRange - w.rangeRows!); + w.rangeRows = rowsInRange; + }, + "the page the parent relays carries pageStatus {pageStatus} and a split cursor": + async (w, { pageStatus }) => { + await waitUntil( + "SplitRequired and split cursor on parent subscription", + () => { + const s = w.pages![0]!; + if (s.error) throw s.error; + return ( + s.value?.pageStatus === pageStatus && + typeof s.value?.splitCursor === "string" + ); + }, + 5000, + ); + const result = w.pages![0]!.value!; + w.split = result.splitCursor!; + await measure("split.parentResult", result); + expect(result.pageStatus).toBe(pageStatus); + expect(typeof result.splitCursor).toBe("string"); + }, + "the two pages on either side of the split cursor together hold every row exactly once and in order {contiguous}": + async (w, { contiguous }) => { + w.pages![0]!.stop(); + w.pages = [ + page(w, { + cursor: null, + endCursor: w.split!, + numItems: w.size!, + maximumRowsRead: w.cap!, + }), + page(w, { + cursor: w.split!, + endCursor: w.cursor!, + numItems: w.size!, + maximumRowsRead: w.cap!, + }), + ]; + await Promise.all(w.pages.map((s) => loaded(s, "split half"))); + const original = (await whole(w)).filter((r) => r.position < w.size!); + await waitUntil( + "both split halves contain every original range row", + () => + JSON.stringify(joined(w).map((r) => r._id)) === + JSON.stringify(original.map((r) => r._id)), + 5000, + ); + await measure("split.halves", { actual: joined(w), expected: original }); + expect( + JSON.stringify(joined(w).map((r) => r._id)) === + JSON.stringify(original.map((r) => r._id)), + ).toBe(contiguous); + expect(joined(w)).toHaveLength(w.rangeRows!); + }, +}); diff --git a/tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.test.ts b/tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.test.ts new file mode 100644 index 0000000..5344ea9 --- /dev/null +++ b/tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.test.ts @@ -0,0 +1,71 @@ +import { expect } from "vitest"; +import { + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { client } from "./world.js"; + +import { onTestFinished } from "vitest"; +import { waitUntil } from "../../harness/wait.js"; +import { setup, seed, loaded } from "./probe5-support.js"; +import type { Probe5World } from "./probe5-support.js"; + +import { probe5SubscriptionUpdatesContract as contract } from "../../generated/contracts/facts.f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.contract.js"; +const anchor = specTest({ + id: testAnchorId( + "test:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates", + ), + verifies: ref( + "spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates", + ), +}); +void anchor; +type ProbeWorld = Probe5World; +async function measure(name: string, value: unknown) { + await recordMeasurement(contract.title, `probe5.${name}`, value); +} + +bindExample(contract, (): ProbeWorld => ({}), { + "a client subscribed to a parent query that returns what a component query reads": + async (w) => { + await setup(w); + await seed(w, 1); + w.first = { stop: () => {} }; + w.first.stop = w.ws!.onUpdate( + api.probe5.first, + {}, + (value) => { + w.first!.value = value; + }, + (error) => { + w.first!.error = error; + }, + ); + onTestFinished(() => w.first!.stop()); + await loaded(w.first, "initial component row"); + await measure("subscription.initial", w.first.value); + }, + "a mutation changes the document inside the component": async (w) => { + await client(w).mutation(api.probe5.change, { + id: w.first!.value!._id, + value: "changed", + }); + }, + "the subscription delivers the changed value without the client asking again {updated}": + async (w, { updated }) => { + await waitUntil( + "component change on existing parent subscription", + () => { + if (w.first!.error) throw w.first!.error; + return w.first!.value?.value === "changed"; + }, + 5000, + ); + await measure("subscription.updated", w.first!.value); + expect(w.first!.value?.value === "changed").toBe(updated); + }, +}); diff --git a/tests/native/probe1-transport.ts b/tests/native/probe1-transport.ts new file mode 100644 index 0000000..4b9e981 --- /dev/null +++ b/tests/native/probe1-transport.ts @@ -0,0 +1,77 @@ +// Controls real client transport timing. Requests still go to the native backend. +// Holding responses keeps the call pending; pausing new sockets lets admin access +// read the restarted storage before the open client can replay its request. +export function controlledTransport() { + const NativeWebSocket = globalThis.WebSocket; + let holdMessages = false; + let pauseConnect = false; + const sockets = new Set(); + class Socket { + onopen: WebSocket["onopen"] = null; + onclose: WebSocket["onclose"] = null; + onerror: WebSocket["onerror"] = null; + onmessage: WebSocket["onmessage"] = null; + private native?: WebSocket; + private messages: MessageEvent[] = []; + private closed = false; + constructor(private url: string | URL) { + sockets.add(this); + if (!pauseConnect) this.connect(); + } + connect() { + if (this.native || this.closed) return; + const ws = new NativeWebSocket(this.url); + this.native = ws; + ws.onopen = (e) => this.onopen?.call(ws, e); + ws.onerror = (e) => this.onerror?.call(ws, e); + ws.onclose = (e) => { + this.messages = []; + sockets.delete(this); + this.onclose?.call(ws, e); + }; + ws.onmessage = (e) => { + if (holdMessages) this.messages.push(e); + else this.onmessage?.call(ws, e); + }; + } + flush() { + for (const e of this.messages) this.onmessage?.call(this.native!, e); + this.messages = []; + } + send(data: string) { + if (!this.native) throw new Error("Transport send before connect"); + this.native.send(data); + } + close() { + this.closed = true; + this.messages = []; + if (this.native) this.native.close(); + else { + sockets.delete(this); + queueMicrotask(() => + this.onclose?.call( + this as unknown as WebSocket, + new CloseEvent("close"), + ), + ); + } + } + } + return { + constructor: Socket as unknown as typeof WebSocket, + holdResponses() { + holdMessages = true; + }, + pauseReconnections() { + pauseConnect = true; + }, + resume() { + holdMessages = false; + pauseConnect = false; + for (const socket of sockets) { + socket.connect(); + socket.flush(); + } + }, + }; +} diff --git a/tests/native/probe5-hook.test.ts b/tests/native/probe5-hook.test.ts new file mode 100644 index 0000000..1dd5c8a --- /dev/null +++ b/tests/native/probe5-hook.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment happy-dom +import { readFile } from "node:fs/promises"; +import { createElement } from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { usePaginatedQuery } from "convex-helpers/react"; +import { expect, test } from "vitest"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { withBackend } from "../../harness/backend.js"; +import { httpClient, websocketClient } from "../../harness/clients.js"; +import { recordMeasurement } from "../../harness/evidence.js"; +import { probe5SplitRequiredRelayedContract as contract } from "../../generated/contracts/facts.f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.contract.js"; +import type { Row, Page } from "./probe5-support.js"; +const name = + "Probe 5: usePaginatedQuery shows every row after a component split"; +test(name, async () => { + const packages = [ + "react", + "react-dom", + "@testing-library/react", + "happy-dom", + ]; + const versions = Object.fromEntries( + await Promise.all( + packages.map(async (packageName) => { + const json = JSON.parse( + await readFile( + new URL( + `../../node_modules/${packageName}/package.json`, + import.meta.url, + ), + "utf8", + ), + ) as { version: string }; + return [packageName, json.version]; + }), + ), + ); + await recordMeasurement(name, "probe5.hook.runtime", { + environment: "happy-dom", + versions, + }); + const given = contract.steps[0]!.params; + const when = contract.steps[1]!.params; + const split = contract.steps[2]!.params; + const final = contract.steps[3]!.params; + // The contract's step-indexed params are a union outside bindExample. + if ( + !("maximumRowsRead" in given) || + !("rowsInRange" in when) || + !("pageStatus" in split) || + !("contiguous" in final) + ) + throw new Error("Split contract step layout changed"); + const cap = given.maximumRowsRead; + const size = Math.floor(cap / 2); + await withBackend(async (backend) => { + const http = httpClient(backend.url); + await http.mutation(api.probe5.write, { + rows: Array.from({ length: cap + size }, (_, position) => ({ + position, + value: `row-${position}`, + })), + }); + const react = new ConvexReactClient(backend.url, { + unsavedChangesWarning: false, + }); + const ws = websocketClient(backend.url); + const measure = async (key: string, value: unknown) => + recordMeasurement(name, `probe5.hook.${key}`, value); + let unmount = () => {}; + try { + let first: Page | undefined; + let subscriptionError: Error | undefined; + const initial = ws.onUpdate( + api.probe5.page, + { paginationOpts: { cursor: null, numItems: size } }, + (value) => { + first = value; + }, + (error) => { + subscriptionError = error; + }, + ); + await waitFor( + () => { + if (subscriptionError) throw subscriptionError; + expect(first).toBeDefined(); + }, + { timeout: 5000 }, + ); + initial(); + let splitSeen = false; + const observe = ws.onUpdate( + api.probe5.page, + { + paginationOpts: { + cursor: null, + numItems: size, + endCursor: first!.continueCursor, + maximumRowsRead: cap, + }, + }, + (value) => { + if (value.pageStatus === split.pageStatus) splitSeen = true; + }, + (error) => { + subscriptionError = error; + }, + ); + const rendered = renderHook( + () => + usePaginatedQuery( + api.probe5.page, + { maximumRowsRead: cap }, + { initialNumItems: size }, + ), + { + wrapper: ({ children }) => + createElement(ConvexProvider, { client: react }, children), + }, + ); + unmount = rendered.unmount; + await waitFor( + () => expect(rendered.result.current.results).toHaveLength(size), + { timeout: 5000 }, + ); + for (let loaded = size; loaded < cap + size; loaded += size) { + act(() => rendered.result.current.loadMore(size)); + await waitFor( + () => + expect(rendered.result.current.results).toHaveLength(loaded + size), + { timeout: 5000 }, + ); + } + await http.mutation(api.probe5.write, { + rows: Array.from({ length: when.rowsInRange - size }, (_, i) => ({ + position: (i + 1) / (when.rowsInRange - size + 1), + value: `insert-${i}`, + })), + }); + const expected = ( + (await backend.readTable("rows", "probe")) as Row[] + ).sort((a, b) => a.position - b.position); + await waitFor( + () => { + if (subscriptionError) throw subscriptionError; + expect(splitSeen).toBe(true); + }, + { timeout: 5000 }, + ); + await measure("splitSeen", splitSeen); + try { + await waitFor( + () => + expect( + JSON.stringify( + (rendered.result.current.results as Row[]).map((r) => r._id), + ) === JSON.stringify(expected.map((r) => r._id)), + ).toBe(final.contiguous), + { timeout: 5000 }, + ); + } finally { + const actual = rendered.result.current.results as Row[]; + await measure("finalRows", { actual, expected }); + await measure( + "everyRow", + JSON.stringify(actual.map((r) => r._id)) === + JSON.stringify(expected.map((r) => r._id)), + ); + } + observe(); + } catch (error) { + await measure("failure", String(error)); + throw error; + } finally { + unmount(); + await react.close(); + await ws.close(); + } + }); +}); diff --git a/tests/native/probe5-support.ts b/tests/native/probe5-support.ts new file mode 100644 index 0000000..c3d9412 --- /dev/null +++ b/tests/native/probe5-support.ts @@ -0,0 +1,107 @@ +import { onTestFinished } from "vitest"; +import type { ConvexClient } from "convex/browser"; +import type { PaginationOptions, PaginationResult } from "convex/server"; +import { api } from "../../fixture/convex/_generated/api.js"; +import { backend, fixture } from "./world.js"; +import type { World } from "./world.js"; +import { websocketClient, httpClient } from "../../harness/clients.js"; +import { waitUntil } from "../../harness/wait.js"; +export interface Row { + _id: string; + _creationTime: number; + position: number; + value: string; +} +export type Page = PaginationResult; +export interface Subscription { + value?: T; + error?: Error; + stop: () => void; +} +export interface Probe5World extends World { + ws?: ConvexClient; + pages?: Subscription[]; + size?: number; + rows?: number; + cap?: number; + cursor?: string; + rangeRows?: number; + split?: string; + first?: Subscription; +} +export async function setup(w: Probe5World) { + await fixture(w); + w.client = httpClient(backend(w).url); + w.ws = websocketClient(backend(w).url); + onTestFinished(() => w.ws!.close()); +} +export async function seed(w: Probe5World, count: number) { + await w.client!.mutation(api.probe5.write, { + rows: Array.from({ length: count }, (_, position) => ({ + position, + value: `row-${position}`, + })), + }); +} +export function page( + w: Probe5World, + options: PaginationOptions, +): Subscription { + const state: Subscription = { stop: () => {} }; + state.stop = w.ws!.onUpdate( + api.probe5.page, + { paginationOpts: options }, + (value) => { + state.value = value as Page; + }, + (error) => { + state.error = error; + }, + ); + onTestFinished(() => state.stop()); + return state; +} +export async function loaded(s: Subscription, description: string) { + await waitUntil( + description, + () => { + if (s.error) throw s.error; + return s.value !== undefined; + }, + 5000, + ); + return s.value!; +} +export async function whole(w: Probe5World): Promise { + return ((await backend(w).readTable("rows", "probe")) as Row[]).sort( + (a, b) => + a.position - b.position || + a._creationTime - b._creationTime || + a._id.localeCompare(b._id), + ); +} +export function joined(w: Probe5World) { + return w.pages!.flatMap((p) => { + if (p.error) throw p.error; + return p.value?.page ?? []; + }); +} +export async function insertInside(w: Probe5World, count: number) { + await w.client!.mutation(api.probe5.write, { + rows: Array.from({ length: count }, (_, i) => ({ + position: (i + 1) / (count + 1), + value: `insert-${i}`, + })), + }); +} +export async function consistent(w: Probe5World, description: string) { + const expected = await whole(w); + await waitUntil( + description, + () => + JSON.stringify(joined(w).map((r) => r._id)) === + JSON.stringify(expected.map((r) => r._id)), + 5000, + ); + return { actual: joined(w), expected }; +} diff --git a/tests/simulator/probe1-transport.test.ts b/tests/simulator/probe1-transport.test.ts new file mode 100644 index 0000000..e0db9e0 --- /dev/null +++ b/tests/simulator/probe1-transport.test.ts @@ -0,0 +1,62 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { controlledTransport } from "../native/probe1-transport.js"; +class FakeSocket { + static instances: FakeSocket[] = []; + onopen: WebSocket["onopen"] = null; + onclose: WebSocket["onclose"] = null; + onerror: WebSocket["onerror"] = null; + onmessage: WebSocket["onmessage"] = null; + sent: string[] = []; + constructor(readonly url: string) { + FakeSocket.instances.push(this); + } + send(data: string) { + this.sent.push(data); + } + close() { + this.onclose?.call(this as unknown as WebSocket, {} as CloseEvent); + } + message(data: string) { + this.onmessage?.call( + this as unknown as WebSocket, + { data } as MessageEvent, + ); + } +} +afterEach(() => { + vi.unstubAllGlobals(); + FakeSocket.instances = []; +}); +test("simulator: response hold passes outgoing requests and releases live responses", () => { + vi.stubGlobal("WebSocket", FakeSocket); + const transport = controlledTransport(); + const socket = new transport.constructor("ws://fixture"); + const received: string[] = []; + socket.onmessage = (e) => received.push(e.data as string); + transport.holdResponses(); + socket.send("mutation request"); + FakeSocket.instances[0]!.message("confirmation"); + expect(FakeSocket.instances[0]!.sent).toEqual(["mutation request"]); + expect(received).toEqual([]); + transport.resume(); + expect(received).toEqual(["confirmation"]); +}); +test("simulator: dead responses are discarded and reconnection waits for release", () => { + vi.stubGlobal("WebSocket", FakeSocket); + const transport = controlledTransport(); + const old = new transport.constructor("ws://fixture"); + const received: string[] = []; + old.onmessage = (e) => received.push(e.data as string); + transport.holdResponses(); + FakeSocket.instances[0]!.message("old response"); + FakeSocket.instances[0]!.close(); + transport.pauseReconnections(); + const next = new transport.constructor("ws://fixture"); + next.onmessage = (e) => received.push(e.data as string); + expect(FakeSocket.instances).toHaveLength(1); + transport.resume(); + expect(FakeSocket.instances).toHaveLength(2); + expect(received).toEqual([]); + FakeSocket.instances[1]!.message("replayed confirmation"); + expect(received).toEqual(["replayed confirmation"]); +}); From 61046dce3913254d1d862a4323d45c8d7f64c3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Thu, 1 Oct 2026 17:00:58 +0200 Subject: [PATCH 04/28] design: slice S0 gives the native harness its own Spec and rewrites the probe examples Rulings S1 to S7 on the three reviews of the foundation commit, and the rulings on the first native run of the probes. - spec:platform.native-harness owns E-13 and E-15, one rule per bullet. The acceptance contract keeps the doc's rules and E-14. The four harness examples move under the new Spec; their test anchors follow. - The issuer is the identity source, not an adjustment. The fixture composition shares the libraries' tables, not the schemas. A test hook is defined. A refusal shows its own error, nothing stored and the target existing on the same deployment. - spec:facts.probe-plan gains extension E-16: bound values are the expectation written before the first run, a different first result is recorded on the fact, an example fails when its boundary is not reached. - Probe 1 names its client and must reach each boundary. Probe 3 says what a local backend cannot say. Probe 4's depth binds 9 as observed. Probe 5's hook check is its own example. - Seven findings added to the ledger as fixed and unreviewed. The probe tests do not match the rewritten examples yet. The fold-in follows. Co-Authored-By: Claude Fable 5.1 --- design/PLAN.md | 1 + design/README.md | 13 +- design/reviews/consensus-ledger.json | 135 ++++++++++++++++++ ...acceptance-production-configuration.sdp.md | 4 +- .../specs/application/first-experiment.sdp.md | 2 +- .../command/tenancy-and-authority.sdp.md | 2 +- ...robe-3-component-call-from-mutation.sdp.md | 7 +- ...s.probe-3-component-call-from-query.sdp.md | 6 +- ...ckend-restart-with-pending-mutation.sdp.md | 9 +- ...client-closed-with-pending-mutation.sdp.md | 7 +- ...confirmed.probe-1-http-client-retry.sdp.md | 12 +- ...robe-4-component-call-shares-limits.sdp.md | 2 + ...s.probe-4-nested-call-shares-limits.sdp.md | 2 + ...s-have-limits.probe-4-nesting-depth.sdp.md | 7 +- ...boundary.probe-2-component-boundary.sdp.md | 9 +- ...nt-boundary.probe-2-nested-mutation.sdp.md | 9 +- ...probe-5-hook-splits-across-boundary.sdp.md | 29 ++++ ...ctive.probe-5-pages-stay-contiguous.sdp.md | 2 +- ...tive.probe-5-split-required-relayed.sdp.md | 8 +- ...active.probe-5-subscription-updates.sdp.md | 4 +- design/specs/facts/probe-plan.sdp.md | 13 +- design/specs/foundation.pack.sdp.md | 14 +- ...y-client-refused-component-function.sdp.md | 29 ---- ...ry-client-refused-internal-function.sdp.md | 29 ---- .../specs/platform/acceptance-contract.sdp.md | 21 +-- ...identity-reaches-internal-function.sdp.md} | 11 +- ...xture-issuer-token-yields-identity.sdp.md} | 8 +- ...y-client-refused-component-function.sdp.md | 33 +++++ ...ry-client-refused-internal-function.sdp.md | 32 +++++ design/specs/platform/native-harness.sdp.md | 66 +++++++++ ...identity-reaches-internal-function.test.ts | 4 +- ...xture-issuer-token-yields-identity.test.ts | 4 +- ...-client-refused-component-function.test.ts | 4 +- ...y-client-refused-internal-function.test.ts | 4 +- 34 files changed, 399 insertions(+), 143 deletions(-) create mode 100644 design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary.sdp.md delete mode 100644 design/specs/platform/acceptance-contract.ordinary-client-refused-component-function.sdp.md delete mode 100644 design/specs/platform/acceptance-contract.ordinary-client-refused-internal-function.sdp.md rename design/specs/platform/{acceptance-contract.admin-key-acting-as-identity-reaches-internal-function.sdp.md => native-harness.admin-key-acting-as-identity-reaches-internal-function.sdp.md} (72%) rename design/specs/platform/{acceptance-contract.fixture-issuer-token-yields-identity.sdp.md => native-harness.fixture-issuer-token-yields-identity.sdp.md} (80%) create mode 100644 design/specs/platform/native-harness.ordinary-client-refused-component-function.sdp.md create mode 100644 design/specs/platform/native-harness.ordinary-client-refused-internal-function.sdp.md create mode 100644 design/specs/platform/native-harness.sdp.md diff --git a/design/PLAN.md b/design/PLAN.md index 9c75f1f..614c0b6 100644 --- a/design/PLAN.md +++ b/design/PLAN.md @@ -158,6 +158,7 @@ Relations shown are the minimum. `epic` means `spec:platform.transactional-domai | `spec:platform.decision-method` | rule | feature | defined | refines epic | The seven rules of "How decisions are made" | | `spec:platform.vocabulary` | model | feature | defined | refines epic | CMS, journal, stream version, receipt, obligation, effect, attempt, generation, operation, baseline event, do-nothing option, plus: context, parent, use case, read model, projection, tenant scope, actor, request key, effect key, needs attention, write pause, layer, profile. OQ6 clashes (receipt, generation) as `[non-blocking]` open questions | | `spec:platform.acceptance-contract` | rule | feature | defined | refines epic | Scenarios test behavior; layer = first layer making it available; the four tiers; fixture app separate from example app; test-only functions never ship; disposable backend per test; the evidence record; readiness stated as specified, implemented, tested under named conditions, operationally accepted | +| `spec:platform.native-harness` | rule | feature | defined | refines acceptance-contract | E-13 and E-15, which the doc does not state: the fixture issuer and ordinary clients, the admin key for setup and reading only, two compositions, faults and assertions, the four controls, the run's record. Added by slice S0 with four examples | | `spec:platform.existing-systems` | rule | feature | defined | refines epic | No deletion or migration authorized; the five bullets | | `spec:laws.law01-sanctioned-writes-only` ... `spec:laws.law12-one-retry-owner` | rule | feature | defined | refines epic (Law 4 also decidedBy D6) | One law each, the sentence verbatim in `## Rule`, the reworded note in narrative | | `spec:decisions.d01-one-mutation-per-operation` ... `spec:decisions.d19-operations-travel-with-capability` | decision | feature | defined | refines epic; dependsOn per table below | Provenance line in narrative; `context` = the concern and the do-nothing option; first `alternative` = the do-nothing option; further `alternative`s = the doc's rejected options; `decision` = the ruling; `rationale`; `consequence` = costs, gaps, open items; the doc's "Probe" lines as `[non-blocking]` open questions naming the probe number | diff --git a/design/README.md b/design/README.md index aacc271..af56a4c 100644 --- a/design/README.md +++ b/design/README.md @@ -22,7 +22,7 @@ Always `npx sdp` or an `npm run` script. A bare `sdp` on macOS resolves to an un The index lists every Spec with its kind, altitude, stated readiness and the floor its structure reaches. Each Spec has one page under `generated/design-review/spec/`, each pack under `generated/design-review/pack/`. The Protocol's root is the repository root, because the graph binds the Specs under `design/specs/` to the tests that verify them, which live outside `design/`. `generated/` at the repository root is gitignored; regenerate it after any edit. -A reading order that works. Start with the epic ([`transactional-domain-platform`](specs/platform/transactional-domain-platform.sdp.md)) and three of its five platform children ([`layers-and-profiles`](specs/platform/layers-and-profiles.sdp.md), [`decision-method`](specs/platform/decision-method.sdp.md), [`vocabulary`](specs/platform/vocabulary.sdp.md)); the other two, [`acceptance-contract`](specs/platform/acceptance-contract.sdp.md) and [`existing-systems`](specs/platform/existing-systems.sdp.md), are best read before the scenario table below. Then the twelve laws in [`specs/laws/`](specs/laws/) and decisions D1 to D12 in [`specs/decisions/`](specs/decisions/). Then go up the layers: the kernel ([`outcome-model`](specs/kernel/outcome-model.sdp.md) first, then [`decider-contract`](specs/kernel/decider-contract.sdp.md) and [`domain-kernel`](specs/kernel/domain-kernel.sdp.md)), the context component and its children in [`specs/context/`](specs/context/), the command pipeline in [`specs/command/`](specs/command/) (read [`actor-and-scope`](specs/command/actor-and-scope.sdp.md) before [`command-pipeline`](specs/command/command-pipeline.sdp.md)), and the composed application in [`specs/application/`](specs/application/). Read the fact ledger and the probe plan in [`specs/facts/`](specs/facts/) before the Layer 3 material, because the obligation module's justification is a probe that has not run. Layers 4 to 6 are trigger, promise and rules only. +A reading order that works. Start with the epic ([`transactional-domain-platform`](specs/platform/transactional-domain-platform.sdp.md)) and three of its five platform children ([`layers-and-profiles`](specs/platform/layers-and-profiles.sdp.md), [`decision-method`](specs/platform/decision-method.sdp.md), [`vocabulary`](specs/platform/vocabulary.sdp.md)); the other two, [`acceptance-contract`](specs/platform/acceptance-contract.sdp.md) and [`existing-systems`](specs/platform/existing-systems.sdp.md), are best read before the scenario table below, and [`native-harness`](specs/platform/native-harness.sdp.md), which refines the acceptance contract, before any native test. Then the twelve laws in [`specs/laws/`](specs/laws/) and decisions D1 to D12 in [`specs/decisions/`](specs/decisions/). Then go up the layers: the kernel ([`outcome-model`](specs/kernel/outcome-model.sdp.md) first, then [`decider-contract`](specs/kernel/decider-contract.sdp.md) and [`domain-kernel`](specs/kernel/domain-kernel.sdp.md)), the context component and its children in [`specs/context/`](specs/context/), the command pipeline in [`specs/command/`](specs/command/) (read [`actor-and-scope`](specs/command/actor-and-scope.sdp.md) before [`command-pipeline`](specs/command/command-pipeline.sdp.md)), and the composed application in [`specs/application/`](specs/application/). Read the fact ledger and the probe plan in [`specs/facts/`](specs/facts/) before the Layer 3 material, because the obligation module's justification is a probe that has not run. Layers 4 to 6 are trigger, promise and rules only. Questions the graph answers better than the files. Pass a recipe body from the Protocol's catalog to `sdp q`, run from the repository root. The catalog ships in the package, at `node_modules/@libar-dev/software-delivery-protocol/docs/agent-surface/recipes.md`: @@ -45,7 +45,7 @@ python3 design/tools/check.py Expected result on the integrated corpus: ``` -185 specs · 5 packs · 17 anchors → 207 nodes · 949 edges (0 errors, 0 warnings) +187 specs · 5 packs · 17 anchors → 209 nodes · 954 edges (0 errors, 0 warnings) validate: 0 errors · 0 warnings (conformance + honesty over the one graph) ``` @@ -82,7 +82,7 @@ Detail follows the build (decision method, rule 4). Full means interfaces, TypeS | Layer | Families | Pack | Specs | |---|---|---|---| -| all | `platform`, `laws`, `decisions`, `facts` | [`foundation`](specs/foundation.pack.sdp.md) | 56 | +| all | `platform`, `laws`, `decisions`, `facts` | [`foundation`](specs/foundation.pack.sdp.md) | 75 | | 0 | `kernel` | [`kernel-and-context`](specs/kernel-and-context.pack.sdp.md) | 7 | | 1 | `context`, `command` | [`kernel-and-context`](specs/kernel-and-context.pack.sdp.md) (18 with the kernel), [`command-pipeline`](specs/command-pipeline.pack.sdp.md) | 11 + 20 | | 2 | `application`, `operations`, `constraints` | [`application`](specs/application.pack.sdp.md) | 31 | @@ -178,7 +178,7 @@ Every row of the doc's acceptance table is an example Spec that `refines` and `v ## The extension register -An extension is a design claim the doc does not make. Each one is three things together: a bullet that starts with `[extension]` and cites its E-number, and a `[non-blocking] Extension E-n` open question in the Intent of the Spec that owns it. The register lists every E-number once. "Owned by" names the Specs that carry the open question and the ruling; other Specs cite the number as a cross-reference without repeating the question. Every extension is a provisional ruling the owner confirms, changes or rejects; none is blocking, because each has a working default. E-15 to E-19, E-27 to E-29, E-48, E-49 and E-59 are unused; E-13 and E-39 were assigned by the integration round from the unassigned and the Package C range, and E-14 by review round 2. +An extension is a design claim the doc does not make. Each one is three things together: a bullet that starts with `[extension]` and cites its E-number, and a `[non-blocking] Extension E-n` open question in the Intent of the Spec that owns it. The register lists every E-number once. "Owned by" names the Specs that carry the open question and the ruling; other Specs cite the number as a cross-reference without repeating the question. Every extension is a provisional ruling the owner confirms, changes or rejects; none is blocking, because each has a working default. E-17 to E-19, E-27 to E-29, E-48, E-49 and E-59 are unused; E-13 and E-39 were assigned by the integration round from the unassigned and the Package C range, E-14 by review round 2, and E-15 and E-16 by slice S0. | E | Owned by | What the doc leaves open, and the option taken | |---|---|---| @@ -194,9 +194,10 @@ An extension is a design claim the doc does not make. Each one is three things t | E-10 | [`obligations.lifecycle-transitions`](specs/obligations/lifecycle-transitions.sdp.md), [`obligations.record-contract`](specs/obligations/record-contract.sdp.md) | The allowed transitions between the six states and who causes each; the `obligations` and `obligationRepairs` tables with seven and two indexes; a 64 KiB inline payload bound; ten late-evidence entries; two attention reasons beyond D13's four, `recoveryFailing` and `chainBound` | | E-11 | [`effects.claim-call-settle`](specs/effects/claim-call-settle.sdp.md) | The signatures of `claimAttempt`, `readForCall`, `callProvider`, `reconcileAttempt`, `settleAttempt`, `expireLease`; the `RepetitionPolicy` union with a fourth value `none` | | E-12 | [`constraints.events-stay-small`](specs/constraints/events-stay-small.sdp.md) | A 16 KiB payload bound measured by the journal's `append`, so a fold of 600 events since the latest baseline stays near 10 MiB under the 16 MiB read ceiling; the reserved `baseline` event is exempt and bounded by its stream type's `budgetBytes`; the 1 second query timeout binds first and the first experiment measures it | -| E-13 | [`platform.acceptance-contract`](specs/platform/acceptance-contract.sdp.md) | The native tier's callers are ordinary clients carrying tokens the harness signs for a fixture issuer; `auth.config.ts` reads the issuer and a data-URI key set from the deployment's environment variables; parity for Sc L2-9 is code path, schema, concurrency, the authority model and Convex's visibility check, and the issuer is the one named difference on every evidence record. The admin key acting as an identity was dropped on 2026-10-01: it passes the visibility check for internal functions | +| E-13 | [`platform.native-harness`](specs/platform/native-harness.sdp.md) | The native tier's callers are ordinary clients carrying tokens the harness signs for a fixture issuer; a composition that runs natively reads its issuer, application ID and a data-URI key set from the deployment's environment variables, which reaches into the production `auth.config.ts`; parity for Sc L2-9 is code path, schema, concurrency, the authority model and Convex's visibility check; the issuer is the identity source and the one named difference on every evidence record. The admin key acting as an identity was dropped on 2026-10-01: it passes the visibility check for internal functions | | E-14 | [`platform.acceptance-contract`](specs/platform/acceptance-contract.sdp.md) | A fifth tier, the build tier: a compile of the fixture app with the change applied that must fail at `tsc` or at the registration check, run before the native suite with no backend; Sc L2-4 is its one scenario | -| E-15 | [`platform.acceptance-contract`](specs/platform/acceptance-contract.sdp.md) | The native harness: the admin key is for setup and reading, never for a caller under test; two compositions, fixture and production; faults only through parts the fixture composition supplies; assertions only on what a client or admin access can read; four controls (the backend process, the clients, the environment variables, fixture functions) and no clock or scheduler switch; the production-configuration run of Sc L2-9 is the scenarios routed to the production composition | +| E-15 | [`platform.native-harness`](specs/platform/native-harness.sdp.md) | The native harness: the admin key is for setup and reading, never for a caller under test; two compositions, fixture and production; faults only through what the fixture composition supplies, and no test hook in the shared libraries; assertions only on what a client or admin access can read, and a refusal shown with its own error, nothing stored and the refused thing existing; four controls (the backend process, the clients, the environment variables, fixture functions) and no clock or scheduler switch; the production-configuration run of Sc L2-9 is the scenarios routed to the production composition; a pass claim cites a record kept in the repository | +| E-16 | [`facts.probe-plan`](specs/facts/probe-plan.sdp.md) | How a probe is written and recorded: example Specs that verify the fact, bound values as the expectation written before the first run, a different first result recorded on the fact and the example then bound to what the release does, a run that must reach its boundary, sizes recorded and not asserted, and what a local backend cannot answer left open on the fact | | E-20 | [`kernel.decider-contract`](specs/kernel/decider-contract.sdp.md), [`kernel.domain-kernel`](specs/kernel/domain-kernel.sdp.md) | The concrete generics, `DomainEvent`, `DecisionActor` as the kernel's structural view of the actor, `DecisionContext { now, actor, facts }`, `DecideResult`, `Invariant`, `Transitions` and `transition`; the kernel imports nothing from Convex and nothing from the command family | | E-21 | [`kernel.outcome-model`](specs/kernel/outcome-model.sdp.md) | `Outcome`, `Rejection`, `CommittedOutcome`, `AffectedRef`; a committed outcome carries at least one event; the reserved codes `staleVersion`, `operationTooLarge` and `entityExists`, the last raised by the adapter for a create planned at expected version 0 whose stream exists | | E-22 | [`context.context-component`](specs/context/context-component.sdp.md) | One registered mutation per sanctioned operation via `defineOperation` with args `{ tenantId, actor, operation, input, facts }`; `contextId` as a code constant equal to the mount name; 256 streams, 800 written documents and 8 MiB of stream documents read or written per call, derived from each stream type's byte budget; `convex-helpers` as the component's dependency; the query and maintenance surface | diff --git a/design/reviews/consensus-ledger.json b/design/reviews/consensus-ledger.json index 69ecb48..7bbb616 100644 --- a/design/reviews/consensus-ledger.json +++ b/design/reviews/consensus-ledger.json @@ -3956,6 +3956,141 @@ "note": "", "slice": "S4", "detector": "judgement" + }, + { + "round": "S0", + "id": "s0-harness-rules-sit-on-the-acceptance-contract", + "lens": "sdp", + "severity": "minor", + "files": [ + "specs/platform/acceptance-contract.sdp.md", + "specs/platform/native-harness.sdp.md", + "specs/foundation.pack.sdp.md", + "README.md" + ], + "issue": "Slice S0 wrote the harness rules of E-13 and E-15 into `spec:platform.acceptance-contract` as seven bullets, several holding two or three rules, and bound the four harness examples to it with `verifies`. The graph then showed the acceptance contract as verified by tests that touch no scenario row, and the issuer as 'the one difference' was stated three times in one Spec. Found by the Opus review of commits a6889c1 and 951874d (minors 2 and 17).", + "fix": "Move the harness rules to a Spec of their own that refines the acceptance contract, one rule per bullet, and re-parent the four examples.", + "status": "fixed", + "unreviewed": true, + "slice": "S0", + "detector": "judgement", + "note": "S0, 2026-10-01, ruling S1. `spec:platform.native-harness` now owns E-13 and E-15, their two open questions, 23 rules and the Design keys `backend`, `identity`, `adminReads`, `localWriteRate` and `evidenceKept`. The acceptance contract keeps the doc's rules and E-14, and one sentence that points at the new Spec. The four examples are `spec:platform.native-harness.`; their test anchors and contract imports follow. Restated in `tenancy-and-authority` (`nativeTierIdentity`), `first-experiment` (the identity rule), the L2-9 example, the foundation pack, README's register and PLAN's inventory, which cite E-13 or E-15 and no longer name the acceptance contract as the owner." + }, + { + "round": "S0", + "id": "s0-issuer-is-both-the-one-difference-and-an-adjustment", + "lens": "sdp", + "severity": "major", + "files": [ + "specs/platform/native-harness.sdp.md", + "specs/application/first-experiment.sdp.md", + "specs/application/first-experiment.native-acceptance-production-configuration.sdp.md" + ], + "issue": "The acceptance contract called the fixture issuer 'the one difference from production' and also required the production-configuration run of Sc L2-9 to have 'no adjusted configuration', while the harness wrote the issuer under `adjustments`. If the issuer is an adjustment, no production-configuration run can satisfy the rule. Opus review, M3.", + "fix": "State once what the issuer is, and have the evidence record follow.", + "status": "fixed", + "unreviewed": true, + "slice": "S0", + "detector": "judgement", + "note": "S0, 2026-10-01, ruling S2. `spec:platform.native-harness`: 'The fixture issuer is the identity source of a native run and its one named difference from production; it is recorded as the identity source and is not an adjusted configuration.' `first-experiment`'s `typeMeasurement` already keeps `identitySource` apart from `adjustments`. The evidence module follows in the fold-in (code ruling C12)." + }, + { + "round": "S0", + "id": "s0-fixture-composition-claims-the-same-schemas", + "lens": "fidelity", + "severity": "major", + "files": [ + "specs/platform/native-harness.sdp.md", + "specs/platform/acceptance-contract.sdp.md", + "specs/application/first-experiment.native-acceptance-production-configuration.sdp.md" + ], + "issue": "The contract said a scenario on the fixture composition 'runs the same libraries, schemas and authority model', and `tierNative` said 'production schemas and authority', while the fixture composition mounts fixture contexts with their own schemas. An owner reads production parity into fixture runs. Opus review, M4.", + "fix": "Name which tables are shared and which are the fixture's.", + "status": "fixed", + "unreviewed": true, + "slice": "S0", + "detector": "judgement", + "note": "S0, 2026-10-01, ruling S3. The rule now reads: a scenario on the fixture composition 'runs the shared libraries with their own tables and the same authority model, beside fixture contexts whose state is the fixture's own'. `tierNative` on the acceptance contract is back to the doc's sentence. The L2-9 example says the same." + }, + { + "round": "S0", + "id": "s0-test-hook-and-replaceable-part-undefined", + "lens": "completeness", + "severity": "minor", + "files": [ + "specs/platform/native-harness.sdp.md" + ], + "issue": "The contract said the shared libraries 'carry no test hook' and allowed 'a replaceable part it wires in place of the production one, such as the audit destination and the diagnostic sink'. The boundary between a hook and a replaceable part was undefined, and the line settled the mechanism of Sc ALL-1 ahead of slice S5. Opus review, minor 3.", + "fix": "Define a hook, and say what a composition may supply without naming S5's mechanism.", + "status": "fixed", + "unreviewed": true, + "slice": "S0", + "detector": "judgement", + "note": "S0, 2026-10-01, ruling S4. Two rules: 'The shared libraries carry no test hook: they read no flag and take no callback that exists only for a test', and a fault enters only through 'a fixture context's declaration, a fixture function, or a dependency the libraries take from every composition'. The audit destination and the diagnostic sink are no longer named here; S5 decides how Sc ALL-1 breaks them." + }, + { + "round": "S0", + "id": "s0-refusal-examples-pass-when-the-target-is-missing", + "lens": "completeness", + "severity": "major", + "files": [ + "specs/platform/native-harness.sdp.md", + "specs/platform/native-harness.ordinary-client-refused-internal-function.sdp.md", + "specs/platform/native-harness.ordinary-client-refused-component-function.sdp.md" + ], + "issue": "The two refusal examples claimed 'Convex refuses the call before a handler runs' and showed only that the call failed and no row existed. Three native mutations survived on commit 951874d: the tests pass when the function or the component they call does not exist. The internal-function example cited Sc L1-7, so a reader could count the namespace scenario as covered by Convex's visibility check. Opus review minors 1 and 7, sol review 1 and 2, astra review 8.", + "fix": "A refusal example states what it shows: the refusal's own error, nothing stored, and the target existing on the same deployment.", + "status": "fixed", + "unreviewed": true, + "slice": "S0", + "detector": "native backend", + "note": "S0, 2026-10-01, ruling S5. The rule on `spec:platform.native-harness` and both examples now require the error text (`Could not find public function`; `BadDeployKey` on the endpoint that takes a component path), zero rows, and one row after the same function is called with admin access on the same deployment. The component example covers both routes a client has. The Sc L1-7 citation is gone. The tests follow in the fold-in (code ruling C8), which reruns the three mutations." + }, + { + "round": "S0", + "id": "s0-example-steps-negated-by-a-boolean", + "lens": "sdp", + "severity": "minor", + "files": [ + "specs/platform/native-harness.fixture-issuer-token-yields-identity.sdp.md", + "specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.sdp.md", + "specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.sdp.md" + ], + "issue": "Several example steps were positive sentences whose bound value negated them: 'is stored {stored: false}', 'returns an identity {anonymousHasIdentity: false}', 'is stored after the parent caught the error and committed {childRowStored: false}'. A reader takes the sentence for the claim. The Probe 2 examples also named 'a client whose parent lets the error pass', a second parent no Given set up. Opus review, minors 4 and 5.", + "fix": "Reword with counts, and set up every actor in a Given.", + "status": "fixed", + "unreviewed": true, + "slice": "S0", + "detector": "judgement", + "note": "S0, 2026-10-01, ruling S6. Steps now bind counts ('holds {rows: 0} rows') or carry no slot ('returns no identity'). Both Probe 2 examples have a Given for the two parents, the one that catches and the one that lets the error pass." + }, + { + "round": "S0", + "id": "s0-probe-examples-bind-the-answer-and-can-miss-the-boundary", + "lens": "completeness", + "severity": "major", + "files": [ + "specs/facts/probe-plan.sdp.md", + "specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.sdp.md", + "specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md", + "specs/facts/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.sdp.md", + "specs/facts/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.sdp.md", + "specs/facts/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.sdp.md", + "specs/facts/f13-transactions-have-limits.probe-4-nesting-depth.sdp.md", + "specs/facts/f13-transactions-have-limits.probe-4-nested-call-shares-limits.sdp.md", + "specs/facts/f13-transactions-have-limits.probe-4-component-call-shares-limits.sdp.md", + "specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.sdp.md", + "specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md", + "specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary.sdp.md", + "README.md" + ], + "issue": "The probe examples wrote the expected answer in as the pass condition with nothing saying it was a hypothesis, so a contrary result would read as a red test to repair: the depth example bound 8 and the first native run showed 9. Probe 3 dropped function-call quota without saying so and its local timings would feed OQ1 unmarked. Probe 5's question, whether the client hook splits a page, sat in a verification bullet that allowed the run to skip it. Probe 1's Thens passed without reaching the guarantee's boundary: twenty trials with no row passed, the HTTP example lost no response, and a random kill might never land before the commit; the examples said 'WebSocket client' for a fact about the React client. The subscription example did not require a parent that reads no table of its own. Opus review, M5 to M8 and minors 5, 6 and 16; the first native run of the probes on 2026-10-01.", + "fix": "Say in the probe plan what a bound value is and what follows a different result; make each example fail when its boundary is not reached; name the client; give the hook its own example; say what a local backend cannot answer.", + "status": "fixed", + "unreviewed": true, + "slice": "S0", + "detector": "native backend", + "note": "S0, 2026-10-01, ruling S7. `spec:facts.probe-plan` carries extension E-16 with six rules: a probe is example Specs that verify its fact; bound values are the expectation written before the first run; a different first result is recorded on the fact and the example is then bound to what the release does and says so; an example fails when its run did not reach its boundary; sizes are recorded and not asserted; what a local backend cannot answer stays open on the fact. Probe 1's three examples name `ConvexClient` or `ConvexHttpClient`, require a trial committed without confirmation, a kill on each side of the commit, and a lost response with one request per call. Probe 3 says quota is not measured and the time is a local backend's. Probe 4's depth binds 9 as observed and calls it a backend default; the shared-limit examples say the seeding is paced under the local backend's write rate and that each half of the write commits alone. Probe 5 gains `probe-5-hook-splits-across-boundary`; the subscription example's parent reads no table of its own. Opus minor 16, drop the depth example, is rejected: the round-3 finding on nested limits asks for the depth and the test is small." } ], "sessions": [ diff --git a/design/specs/application/first-experiment.native-acceptance-production-configuration.sdp.md b/design/specs/application/first-experiment.native-acceptance-production-configuration.sdp.md index 8024ccb..93508de 100644 --- a/design/specs/application/first-experiment.native-acceptance-production-configuration.sdp.md +++ b/design/specs/application/first-experiment.native-acceptance-production-configuration.sdp.md @@ -25,6 +25,6 @@ Then authority, schemas, concurrency and code path {parity: "match a release"} ## Verification — executable - Runs in the end-to-end tier on the production composition; every test owns its disposable backend. -- The test deploys the release build, not a test build, with the production `auth.config.ts`, grants, namespaces and entry points and the production schema, and runs against it every Layer 0 to 2 scenario that the acceptance contract routes to the production composition under E-15; the scenarios it routes to the fixture composition run there with the same libraries, schemas and authority model. -- The test obtains each identity from a token the harness signs for the fixture issuer that the deployment's environment variables name, carried by an ordinary client, because a production issuer cannot mint tokens for a disposable backend; the evidence record names that issuer as the one difference from production, under E-13 of the acceptance contract. +- The test deploys the release build, not a test build, with the production `auth.config.ts`, grants, namespaces and entry points and the production schema, and runs against it every Layer 0 to 2 scenario that E-15 routes to the production composition; the scenarios it routes to the fixture composition run there with the shared libraries, their own tables and the same authority model. +- The test obtains each identity from a token the harness signs for the fixture issuer that the deployment's environment variables name, carried by an ordinary client, because a production issuer cannot mint tokens for a disposable backend; the evidence record names that issuer as the identity source, the one named difference from production, under E-13. - The test asserts that no test-only function is registered in the deployment and that the evidence record of the run states no configuration difference from production other than the identity source. diff --git a/design/specs/application/first-experiment.sdp.md b/design/specs/application/first-experiment.sdp.md index e2d0497..50799e2 100644 --- a/design/specs/application/first-experiment.sdp.md +++ b/design/specs/application/first-experiment.sdp.md @@ -70,7 +70,7 @@ The six cost targets are the constraint Specs this workflow is constrained by. T - rule: The cost targets are one top-level commit per successful command, zero core projection jobs, O(N) business work allowed, one public command execution per business intent, no application-wide counter, and no queue recovery needed for essential reads (First experiment) - rule: The results feed the open question on component cost and the Layer 3 decisions (First experiment, OQ1, Decision method rule 4) - rule: Native acceptance runs with production configuration: the same authority, schemas, concurrency and code path as a release (Sc L2-9) -- rule: [extension] The native tier obtains its identities from tokens the harness signs for the fixture issuer that the deployment's environment variables name, carried by ordinary clients, so the actor mapping, the grants, the namespaces, the entry points and Convex's visibility check are the production ones; the issuer is the one named difference from production and every run's evidence record states it, as `spec:platform.acceptance-contract` rules under E-13 (E-13, S13, Sc L2-9) +- rule: [extension] The native tier obtains its identities from tokens the harness signs for the fixture issuer that the deployment's environment variables name, carried by ordinary clients, so the actor mapping, the grants, the namespaces, the entry points and Convex's visibility check are the production ones; the issuer is the identity source, the one named difference from production, and every run's evidence record states it (E-13, S13, Sc L2-9) - rule: Every run is recorded as acceptance evidence: commit, installed layers, backend and dependency versions, configuration, dataset, command and result, and a run with adjusted configuration states how it differs from production (Acceptance scenarios) - rule: The kernel's fixture app is separate from the example app, and test-only functions never ship (Acceptance scenarios) - rule: [extension] The second lifecycle command is `CancelOrder`, the essential summary is the order summary read model, and the provisional maximum is 100 lines (E-46, First experiment) diff --git a/design/specs/command/tenancy-and-authority.sdp.md b/design/specs/command/tenancy-and-authority.sdp.md index aa8c388..bd4f743 100644 --- a/design/specs/command/tenancy-and-authority.sdp.md +++ b/design/specs/command/tenancy-and-authority.sdp.md @@ -71,7 +71,7 @@ Nothing here signs anything. The internal entry is unreachable by clients becaus - clientClaimRefused: a `namespace` or `actor` field sent to the public entry fails its `args` validator, and a client call to the internal entry is refused by Convex before any handler runs (D11, Sc L1-7) - workerModes: the two modes of the one `Authority` shape pinned in `spec:command.actor-and-scope`, never restated here: `recheckDelegator` re-reads the delegating user's grants at execution through `authorize` with the captured actor and scope, and `serviceAuthority` runs under the service actor's own grants with no recheck; both carry the obligation on the actor's `delegationRef` (D11, D13, E-6) - trustBoundary: one trusted deployment; component privacy and type brands, never a signed token, and no claim against a malicious administrator (D11) -- nativeTierIdentity: on the native tier the identity `ctx.auth.getUserIdentity()` returns comes from a token the harness signs for the fixture issuer that the deployment's environment variables name, carried by an ordinary client, so `establishActor`, the grants read, the namespaces and Convex's visibility check run unchanged; the issuer is the one named difference from production, recorded under E-13 on `spec:platform.acceptance-contract` (E-13, S13, Sc L2-9) +- nativeTierIdentity: on the native tier the identity `ctx.auth.getUserIdentity()` returns comes from a token the harness signs for the fixture issuer that the deployment's environment variables name, carried by an ordinary client, so `establishActor`, the grants read, the namespaces and Convex's visibility check run unchanged; the issuer is the identity source of a native run and its one named difference from production (E-13, S13, Sc L2-9) - limitGrantsPerPrincipal: at most 500 grants read per principal per tenant per command; the read takes 501 and a 501st row throws a plain `Error`, a technical failure, never a silent truncation, because `.take(500)` alone cannot show that a 501st exists; a principal with more is a product problem the failure surfaces (F13, E-37) ## Example space diff --git a/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.sdp.md b/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.sdp.md index b3c4865..bd1303f 100644 --- a/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.sdp.md +++ b/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.sdp.md @@ -19,11 +19,12 @@ Probe 3 · native tier · fixture composition. Given a parent mutation that reads the same document {reads: 50} times in a row through one of three paths: a helper function, a nested query, a component query When a client calls the parent mutation once for each path, several times over Then the median execution time through the component is {componentAgainstHelper: "higher"} than through the helper -And the function log holds {recordsPerCall: 1} execution record for one parent call through the component +And the function log holds {recordsPerCall: 1} completion record for one parent call through the component ``` ## Verification — executable - Runs in the native tier on the fixture composition; every test owns its disposable backend. -- The execution time is the one the backend's function log reports for the parent call; the test records the three medians and the cost per call in the run's evidence. -- The test records what the function log shows of the nested and the component calls, which is all a local backend can say about function-call quota. +- The execution time is the one the backend's function log reports for the parent call; the run's evidence holds the three medians and the difference per call. +- The size is recorded and not asserted. It is a local backend's time on one machine, a first reading for OQ1 and not a hosted deployment's cost. +- Function-call quota is not measured. A local backend has none, and its function log holds one completion record for the parent call and none for the calls inside it, which does not say how a hosted deployment counts them. That half of Probe 3 needs a hosted deployment and stays open on F4. diff --git a/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md b/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md index f16ca23..3598d52 100644 --- a/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md +++ b/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md @@ -19,10 +19,12 @@ Probe 3 · native tier · fixture composition. Given a parent query that reads the same document {reads: 50} times in a row through one of three paths: a helper function, a nested query, a component query When a client calls the parent query once for each path, several times over, with arguments that defeat the query cache Then the median execution time through the component is {componentAgainstHelper: "higher"} than through the helper -And the function log holds {recordsPerCall: 1} execution record for one parent call through the component +And the function log holds {recordsPerCall: 1} completion record for one parent call through the component ``` ## Verification — executable - Runs in the native tier on the fixture composition; every test owns its disposable backend. -- The execution time is the one the backend's function log reports for the parent call, because the clock inside a query does not advance; the test records the three medians and the cost per call in the run's evidence. +- The execution time is the one the backend's function log reports for the parent call, because the clock inside a query does not advance; the run's evidence holds the three medians and the difference per call. +- The size is recorded and not asserted. It is a local backend's time on one machine, a first reading for OQ1 and not a hosted deployment's cost. +- Function-call quota is not measured, for the reason the mutation example gives. diff --git a/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.sdp.md b/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.sdp.md index 7aef24f..1fc4986 100644 --- a/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.sdp.md +++ b/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-backend-restart-with-pending-mutation.sdp.md @@ -16,13 +16,16 @@ Probe 1 · native tier · fixture composition. - outcome: The client retries until confirmed and the backend executes the call once. (Probe 1, F5) ```gwt -Given a WebSocket client that sends a mutation which inserts one marker row +Given a WebSocket client, the ConvexClient of convex/browser, that sends a mutation which inserts one marker row When the backend process is killed and restarted on the same storage while the client stays open, in each of {trials: 10} trials -Then the mutation's promise resolves after the restart {resolves: true} +Then the mutation's promise resolves after the restart in {resolvedTrials: 10} trials And every trial leaves exactly {markerRows: 1} marker row +And at least {leastKilledBeforeCommit: 1} trial was killed before its commit and at least {leastKilledAfterCommit: 1} after it ``` ## Verification — executable - Runs in the native tier on the fixture composition; every test owns its disposable backend. -- The kill is a SIGKILL sent a short random time after the send, so that some trials die before the commit and some after it, and the test records the split. +- The client is `ConvexClient` from `convex/browser`, which sends mutations through the same `BaseConvexClient` as the React client; the React client itself is not run. +- The kill is a SIGKILL, sent after a delay that the trials spread from none to a few tens of milliseconds. The test holds the backend's responses back from the client, reads the marker table with admin access after the restart and before the client reconnects, and so tells a kill before the commit from a kill after it. +- A trial killed after its commit is the one that tests the guarantee: the client never saw the result, sends the mutation again when it reconnects, and the backend must not run it twice. diff --git a/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.sdp.md b/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.sdp.md index 62dfcbc..24937fe 100644 --- a/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.sdp.md +++ b/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-client-closed-with-pending-mutation.sdp.md @@ -16,12 +16,15 @@ Probe 1 · native tier · fixture composition. - outcome: The backend executes the call once or not at all, never more. (Probe 1, F5) ```gwt -Given a WebSocket client that sends a mutation which inserts one marker row -When the client is closed before the mutation's result arrives, in each of {trials: 20} trials +Given a WebSocket client, the ConvexClient of convex/browser, that sends a mutation which inserts one marker row +When the client is closed before the mutation's result reaches it, in each of {trials: 20} trials Then no trial leaves more than {mostMarkerRows: 1} marker row +And at least {leastCommittedUnconfirmed: 1} trial leaves its row although its client never received the result ``` ## Verification — executable - Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The client is `ConvexClient` from `convex/browser`, which sends mutations through the same `BaseConvexClient` as the React client; the React client itself is not run. +- The test holds the backend's responses back from the client, so every close happens with the mutation pending, and varies the time between the send and the close. - The test records how many trials left no row and how many left one, which is the measure of what a closed tab can lose. diff --git a/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.sdp.md b/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.sdp.md index 71101e3..60c2d95 100644 --- a/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.sdp.md +++ b/design/specs/facts/f05-react-client-retries-until-confirmed.probe-1-http-client-retry.sdp.md @@ -13,15 +13,17 @@ Probe 1 · native tier · fixture composition. ## Intent -- outcome: The backend executes each call once, and a caller's retry is a second call. (Probe 1, F5) +- outcome: The HTTP client sends each call once, and a caller's retry after a lost response is a second execution. (Probe 1, F5) ```gwt -Given an HTTP client whose caller sends a mutation which inserts one marker row and discards the response -When the caller sends the same mutation again -Then the backend holds {markerRows: 2} marker rows +Given an HTTP client, ConvexHttpClient, whose transport delivers each request to the backend and then fails as a lost response would +When its caller sends a mutation which inserts one marker row, sees the call fail, and sends the same mutation again +Then the client sent {requestsPerCall: 1} request for each call +And the backend holds {markerRows: 2} marker rows ``` ## Verification — executable - Runs in the native tier on the fixture composition; every test owns its disposable backend. -- The test asserts that one call with no retry leaves exactly one row, and records that `ConvexHttpClient` sends each mutation in one request with no retry of its own. +- The transport is the `fetch` the test passes to `ConvexHttpClient`: it performs the request, drops the response and throws, as a connection lost after the commit would. +- The test also asserts that one call on an unfaulted transport sends one request and leaves one row. diff --git a/design/specs/facts/f13-transactions-have-limits.probe-4-component-call-shares-limits.sdp.md b/design/specs/facts/f13-transactions-have-limits.probe-4-component-call-shares-limits.sdp.md index 146e81e..4762a56 100644 --- a/design/specs/facts/f13-transactions-have-limits.probe-4-component-call-shares-limits.sdp.md +++ b/design/specs/facts/f13-transactions-have-limits.probe-4-component-call-shares-limits.sdp.md @@ -27,3 +27,5 @@ And the same split of bytes written by a parent mutation and a component mutatio - Runs in the native tier on the fixture composition; every test owns its disposable backend. - The test also reads `ctx.meta.getTransactionMetrics()` in the parent before and after a component read that stays under the limit and asserts that `bytesRead.used` and `documentsRead.used` grew by what the component read. +- The test also shows that each half of the write commits alone, so the failure is the sum. +- The local backend refuses writes past a rate, with `TooManyWrites` and the text "limited to 4 MiB bytes written per 1 second", so the test paces the transactions that seed and write its documents. diff --git a/design/specs/facts/f13-transactions-have-limits.probe-4-nested-call-shares-limits.sdp.md b/design/specs/facts/f13-transactions-have-limits.probe-4-nested-call-shares-limits.sdp.md index da02347..a5d0eb8 100644 --- a/design/specs/facts/f13-transactions-have-limits.probe-4-nested-call-shares-limits.sdp.md +++ b/design/specs/facts/f13-transactions-have-limits.probe-4-nested-call-shares-limits.sdp.md @@ -27,3 +27,5 @@ And the same split of bytes written by a parent mutation and a nested mutation { - Runs in the native tier on the fixture composition; every test owns its disposable backend. - The test also reads `ctx.meta.getTransactionMetrics()` in the parent before and after a nested read that stays under the limit and asserts that `bytesRead.used` and `documentsRead.used` grew by what the child read. +- The test also shows that each half of the write commits alone, so the failure is the sum. +- The local backend refuses writes past a rate, with `TooManyWrites` and the text "limited to 4 MiB bytes written per 1 second", so the test paces the transactions that seed and write its documents. diff --git a/design/specs/facts/f13-transactions-have-limits.probe-4-nesting-depth.sdp.md b/design/specs/facts/f13-transactions-have-limits.probe-4-nesting-depth.sdp.md index 424ae5d..42227ba 100644 --- a/design/specs/facts/f13-transactions-have-limits.probe-4-nesting-depth.sdp.md +++ b/design/specs/facts/f13-transactions-have-limits.probe-4-nesting-depth.sdp.md @@ -13,15 +13,16 @@ Probe 4 · native tier · fixture composition. ## Intent -- outcome: Nested calls have a depth bound, and the run records it. (Probe 4, F13) +- outcome: Nested calls have a depth bound, which this backend release sets at eight calls below the top-level function. (Probe 4, F13) ```gwt Given a mutation that calls itself through ctx.runMutation until its call stack holds a requested number of functions When a client asks for each number from 1 upward -Then the deepest call stack that commits holds {deepestStack: 8} functions, the top-level mutation included +Then the deepest call stack that commits holds {deepestStack: 9} functions, the top-level mutation included ``` ## Verification — executable - Runs in the native tier on the fixture composition; every test owns its disposable backend. -- The test records the error text of the first depth that fails. +- The value is observed, not expected: the expectation written before the first run was 8 functions, and the run on release `precompiled-2026-09-28-5c7cb5b` showed 9. +- The bound is a backend default and not a platform contract. The test records the error text of the first depth that fails, which on this release is "Cross component call depth limit exceeded". diff --git a/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.sdp.md b/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.sdp.md index 6679cbd..2299cc3 100644 --- a/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.sdp.md +++ b/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.sdp.md @@ -17,10 +17,11 @@ Probe 2 · native tier · fixture composition. ```gwt Given a mutation inside a component that writes one row and then throws a ConvexError carrying structured data with code {code: "staleVersion"} -When a parent mutation calls it through the component API and a client calls the parent -Then the parent's catch reads the thrown data unchanged {parentReadsData: true} -And a client whose parent lets the error pass reads the thrown data unchanged {clientReadsData: true} -And the row the component's mutation wrote is stored after the parent caught the error and committed {childRowStored: false} +And two parent mutations that call it through the component API, one that writes a row of its own, catches the error and returns its data, and one that lets the error pass +When a client calls each parent +Then the catching parent returns the thrown data unchanged +And the client of the other parent reads the thrown data unchanged from the error it receives +And after the catching parent committed, the component mutation's table holds {childRows: 0} rows and the parent's own table holds {parentRows: 1} row ``` ## Verification — executable diff --git a/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.sdp.md b/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.sdp.md index c8f4b14..acd9bea 100644 --- a/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.sdp.md +++ b/design/specs/facts/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.sdp.md @@ -17,10 +17,11 @@ Probe 2 · native tier · fixture composition. ```gwt Given a mutation that writes one row and then throws a ConvexError carrying structured data with code {code: "staleVersion"} -When a parent mutation calls it through ctx.runMutation and a client calls the parent -Then the parent's catch reads the thrown data unchanged {parentReadsData: true} -And a client whose parent lets the error pass reads the thrown data unchanged {clientReadsData: true} -And the row the throwing mutation wrote is stored after the parent caught the error and committed {childRowStored: false} +And two parent mutations that call it through ctx.runMutation, one that writes a row of its own, catches the error and returns its data, and one that lets the error pass +When a client calls each parent +Then the catching parent returns the thrown data unchanged +And the client of the other parent reads the thrown data unchanged from the error it receives +And after the catching parent committed, the throwing mutation's table holds {childRows: 0} rows and the parent's own table holds {parentRows: 1} row ``` ## Verification — executable diff --git a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary.sdp.md b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary.sdp.md new file mode 100644 index 0000000..7059b68 --- /dev/null +++ b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary +kind: example +altitude: story +readiness: defined +relations: + refines: spec:facts.f15-parent-query-over-component-query-stays-reactive + verifies: spec:facts.f15-parent-query-over-component-query-stays-reactive +--- +# Probe 5: the client hook splits a page across the boundary without a gap + +Probe 5 · native tier · fixture composition. + +## Intent + +- outcome: The hook shows every row after the split. (Probe 5, F15, S4) + +```gwt +Given a component list of {rows: 30} rows read through the parent by the usePaginatedQuery hook of convex-helpers/react, in pages of {pageSize: 10} with maximumRowsRead {maximumRowsRead: 20} +And the hook has loaded every page +When rows are inserted inside the first page's range until it holds {rowsInRange: 25} rows +Then the hook's results hold {rowsShown: 45} rows, every row of the list once and in order +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition, with a DOM environment for the hook; every test owns its disposable backend. +- The test shows through a second subscription that the capped page came back `SplitRequired`, so a pass cannot come from a page that never needed the split. +- The hook is the `usePaginatedQuery` of `convex-helpers/react`, whose pages carry an end cursor. Convex's own `usePaginatedQuery` is not the subject. diff --git a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.sdp.md b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.sdp.md index ce8fa1e..0dc7baf 100644 --- a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.sdp.md +++ b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.sdp.md @@ -19,7 +19,7 @@ Probe 5 · native tier · fixture composition. Given a component list of {rows: 30} rows that a parent query relays from paginator, read in pages of {pageSize: 10} And a client subscribed to every page, each bounded by the end cursor its first load returned When {inserted: 5} rows are inserted inside the first page's range -Then the subscribed pages together hold every row exactly once and in order {contiguous: true} +Then the subscribed pages together hold {rowsHeld: 35} rows, every row of the list once and in order And the first page now holds {firstPageRows: 15} rows ``` diff --git a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md index 6a24a8c..2c98e2d 100644 --- a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md +++ b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md @@ -7,22 +7,22 @@ relations: refines: spec:facts.f15-parent-query-over-component-query-stays-reactive verifies: spec:facts.f15-parent-query-over-component-query-stays-reactive --- -# Probe 5: a page that outgrows its read cap splits across the boundary +# Probe 5: a page that outgrows its read cap comes back through the parent as a split Probe 5 · native tier · fixture composition. ## Intent -- outcome: The split crosses the boundary without a gap. (Probe 5, F15, S4) +- outcome: The parent relays the split, and the pages on either side of it leave no gap. (Probe 5, F15, S4) ```gwt Given a subscribed page of a component list, bounded by an end cursor and read through the parent with maximumRowsRead {maximumRowsRead: 20} When rows are inserted inside the page's range until it holds {rowsInRange: 25} rows Then the page the parent relays carries pageStatus {pageStatus: "SplitRequired"} and a split cursor -And the two pages on either side of the split cursor together hold every row exactly once and in order {contiguous: true} +And the two pages on either side of the split cursor together hold {rowsHeld: 25} rows, every row of the range once and in order ``` ## Verification — executable - Runs in the native tier on the fixture composition; every test owns its disposable backend. -- The test also runs the `usePaginatedQuery` hook of `convex-helpers/react` against the same parent query and records whether it shows every row after the split; if the hook cannot run in the test process, the run records that gap. +- This example reads the relayed pages directly. Whether the client hook acts on the split is the next example's question. diff --git a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.sdp.md b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.sdp.md index 60c0260..41ec70f 100644 --- a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.sdp.md +++ b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.sdp.md @@ -16,9 +16,9 @@ Probe 5 · native tier · fixture composition. - outcome: The subscription updates. (Probe 5, F15) ```gwt -Given a client subscribed to a parent query that returns what a component query reads +Given a client subscribed to a parent query that reads no table of its own and returns what a component query reads When a mutation changes the document inside the component -Then the subscription delivers the changed value without the client asking again {updated: true} +Then the subscription delivers {updates: 1} changed value without the client asking again ``` ## Verification — executable diff --git a/design/specs/facts/probe-plan.sdp.md b/design/specs/facts/probe-plan.sdp.md index be7ea27..e67000b 100644 --- a/design/specs/facts/probe-plan.sdp.md +++ b/design/specs/facts/probe-plan.sdp.md @@ -18,7 +18,7 @@ relations: --- # The probe plan -Feature · Detail: full transcription · Traces: Probe 1 to Probe 7, F4, F5, F12, F13, F14, F15, F16, F17, S13. +Feature · Detail: full transcription · Traces: Probe 1 to Probe 7, F4, F5, F12, F13, F14, F15, F16, F17, S13, E-16. Seven probes, in the order the decisions need them, each one small test on a native backend. A probe turns an assumed fact into a probed one, or measures a size the docs leave open. The recheck of 2026-09-30 narrowed two of them and left the rest standing: the limits page states no limit on nested calls, so Probe 4 stands; the application-errors page does not state that `ConvexError` data survives a component boundary, so Probe 2 stands; the scheduling page documents the states and the 7-day retention of `_scheduled_functions`, so Probe 7 need only confirm them and answer what a restore leaves; the backup page says nothing about component data or the scheduler, so that half of Probe 7 stands. @@ -37,11 +37,12 @@ The probes are not the first experiment. The experiment builds Layers 0 to 2 and - [non-blocking] Probe 1 pending: it must show where the client's exactly-once guarantee ends, for a tab closed with a pending mutation, a server restart and the HTTP client (Probe 1, D6) - [non-blocking] Probe 2 pending: it must show that `ConvexError` data arrives intact through a nested mutation and through a component boundary (Probe 2, F14, D7) -- [non-blocking] Probe 3 pending: it must give the cost of one component call in latency and function-call quota (Probe 3, F4, D2, D8) +- [non-blocking] Probe 3 pending: it must give the cost of one component call in latency and function-call quota; a local backend has no quota, so that half waits for a hosted deployment (Probe 3, F4, D2, D8) - [non-blocking] Probe 4 pending: it must show whether transaction limits add up across nested calls and components (Probe 4, F13, D10) - [non-blocking] Probe 5 pending: it must show that a parent query over component queries stays reactive, and whether a component list paginated with `paginator` from `convex-helpers`, the documented replacement for the built-in `.paginate()` that S4 says does not work in a component, stays contiguous across the boundary when the client passes the end cursor and splits correctly when the component's `maximumRowsRead` or `maximumBytesRead` caps a re-run; that the cap exists is read in the helper's source and is not the probe's question (Probe 5, F15, S4, D8) - [non-blocking] Probe 6 pending: it must show a backfill batch racing a live command under optimistic concurrency, and that the migrations component fits a generation backfill (Probe 6, F17, D9) - [non-blocking] Probe 7 pending: before Layer 3, it must show what a restore leaves of Workpool and Workflow state and confirm the states and retention of `_scheduled_functions` on a native backend (Probe 7, F12, F16, D13, D19) +- [non-blocking] Extension E-16: the doc says each probe is one small test on a native backend and that a probed fact is one a native backend test showed, and does not say how a test that settles an unknown states what it expects or what follows when the backend answers otherwise; the option taken here: a probe is written as example Specs that verify its fact, each bound to its native test; an example's bound values are the expectation written before the first run; a first run that shows another value is a finding recorded on the fact, and the example is then bound to what that release does and says so; an example fails when its run did not reach the boundary it is about; a size is recorded and not asserted; and what a local backend cannot answer is named on the example and stays open on the fact; the owner confirms (E-16, Probes, Acceptance scenarios) ## Workflow @@ -49,6 +50,12 @@ The probes are not the first experiment. The experiment builds Layers 0 to 2 and - rule: Probes run in the order the decisions need them (Probes) - rule: A probe result changes a fact's status to probed; it never changes a decision by itself, and the decision Spec records what changed (Probes, Decision method rule 1) - rule: A probe run records the commit, backend and dependency versions, configuration, command and result, like any acceptance run (Acceptance scenarios) +- rule: [extension] A probe is written as example Specs that verify its fact, one example per case, each bound to its native test (E-16) +- rule: [extension] An example's bound values are the expectation written before the first run (E-16) +- rule: [extension] A first run that shows another value is a finding recorded on the fact with the backend release that showed it; the example is then bound to what that release does, and its text says the value was observed (E-16) +- rule: [extension] An example fails when its run did not reach the boundary it is about (E-16) +- rule: [extension] A measured size is recorded in the run's evidence and on the fact, and an example asserts a size only where the fact states a bound (E-16) +- rule: [extension] An example names what a local backend cannot answer, and that part stays open on the fact (E-16) - Probe 1 sends a mutation from a tab that closes while it is pending, across a server restart, and from the HTTP client, and records for each whether the backend executed it once, more than once or not at all; it serves D6 (Probe 1, F5, F6) - Probe 2 throws a `ConvexError` carrying structured data from a nested mutation and from a mutation inside a component, and checks that the parent and then the client read the same data; it serves D7 (Probe 2, F14) - Probe 3 measures one component call from a parent mutation and from a parent query in latency and in function-call quota, and reports the difference from a plain helper; it serves D2 and D8 (Probe 3, F4) @@ -59,7 +66,7 @@ The probes are not the first experiment. The experiment builds Layers 0 to 2 and ## Design -Each probe is a fixture-app test on a disposable backend. The probe app is separate from the example app and ships no test-only function to production. Where the probes live is an open question of the doc that the corpus does not decide. +Each probe is a fixture-app test on a disposable backend. The probe app is separate from the example app and ships no test-only function to production. - backend: one disposable native backend per probe run, from the same local backend S13 describes (Acceptance scenarios, S13) - fixtureApp: the probe app is the kernel's fixture app, separate from the example app, and test-only functions never ship (Acceptance scenarios) diff --git a/design/specs/foundation.pack.sdp.md b/design/specs/foundation.pack.sdp.md index aab2ebf..096d5e0 100644 --- a/design/specs/foundation.pack.sdp.md +++ b/design/specs/foundation.pack.sdp.md @@ -6,10 +6,11 @@ specs: - spec:platform.decision-method - spec:platform.vocabulary - spec:platform.acceptance-contract - - spec:platform.acceptance-contract.fixture-issuer-token-yields-identity - - spec:platform.acceptance-contract.ordinary-client-refused-internal-function - - spec:platform.acceptance-contract.ordinary-client-refused-component-function - - spec:platform.acceptance-contract.admin-key-acting-as-identity-reaches-internal-function + - spec:platform.native-harness + - spec:platform.native-harness.fixture-issuer-token-yields-identity + - spec:platform.native-harness.ordinary-client-refused-internal-function + - spec:platform.native-harness.ordinary-client-refused-component-function + - spec:platform.native-harness.admin-key-acting-as-identity-reaches-internal-function - spec:platform.existing-systems - spec:laws.law01-sanctioned-writes-only - spec:laws.law02-state-and-events-commit-together @@ -72,6 +73,7 @@ specs: - spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates - spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous - spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed + - spec:facts.f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary - spec:facts.f16-scheduled-functions-table-shows-failed-runs - spec:facts.f17-migrations-fits-generation-backfill modelRefs: @@ -79,6 +81,6 @@ modelRefs: --- # Foundation -Package A. The decisions document carried into the graph: the platform epic, the twelve laws, the nineteen decisions, the fact ledger and its probe plan, the decision method, layers and profiles, the acceptance contract, existing-systems rules and the vocabulary. Every other package cites these IDs; none of them redefines them. +Package A. The decisions document carried into the graph: the platform epic, the twelve laws, the nineteen decisions, the fact ledger and its probe plan, the decision method, layers and profiles, the acceptance contract and the native harness, existing-systems rules and the vocabulary. Every other package cites these IDs; none of them redefines them. -Membership order follows the plan's inventory: the platform Specs, then the laws in number order, then the decisions in number order, then the fact ledger, the probe plan and the facts in number order. Each parent's examples follow it: the four harness examples after the acceptance contract, and each probe's examples after the fact they verify. Seventy-three Specs. +Membership order follows the plan's inventory: the platform Specs, then the laws in number order, then the decisions in number order, then the fact ledger, the probe plan and the facts in number order. Each parent's examples follow it: the native harness and its four examples after the acceptance contract, and each probe's examples after the fact they verify. Seventy-five Specs. diff --git a/design/specs/platform/acceptance-contract.ordinary-client-refused-component-function.sdp.md b/design/specs/platform/acceptance-contract.ordinary-client-refused-component-function.sdp.md deleted file mode 100644 index 2609fd5..0000000 --- a/design/specs/platform/acceptance-contract.ordinary-client-refused-component-function.sdp.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -id: spec:platform.acceptance-contract.ordinary-client-refused-component-function -kind: example -altitude: story -readiness: defined -relations: - refines: spec:platform.acceptance-contract - verifies: spec:platform.acceptance-contract ---- -# An ordinary client cannot call a component's function - -E-13, E-15 · native tier · fixture composition. - -## Intent - -- outcome: Convex refuses the call before a handler runs. (E-13, D2) - -```gwt -Given a disposable backend running the fixture composition, which mounts a component with a mutation that writes one row -And an ordinary client carrying a token the harness signed for subject {subject: "user-1"} -When the client calls the component's mutation directly by component path and name -Then Convex refuses the call {refused: true} -And the row the component's mutation writes is stored {stored: false} -``` - -## Verification — executable - -- Runs in the native tier on the fixture composition; every test owns its disposable backend. -- The test repeats the call from a client with no token and asserts the same refusal, and reads the component's table with the harness's admin access. diff --git a/design/specs/platform/acceptance-contract.ordinary-client-refused-internal-function.sdp.md b/design/specs/platform/acceptance-contract.ordinary-client-refused-internal-function.sdp.md deleted file mode 100644 index 2952453..0000000 --- a/design/specs/platform/acceptance-contract.ordinary-client-refused-internal-function.sdp.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -id: spec:platform.acceptance-contract.ordinary-client-refused-internal-function -kind: example -altitude: story -readiness: defined -relations: - refines: spec:platform.acceptance-contract - verifies: spec:platform.acceptance-contract ---- -# An ordinary client cannot call an internal function - -E-13, E-15 · native tier · fixture composition. - -## Intent - -- outcome: Convex refuses the call before a handler runs. (E-13, Sc L1-7) - -```gwt -Given a disposable backend running the fixture composition, which registers an internal mutation that writes one row -And an ordinary client carrying a token the harness signed for subject {subject: "user-1"} -When the client calls the internal mutation by name -Then Convex refuses the call {refused: true} -And the row the internal mutation writes is stored {stored: false} -``` - -## Verification — executable - -- Runs in the native tier on the fixture composition; every test owns its disposable backend. -- The test repeats the call from a client with no token and asserts the same refusal and no row, read with the harness's admin access. diff --git a/design/specs/platform/acceptance-contract.sdp.md b/design/specs/platform/acceptance-contract.sdp.md index 17f5d90..a45a136 100644 --- a/design/specs/platform/acceptance-contract.sdp.md +++ b/design/specs/platform/acceptance-contract.sdp.md @@ -8,22 +8,20 @@ relations: --- # The acceptance contract -Feature · Detail: full transcription · Traces: Acceptance scenarios (table, tiers, evidence), First experiment, S13, Sc L2-4, Sc L2-9, E-13, E-14, E-15. +Feature · Detail: full transcription · Traces: Acceptance scenarios (table, tiers, evidence), First experiment, S13, Sc L2-4, E-14. -The doc's acceptance table is the contract. It lists forty-three scenario rows by layer with a pass condition each, and states how each row is tested (the tier), what a test run must record (the evidence) and how readiness is stated. This Spec carries those rules. The rows themselves become example Specs under the component that first provides the capability, one example per row and one sibling example per enumerated case, so that each pass condition is a bound point in that component's example space; the plan's section 4 gives the assignment and the example IDs. +The doc's acceptance table is the contract. It lists forty-three scenario rows by layer with a pass condition each, and states how each row is tested (the tier), what a test run must record (the evidence) and how readiness is stated. This Spec carries those rules. The rows themselves become example Specs under the component that first provides the capability, one example per row and one sibling example per enumerated case, so that each pass condition is a bound point in that component's example space; the plan's section 4 gives the assignment and the example IDs. How a native test obtains an identity, what the fixture app contains and what a test controls are the rules of `spec:platform.native-harness`, which refines this Spec. ## Intent - outcome: Every acceptance scenario is tested as behavior on the tier that can prove it, with recorded evidence, and readiness is stated in plain terms that a run can back (Acceptance scenarios) - value: The scenarios are the spine of the decision method: a mechanism is justified only by a scenario the do-nothing option fails, so the contract is also the refutation surface for every mechanism (Decision method rule 3) - risk: A native tier costs a disposable backend per test and a production-configuration run; skipping it leaves component, nested-mutation, scheduling and contention behavior proven only in a simulator, which is not native proof (Acceptance scenarios) -- assumption: Native tests run against a local Convex backend as S13 describes; the testing guide S13 links obtains a test's identity through the backend's admin key acting as that identity, which the harness does not use for a caller under test (S13, E-13) +- assumption: Native tests run against a local Convex backend as S13 describes (S13) ### Open questions -- [non-blocking] Extension E-13: the doc says native acceptance runs with production authority and a disposable backend per test and does not say how a test obtains a signed identity, which a production issuer cannot mint for a throwaway backend; the option taken here is a fixture issuer: the composition's `auth.config.ts` reads its issuer, application ID and key set from the deployment's environment variables, the harness sets them to an issuer name and a data-URI key set whose private key it holds, and each caller under test is an ordinary client carrying a token the harness signs, so the actor mapping, grants, namespaces, entry points and Convex's own visibility check run as in production and the issuer named by the environment is the one difference; the option first taken, the local backend's admin key acting as an identity, was dropped on 2026-10-01 because such a caller passes the visibility check for internal functions, which `spec:platform.acceptance-contract.admin-key-acting-as-identity-reaches-internal-function` shows on a native backend; the owner confirms (E-13, S13, Sc L2-9) - [non-blocking] Extension E-14: the doc's four tiers, domain, simulator, native and end to end, name no compile-time check, while Sc L2-4's pass condition is a build, type or registration check that fails before any traffic; the option taken here is a fifth tier, the build tier, a compile of the fixture app with the change applied that asserts a non-zero `tsc` exit or a failed registration check, run as a step of the fixture app's build before the native suite and recorded like any run; the alternative, folding it into the native tier as a compile step that precedes the native run, was not taken because a native run needs a backend and the check needs none; the owner confirms (E-14, Sc L2-4, Acceptance scenarios) -- [non-blocking] Extension E-15: the doc says the kernel's fixture app is separate from the example app, that test-only functions never ship and that every test owns its disposable backend, and does not say what the fixture app contains, how a fault enters a run, what a test may observe, or what a test controls on a backend that offers no clock and no scheduler switch; the option taken here is the harness rules below: two compositions, faults only through parts the fixture composition supplies, assertions only on what a client or the harness's admin access can read, and four controls, the backend process, the clients, the environment variables and fixture functions; it reads the production-configuration run of Sc L2-9 as the scenarios routed to the production composition, where the corpus earlier asked for the full suite on a release build; whether the durations Layer 3 pins become configuration, so that a test can outwait them, is left to Layer 3; the owner confirms (E-15, Acceptance scenarios, Sc L2-9) ## Rule @@ -44,13 +42,6 @@ The doc's acceptance table is the contract. It lists forty-three scenario rows b - Readiness is stated plainly as specified, implemented, tested under named conditions, or operationally accepted (Acceptance scenarios) - The first experiment passes when every Layer 0, 1 and 2 scenario passes on a native backend (First experiment) - The one all-layer scenario, breaking metrics and logging and separately breaking mandatory audit, applies to every installed layer (Sc ALL-1) -- [extension] Parity with a release for Sc L2-9 means the same code path, schema, concurrency and authority model: the grants, namespaces, entry points and actor mapping, and Convex's own visibility check on every call a caller under test makes; a native run authenticates each caller with a token signed by the fixture issuer that the deployment's environment variables name, and its evidence record names that issuer as the one difference from production (E-13, S13, Sc L2-9) -- [extension] The harness holds the disposable backend's admin key and uses it to deploy, to set environment variables, to read stored data and the function log, and to run fixture functions; a caller under test never carries it, because a call made with the admin key passes Convex's visibility check, and every assertion about authority, identity or visibility is made through an ordinary client (E-15, E-13) -- [extension] A native scenario runs on one of two compositions: the fixture composition, which mounts the shared libraries with fixture contexts, fixture functions and fixture-supplied parts, or the production composition, which is the example application with no test-only function registered; an example names its composition, and it runs on the production composition unless it needs a fixture-only state or operation, a fault or a fixture function (E-15, Acceptance scenarios) -- [extension] The shared libraries carry no test hook and read no test flag; a fault enters a native run only through a part the fixture composition supplies: a fixture context's own declaration, a fixture function, or a replaceable part it wires in place of the production one, such as the audit destination and the diagnostic sink (E-15, Acceptance scenarios) -- [extension] A native assertion reads only what a client or the harness's admin access can read: a returned value, the data of a thrown error, stored documents and the backend's function log; where a scenario asserts that something did not run, the fixture composition supplies a part that fails the call if it runs (E-15) -- [extension] The native tier controls a run through the backend process, which it starts on fresh storage, stops and restarts on the same storage; through its clients, which may close or discard a response; through the deployment's environment variables; and, on the fixture composition, through fixture functions; it cannot move the backend's clock or switch its scheduler off, so a scenario that needs time to pass waits in real time or shortens a duration that its owning Spec makes configurable, and that run is adjusted configuration (E-15, S13, Acceptance scenarios) -- [extension] The production-configuration run of Sc L2-9 is the scenarios routed to the production composition, deployed with no test-only function and no adjusted configuration; a scenario on the fixture composition runs the same libraries, schemas and authority model and is native evidence for its own row (E-15, Sc L2-9, First experiment) - [extension] A build-tier scenario passes when a compile of the fixture app with the change applied fails at `tsc` or at the registration check before any deployment; the build tier is the fifth tier, named by Sc L2-4 alone, and needs no backend (E-14, Sc L2-4) ## Design @@ -59,12 +50,10 @@ The corpus maps the doc's readiness words onto SDP's rungs without collapsing th - tierDomain: pure tests of `decide`, `evolve` and the fold with no I/O; Sc L0-1 and L0-2 (Acceptance scenarios) - tierSimulator: outcome combinations, serialization and classification of the four outcomes and the error codes (Acceptance scenarios, D4) -- tierNative: a disposable local backend per test, production schemas and authority, proving component and nested-mutation behavior, scheduling, contention and deployment (Acceptance scenarios, S13) -- tierNativeBackend: [extension] the disposable backend is the `convex-local-backend` binary of one pinned release, which the harness starts on free local ports with fresh storage, a generated instance secret and the admin key the binary derives from it; the harness sets the environment variables, deploys the composition's function directory and, when the test ends, stops the process and removes the storage (E-15, S13) -- tierNativeIdentity: [extension] each caller under test is an ordinary `ConvexHttpClient` or `ConvexClient` whose `setAuth` carries a token the harness signs; the composition's `auth.config.ts` declares one `customJwt` provider whose `issuer`, `applicationID` and `jwks` come from the deployment's environment variables, and the harness sets `jwks` to a data URI holding the fixture issuer's public key, so `ctx.auth.getUserIdentity()` in the public entry returns the shape a production token yields and `establishActor`, the grants read and the namespace rules run unchanged; no production issuer is asked to sign for a disposable backend (E-13, S13, D11) +- tierNative: a disposable local backend per test, proving component and nested-mutation behavior, scheduling, contention and deployment (Acceptance scenarios, S13) - tierEndToEnd: the production composition on a representative dataset; Sc L2-8, L2-9, L3-7, L4-3 (Acceptance scenarios) - tierBuild: [extension] a compile of the kernel's fixture app with the change under test applied, asserting a non-zero `tsc` exit naming the caller or a failed registration check, run as a build step before the native suite with no backend; Sc L2-4 (E-14, Sc L2-4) -- evidenceRecord: commit, installed layers, backend and dependency versions, configuration, dataset, command and result, plus the difference from production when configuration was adjusted, and the identity source, which on the native tier is the fixture issuer the deployment's environment names and is the one difference a production-configuration run names (Acceptance scenarios, E-13) +- evidenceRecord: commit, installed layers, backend and dependency versions, configuration, dataset, command and result, plus the difference from production when configuration was adjusted (Acceptance scenarios) - readinessWords: specified, implemented, tested under named conditions, operationally accepted; never a percentage or a score (Acceptance scenarios) ## Verification — reviewed diff --git a/design/specs/platform/acceptance-contract.admin-key-acting-as-identity-reaches-internal-function.sdp.md b/design/specs/platform/native-harness.admin-key-acting-as-identity-reaches-internal-function.sdp.md similarity index 72% rename from design/specs/platform/acceptance-contract.admin-key-acting-as-identity-reaches-internal-function.sdp.md rename to design/specs/platform/native-harness.admin-key-acting-as-identity-reaches-internal-function.sdp.md index 0f1258b..6febcab 100644 --- a/design/specs/platform/acceptance-contract.admin-key-acting-as-identity-reaches-internal-function.sdp.md +++ b/design/specs/platform/native-harness.admin-key-acting-as-identity-reaches-internal-function.sdp.md @@ -1,11 +1,11 @@ --- -id: spec:platform.acceptance-contract.admin-key-acting-as-identity-reaches-internal-function +id: spec:platform.native-harness.admin-key-acting-as-identity-reaches-internal-function kind: example altitude: story readiness: defined relations: - refines: spec:platform.acceptance-contract - verifies: spec:platform.acceptance-contract + refines: spec:platform.native-harness + verifies: spec:platform.native-harness --- # The admin key acting as an identity reaches an internal function @@ -19,11 +19,12 @@ E-13 · native tier · fixture composition. Given a disposable backend running the fixture composition, which registers an internal mutation that writes one row And a client holding the admin key and acting as the identity with subject {subject: "user-1"} When the client calls the internal mutation by name -Then Convex runs the call {runs: true} -And the row the internal mutation writes is stored {stored: true} +Then the call returns without an error +And the table the internal mutation writes holds {rows: 1} row ``` ## Verification — executable - Runs in the native tier on the fixture composition; every test owns its disposable backend. - The test asserts that a public query called by the same client returns the identity it acts as, so the client is indistinguishable from an ordinary caller inside a handler. +- This is the one test in which a caller holds the admin key. diff --git a/design/specs/platform/acceptance-contract.fixture-issuer-token-yields-identity.sdp.md b/design/specs/platform/native-harness.fixture-issuer-token-yields-identity.sdp.md similarity index 80% rename from design/specs/platform/acceptance-contract.fixture-issuer-token-yields-identity.sdp.md rename to design/specs/platform/native-harness.fixture-issuer-token-yields-identity.sdp.md index 1052ca3..7b418e2 100644 --- a/design/specs/platform/acceptance-contract.fixture-issuer-token-yields-identity.sdp.md +++ b/design/specs/platform/native-harness.fixture-issuer-token-yields-identity.sdp.md @@ -1,11 +1,11 @@ --- -id: spec:platform.acceptance-contract.fixture-issuer-token-yields-identity +id: spec:platform.native-harness.fixture-issuer-token-yields-identity kind: example altitude: story readiness: defined relations: - refines: spec:platform.acceptance-contract - verifies: spec:platform.acceptance-contract + refines: spec:platform.native-harness + verifies: spec:platform.native-harness --- # A token signed by the fixture issuer yields the caller's identity @@ -20,7 +20,7 @@ Given a disposable backend whose environment variables name the fixture issuer { And an ordinary client carrying a token the harness signed for subject {subject: "user-1"} When the client calls a public query that returns the caller's identity Then the identity names issuer {identityIssuer: "https://fixture-issuer.test"} and subject {identitySubject: "user-1"} -And the same query from a client with no token returns an identity {anonymousHasIdentity: false} +And the same query from a client with no token returns no identity ``` ## Verification — executable diff --git a/design/specs/platform/native-harness.ordinary-client-refused-component-function.sdp.md b/design/specs/platform/native-harness.ordinary-client-refused-component-function.sdp.md new file mode 100644 index 0000000..eabb482 --- /dev/null +++ b/design/specs/platform/native-harness.ordinary-client-refused-component-function.sdp.md @@ -0,0 +1,33 @@ +--- +id: spec:platform.native-harness.ordinary-client-refused-component-function +kind: example +altitude: story +readiness: defined +relations: + refines: spec:platform.native-harness + verifies: spec:platform.native-harness +--- +# An ordinary client cannot call a component's function + +E-13, E-15 · native tier · fixture composition. + +## Intent + +- outcome: Convex refuses the call on both routes a client has, each with its own error, nothing is stored, and the same function runs for the harness's admin access. (E-13, E-15, D2) + +```gwt +Given a disposable backend running the fixture composition, which mounts a component with a mutation that writes one row +And an ordinary client carrying a token the harness signed for subject {subject: "user-1"} +When the client calls the component's mutation by each of the two routes a client has +Then the call to the public endpoint with a component-qualified name fails with an error whose text holds {publicRouteRefusal: "Could not find public function"} +And the call to the endpoint that takes a component path fails with an error whose text holds {componentRouteRefusal: "BadDeployKey"} +And the table the component's mutation writes holds {rowsAfterRefusal: 0} rows +And the same mutation called with the harness's admin access writes {rowsAfterAdminCall: 1} row +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- Each refusal is identified by the text of Convex's own error, so a call that fails for another reason does not pass. +- The test repeats both refused calls from a client with no token and asserts the same errors, and reads the component's table with the harness's admin access. +- The admin call comes last and on the same deployment, so the refused function is shown to exist and to write its row. diff --git a/design/specs/platform/native-harness.ordinary-client-refused-internal-function.sdp.md b/design/specs/platform/native-harness.ordinary-client-refused-internal-function.sdp.md new file mode 100644 index 0000000..9a504b3 --- /dev/null +++ b/design/specs/platform/native-harness.ordinary-client-refused-internal-function.sdp.md @@ -0,0 +1,32 @@ +--- +id: spec:platform.native-harness.ordinary-client-refused-internal-function +kind: example +altitude: story +readiness: defined +relations: + refines: spec:platform.native-harness + verifies: spec:platform.native-harness +--- +# An ordinary client cannot call an internal function + +E-13, E-15 · native tier · fixture composition. + +## Intent + +- outcome: Convex refuses the call with its own error, nothing is stored, and the same function runs for the harness's admin access. (E-13, E-15) + +```gwt +Given a disposable backend running the fixture composition, which registers an internal mutation that writes one row +And an ordinary client carrying a token the harness signed for subject {subject: "user-1"} +When the client calls the internal mutation by name +Then the call fails with an error whose text holds {refusal: "Could not find public function"} +And the table the internal mutation writes holds {rowsAfterRefusal: 0} rows +And the same mutation called with the harness's admin access writes {rowsAfterAdminCall: 1} row +``` + +## Verification — executable + +- Runs in the native tier on the fixture composition; every test owns its disposable backend. +- The refusal is identified by the text of Convex's own error, so a call that fails for another reason does not pass. +- The test repeats the refused call from a client with no token and asserts the same error, and reads the table with the harness's admin access. +- The admin call comes last and on the same deployment, so the refused function is shown to exist and to write its row. diff --git a/design/specs/platform/native-harness.sdp.md b/design/specs/platform/native-harness.sdp.md new file mode 100644 index 0000000..a545a5b --- /dev/null +++ b/design/specs/platform/native-harness.sdp.md @@ -0,0 +1,66 @@ +--- +id: spec:platform.native-harness +kind: rule +altitude: feature +readiness: defined +relations: + refines: spec:platform.acceptance-contract +--- +# The native harness + +Feature · Detail: extension · Traces: Acceptance scenarios (tiers, evidence), S13, Sc L2-9, D11, E-13, E-15. + +The doc says that native tests prove component and nested-mutation behavior, scheduling, contention and deployment, that the kernel's fixture app is separate from the example app, that test-only functions never ship and that every test owns its disposable backend. It does not say how a test obtains an identity, what the fixture app contains, how a fault enters a run, what a test may read or what it controls. This Spec carries those rules as two extensions, E-13 and E-15. Every rule here is the corpus's provisional reading until the owner rules. The doc's own rules on tiers and evidence stay in `spec:platform.acceptance-contract`. + +## Intent + +- outcome: A native test runs on a backend it owns, as an ordinary caller, on a named composition, and asserts only what a client or the harness's admin access can read (Acceptance scenarios) +- value: Every later slice builds its native tests on this harness, so a harness that is more privileged or more lenient than production lowers the worth of every native pass (Sc L2-9) +- risk: One backend per test costs a process start, a deploy and a teardown per test, and a harness that leaks a process or a port fails tests that did nothing wrong (Acceptance scenarios) +- assumption: Native tests run against a local Convex backend as S13 describes; the testing guide S13 links obtains a test's identity through the backend's admin key acting as that identity, which this harness does not use for a caller under test (S13, E-13) + +### Open questions + +- [non-blocking] Extension E-13: the doc says native acceptance runs with production authority and a disposable backend per test and does not say how a test obtains a signed identity, which a production issuer cannot mint for a throwaway backend; the option taken here is a fixture issuer: a composition that runs natively reads its issuer, application ID and key set from the deployment's environment variables, the harness sets the key set to a data URI whose private key it holds, and each caller under test is an ordinary client carrying a token the harness signs; the option first taken, the backend's admin key acting as an identity, was dropped on 2026-10-01 because such a caller passes Convex's visibility check for internal functions, which `spec:platform.native-harness.admin-key-acting-as-identity-reaches-internal-function` shows; this reading reaches into production, because running the production composition natively with no changed file means the example application's `auth.config.ts` is a `customJwt` provider whose trust root is whatever the deployment's environment names, and a provider that fetches its keys from an issuer's own endpoint cannot be run on a disposable backend this way; the owner rules on both halves, the test identity and the production provider (E-13, S13, Sc L2-9, D11) +- [non-blocking] Extension E-15: the doc does not say what the fixture app contains, how a fault enters a run, what a test may observe, or what a test controls on a backend that offers no clock and no scheduler switch; the option taken here is the rules below; two of them read the doc for the owner: the production-configuration run of Sc L2-9 is taken as the scenarios routed to the production composition, where the corpus earlier asked for the full Layer 0 to 2 suite on a release build, and evidence records that back a pass claim are kept in the repository; whether the durations Layer 3 pins become configuration, so that a test can outwait them, is left to Layer 3 (E-15, Acceptance scenarios, Sc L2-9) + +## Rule + +- [extension] Parity with a release for Sc L2-9 means the same code path, schema, concurrency and authority model: the grants, namespaces, entry points and actor mapping, and Convex's own visibility check on every call a caller under test makes (E-13, S13, Sc L2-9) +- [extension] A caller under test is an ordinary client that carries a token signed by the fixture issuer, or no token (E-13) +- [extension] A composition that runs natively reads its issuer, its application ID and its key set from the deployment's environment variables, and the harness sets the key set to a data URI that holds the fixture issuer's public key (E-13, S13) +- [extension] No caller under test carries the admin key, alone or acting as an identity, because a call made with it passes Convex's visibility check (E-13) +- [extension] The fixture issuer is the identity source of a native run and its one named difference from production; it is recorded as the identity source and is not an adjusted configuration (E-13, Sc L2-9) +- [extension] The harness uses the admin key to deploy, to set environment variables, to read stored data and the function log, and to run fixture functions (E-15) +- [extension] An assertion about authority, identity or visibility is made through an ordinary client (E-15, E-13) +- [extension] A native scenario runs on one of two compositions, the fixture composition or the production composition, and its example names which (E-15) +- [extension] The fixture composition mounts the shared libraries beside fixture contexts and fixture functions; the production composition is the example application with no test-only function registered (E-15, Acceptance scenarios) +- [extension] A scenario runs on the production composition unless it needs a fixture-only state or operation, a fault or a fixture function (E-15) +- [extension] The production-configuration run of Sc L2-9 is the scenarios routed to the production composition, deployed with no test-only function and no adjusted configuration (E-15, Sc L2-9) +- [extension] A scenario on the fixture composition is native evidence for its own row; it runs the shared libraries with their own tables and the same authority model, beside fixture contexts whose state is the fixture's own (E-15, First experiment) +- [extension] The shared libraries carry no test hook: they read no flag and take no callback that exists only for a test (E-15) +- [extension] A fault enters a native run only through something the fixture composition supplies: a fixture context's declaration, a fixture function, or a dependency the libraries take from every composition (E-15) +- [extension] A native assertion reads only what a client or the harness's admin access can read: a returned value, the data of a thrown error, stored documents and the function log (E-15) +- [extension] Where a scenario asserts that something did not run, the test shows the refusal's own error, shows that nothing the thing writes is stored, and shows on the same deployment that the thing exists and runs when called with admin access; where no error tells a refusal from a failure, the fixture composition supplies a part that fails the call if it runs (E-15) +- [extension] The native tier controls a run through four things: the backend process, its clients, the deployment's environment variables and, on the fixture composition, fixture functions (E-15) +- [extension] The harness starts the backend on fresh storage, stops it, kills it, and restarts it on the same storage (E-15, S13) +- [extension] A client under test may close, and may lose a response (E-15) +- [extension] The native tier cannot move the backend's clock and cannot switch its scheduler off (E-15, S13) +- [extension] A scenario that needs time to pass waits in real time or shortens a duration that its owning Spec makes configuration, and a run with a shortened duration is adjusted configuration (E-15, Acceptance scenarios) +- [extension] A native run's record names the backend that ran by its release and by the hash of the executable, the identity source, and the commit with whether the tree was clean (E-15, Acceptance scenarios) +- [extension] A claim that a tier passed cites a record that is kept in the repository and was made on a clean tree at the commit it names (E-15, Acceptance scenarios) + +## Design + +The harness is plain test support with no Convex function in it. The facts below were observed on 2026-10-01 on the backend release the repository pins, `precompiled-2026-09-28-5c7cb5b`, with `convex` 1.46.0. + +- backend: [extension] the disposable backend is the `convex-local-backend` binary of one pinned release, checked against a recorded hash; the harness starts it on local ports with fresh storage, a generated instance secret and the admin key the binary derives from it, sets the environment variables, deploys the composition's function directory, and at the end of the test stops the process and removes the storage (E-15, S13) +- identity: [extension] a caller is a `ConvexHttpClient` or a `ConvexClient` whose `setAuth` carries a token the harness signs; `auth.config.ts` declares one `customJwt` provider whose `issuer`, `applicationID` and `jwks` are environment variables, so `ctx.auth.getUserIdentity()` in a public entry returns the shape a production token yields and `establishActor`, the grants read and the namespace rules run unchanged (E-13, S13, D11) +- adminReads: [extension] the harness reads a table of the app or of a component, and the function log's completion records, with the admin key; a table read returns every document with every Convex value intact or fails, never a sample, and a log read returns every completion record after a point the test marks (E-15) +- localWriteRate: [extension] the pinned local backend holds and then refuses writes past a rate, with `TooManyWrites` and the text "limited to 4 MiB bytes written per 1 second"; one transaction that writes 8.8 MiB commits, so the rate is not a bound on a transaction; a test that writes more than the rate paces its transactions, and a throughput measured on this backend states the limit (E-15, S13) +- evidenceKept: [extension] the record of the run a Spec or a commit cites is committed under `evidence/`; other runs' records are not kept (E-15, Acceptance scenarios) + +## Verification — reviewed + +- A reviewer confirms that every native example names its composition, and that no native test gives a caller under test the admin key except the one example that shows why not. +- A reviewer confirms that each example which asserts a refusal also shows, on the same deployment, that the refused thing exists. diff --git a/tests/native/admin-key-acting-as-identity-reaches-internal-function.test.ts b/tests/native/admin-key-acting-as-identity-reaches-internal-function.test.ts index 142e568..79cf38a 100644 --- a/tests/native/admin-key-acting-as-identity-reaches-internal-function.test.ts +++ b/tests/native/admin-key-acting-as-identity-reaches-internal-function.test.ts @@ -10,13 +10,13 @@ import { api } from "../../fixture/convex/_generated/api.js"; import { adminClient } from "../../harness/clients.js"; import { backend, client, fixture } from "./world.js"; import type { World } from "./world.js"; -import { adminKeyActingAsIdentityReachesInternalFunctionContract } from "../../generated/contracts/platform.acceptance-contract.admin-key-acting-as-identity-reaches-internal-function.contract.js"; +import { adminKeyActingAsIdentityReachesInternalFunctionContract } from "../../generated/contracts/platform.native-harness.admin-key-acting-as-identity-reaches-internal-function.contract.js"; const anchor = specTest({ id: testAnchorId( "test:platform.admin-key-acting-as-identity-reaches-internal-function", ), verifies: ref( - "spec:platform.acceptance-contract.admin-key-acting-as-identity-reaches-internal-function", + "spec:platform.native-harness.admin-key-acting-as-identity-reaches-internal-function", ), }); void anchor; diff --git a/tests/native/fixture-issuer-token-yields-identity.test.ts b/tests/native/fixture-issuer-token-yields-identity.test.ts index a55f196..732a6ca 100644 --- a/tests/native/fixture-issuer-token-yields-identity.test.ts +++ b/tests/native/fixture-issuer-token-yields-identity.test.ts @@ -10,11 +10,11 @@ import { httpClient } from "../../harness/clients.js"; import { backend, client, fixture } from "./world.js"; import type { World } from "./world.js"; import { createIdentity } from "../../harness/identity.js"; -import { fixtureIssuerTokenYieldsIdentityContract } from "../../generated/contracts/platform.acceptance-contract.fixture-issuer-token-yields-identity.contract.js"; +import { fixtureIssuerTokenYieldsIdentityContract } from "../../generated/contracts/platform.native-harness.fixture-issuer-token-yields-identity.contract.js"; const anchor = specTest({ id: testAnchorId("test:platform.fixture-issuer-token-yields-identity"), verifies: ref( - "spec:platform.acceptance-contract.fixture-issuer-token-yields-identity", + "spec:platform.native-harness.fixture-issuer-token-yields-identity", ), }); void anchor; diff --git a/tests/native/ordinary-client-refused-component-function.test.ts b/tests/native/ordinary-client-refused-component-function.test.ts index f07e00c..8d4de62 100644 --- a/tests/native/ordinary-client-refused-component-function.test.ts +++ b/tests/native/ordinary-client-refused-component-function.test.ts @@ -8,11 +8,11 @@ import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; import { httpClient } from "../../harness/clients.js"; import { backend, client, fixture } from "./world.js"; import type { World } from "./world.js"; -import { ordinaryClientRefusedComponentFunctionContract } from "../../generated/contracts/platform.acceptance-contract.ordinary-client-refused-component-function.contract.js"; +import { ordinaryClientRefusedComponentFunctionContract } from "../../generated/contracts/platform.native-harness.ordinary-client-refused-component-function.contract.js"; const anchor = specTest({ id: testAnchorId("test:platform.ordinary-client-refused-component-function"), verifies: ref( - "spec:platform.acceptance-contract.ordinary-client-refused-component-function", + "spec:platform.native-harness.ordinary-client-refused-component-function", ), }); void anchor; diff --git a/tests/native/ordinary-client-refused-internal-function.test.ts b/tests/native/ordinary-client-refused-internal-function.test.ts index bfb5934..4bffa5d 100644 --- a/tests/native/ordinary-client-refused-internal-function.test.ts +++ b/tests/native/ordinary-client-refused-internal-function.test.ts @@ -9,11 +9,11 @@ import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; import { httpClient } from "../../harness/clients.js"; import { backend, client, fixture } from "./world.js"; import type { World } from "./world.js"; -import { ordinaryClientRefusedInternalFunctionContract } from "../../generated/contracts/platform.acceptance-contract.ordinary-client-refused-internal-function.contract.js"; +import { ordinaryClientRefusedInternalFunctionContract } from "../../generated/contracts/platform.native-harness.ordinary-client-refused-internal-function.contract.js"; const anchor = specTest({ id: testAnchorId("test:platform.ordinary-client-refused-internal-function"), verifies: ref( - "spec:platform.acceptance-contract.ordinary-client-refused-internal-function", + "spec:platform.native-harness.ordinary-client-refused-internal-function", ), }); void anchor; From bee2a7e04bf2f2fa412c85e12b4b47f1f36ee9ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Thu, 1 Oct 2026 18:09:18 +0200 Subject: [PATCH 05/28] design: slice S0 writes the harness design into its Spec and rules on ten gaps the design found The Fable design pass proposed rules and Design entries for spec:platform.native-harness; the main thread ruled and wrote them. - Five rules: CLI calls bound to the owned backend, cleanup owned before a start can stall, no secret in output, an incomplete read fails, the record takes its facts from the backends that ran. - Design entries, signatures only: the executable and its pin, the fixture issuer, the backend and its admin access, the table read, the function log, the clients and their two faults, the evidence record, the layout. - Probe 5's hook example is rebound to what the backend showed: 40 of 45 rows after a capped split. Ledger finding for slice S2. - The split example says where the second half ends. The component refusal example covers the third route, the WebSocket protocol. - docs/sdp-feedback.md: a Spec with designed stubs is a maturity the Protocol cannot state; the owner asks for a ts block in the carrier. Co-Authored-By: Claude Fable 5.1 --- design/reviews/consensus-ledger.json | 18 +++++++++ ...s.probe-3-component-call-from-query.sdp.md | 1 + ...probe-5-hook-splits-across-boundary.sdp.md | 9 +++-- ...tive.probe-5-split-required-relayed.sdp.md | 5 ++- ...y-client-refused-component-function.sdp.md | 11 ++++-- design/specs/platform/native-harness.sdp.md | 38 +++++++++++++++++-- docs/sdp-feedback.md | 24 ++++++++++++ 7 files changed, 93 insertions(+), 13 deletions(-) diff --git a/design/reviews/consensus-ledger.json b/design/reviews/consensus-ledger.json index 7bbb616..1b5329e 100644 --- a/design/reviews/consensus-ledger.json +++ b/design/reviews/consensus-ledger.json @@ -4091,6 +4091,24 @@ "slice": "S0", "detector": "native backend", "note": "S0, 2026-10-01, ruling S7. `spec:facts.probe-plan` carries extension E-16 with six rules: a probe is example Specs that verify its fact; bound values are the expectation written before the first run; a different first result is recorded on the fact and the example is then bound to what the release does and says so; an example fails when its run did not reach its boundary; sizes are recorded and not asserted; what a local backend cannot answer stays open on the fact. Probe 1's three examples name `ConvexClient` or `ConvexHttpClient`, require a trial committed without confirmation, a kill on each side of the commit, and a lost response with one request per call. Probe 3 says quota is not measured and the time is a local backend's. Probe 4's depth binds 9 as observed and calls it a backend default; the shared-limit examples say the seeding is paced under the local backend's write rate and that each half of the write commits alone. Probe 5 gains `probe-5-hook-splits-across-boundary`; the subscription example's parent reads no table of its own. Opus minor 16, drop the depth example, is rejected: the round-3 finding on nested limits asks for the depth and the test is small." + }, + { + "round": "S0", + "id": "s0-helper-hook-loses-rows-after-a-capped-split", + "lens": "convex", + "severity": "major", + "files": [ + "specs/context/queries.sdp.md", + "specs/application/read-models.sdp.md", + "specs/facts/f15-parent-query-over-component-query-stays-reactive.sdp.md", + "specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary.sdp.md" + ], + "issue": "`spec:context.queries` relays a component list's `paginator` page through the parent and relies on the client hook to split a page the helper marks `SplitRequired` (`componentPagination`, `paginationShape`, `parentPassThrough`). On a native backend, release `precompiled-2026-09-28-5c7cb5b`, with `convex-helpers` 0.1.124, the `usePaginatedQuery` hook of `convex-helpers/react` showed 40 of 45 rows after a page of 10 grew to 25 rows under `maximumRowsRead` 20: the five rows between the capped page's `continueCursor` and its end cursor were never loaded, and the hook reported no error. Read in the helper's source by the design agent: a capped page returns `continueCursor` at the last row it read, and the hook ends the second half of the split there. Reading the two halves directly, from the start to the split cursor and from the split cursor to the page's own end cursor, gives all 25 rows. So a list that is capped by `maximumRowsRead` and read with this hook can lose rows without a sign. Found by the design agent's bound test in slice S0, 2026-10-01, run twice; the orchestrator reran it after applying the prototype.", + "fix": "Slice S2 decides before it builds lists: whether a context list sets `maximumRowsRead` at all, whether the parent or the client closes the range to the page's end cursor, or whether the client uses another hook. The Probe 5 example keeps the observed value so a later helper release that fixes it turns the test red.", + "status": "open", + "note": "", + "slice": "S2", + "detector": "native backend" } ], "sessions": [ diff --git a/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md b/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md index 3598d52..c3c97d6 100644 --- a/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md +++ b/design/specs/facts/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.sdp.md @@ -26,5 +26,6 @@ And the function log holds {recordsPerCall: 1} completion record for one parent - Runs in the native tier on the fixture composition; every test owns its disposable backend. - The execution time is the one the backend's function log reports for the parent call, because the clock inside a query does not advance; the run's evidence holds the three medians and the difference per call. +- A cached query also logs a completion record, with `cachedResult` true and a time near zero, so the test gives each call fresh arguments and counts a sample only when its record says the result was not cached. - The size is recorded and not asserted. It is a local backend's time on one machine, a first reading for OQ1 and not a hosted deployment's cost. - Function-call quota is not measured, for the reason the mutation example gives. diff --git a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary.sdp.md b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary.sdp.md index 7059b68..58e0b76 100644 --- a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary.sdp.md +++ b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary.sdp.md @@ -7,19 +7,20 @@ relations: refines: spec:facts.f15-parent-query-over-component-query-stays-reactive verifies: spec:facts.f15-parent-query-over-component-query-stays-reactive --- -# Probe 5: the client hook splits a page across the boundary without a gap +# Probe 5: the client hook splits a page across the boundary and loses rows Probe 5 · native tier · fixture composition. ## Intent -- outcome: The hook shows every row after the split. (Probe 5, F15, S4) +- outcome: On this release the hook splits the page and does not show the rows between the capped page's continue cursor and its end cursor. (Probe 5, F15, S4) ```gwt Given a component list of {rows: 30} rows read through the parent by the usePaginatedQuery hook of convex-helpers/react, in pages of {pageSize: 10} with maximumRowsRead {maximumRowsRead: 20} And the hook has loaded every page When rows are inserted inside the first page's range until it holds {rowsInRange: 25} rows -Then the hook's results hold {rowsShown: 45} rows, every row of the list once and in order +Then the hook's results hold {rowsShown: 40} rows, in order and each once +And {rowsMissing: 5} rows of the list are not shown, the ones between the capped page's continue cursor and its end cursor ``` ## Verification — executable @@ -27,3 +28,5 @@ Then the hook's results hold {rowsShown: 45} rows, every row of the list once an - Runs in the native tier on the fixture composition, with a DOM environment for the hook; every test owns its disposable backend. - The test shows through a second subscription that the capped page came back `SplitRequired`, so a pass cannot come from a page that never needed the split. - The hook is the `usePaginatedQuery` of `convex-helpers/react`, whose pages carry an end cursor. Convex's own `usePaginatedQuery` is not the subject. +- The values are observed, not expected: the expectation written before the first run was 45 rows shown and none missing. The run on release `precompiled-2026-09-28-5c7cb5b` with `convex-helpers` 0.1.124 showed 40. +- The cause is read in the helper's source and not probed further: a capped page returns its `continueCursor` at the last row it read, and the hook ends the second half of the split at that cursor and not at the page's end cursor. diff --git a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md index 2c98e2d..e9200cd 100644 --- a/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md +++ b/design/specs/facts/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.sdp.md @@ -16,13 +16,14 @@ Probe 5 · native tier · fixture composition. - outcome: The parent relays the split, and the pages on either side of it leave no gap. (Probe 5, F15, S4) ```gwt -Given a subscribed page of a component list, bounded by an end cursor and read through the parent with maximumRowsRead {maximumRowsRead: 20} +Given a component list of {rows: 30} rows and a subscribed page of {pageSize: 10}, bounded by its end cursor and read through the parent with maximumRowsRead {maximumRowsRead: 20} When rows are inserted inside the page's range until it holds {rowsInRange: 25} rows Then the page the parent relays carries pageStatus {pageStatus: "SplitRequired"} and a split cursor -And the two pages on either side of the split cursor together hold {rowsHeld: 25} rows, every row of the range once and in order +And the page from the start to the split cursor and the page from the split cursor to the first page's end cursor together hold {rowsHeld: 25} rows, every row of the range once and in order ``` ## Verification — executable - Runs in the native tier on the fixture composition; every test owns its disposable backend. - This example reads the relayed pages directly. Whether the client hook acts on the split is the next example's question. +- The second half ends at the end cursor the page was subscribed with. The capped page's own `continueCursor` sits at the last row it read, and a second half that ends there holds 20 rows and leaves the rest of the range out. diff --git a/design/specs/platform/native-harness.ordinary-client-refused-component-function.sdp.md b/design/specs/platform/native-harness.ordinary-client-refused-component-function.sdp.md index eabb482..7364bd1 100644 --- a/design/specs/platform/native-harness.ordinary-client-refused-component-function.sdp.md +++ b/design/specs/platform/native-harness.ordinary-client-refused-component-function.sdp.md @@ -13,14 +13,15 @@ E-13, E-15 · native tier · fixture composition. ## Intent -- outcome: Convex refuses the call on both routes a client has, each with its own error, nothing is stored, and the same function runs for the harness's admin access. (E-13, E-15, D2) +- outcome: Convex refuses the call on every route a client has, nothing is stored, and the same function runs for the harness's admin access. (E-13, E-15, D2) ```gwt Given a disposable backend running the fixture composition, which mounts a component with a mutation that writes one row And an ordinary client carrying a token the harness signed for subject {subject: "user-1"} -When the client calls the component's mutation by each of the two routes a client has +When the client calls the component's mutation by each of the three routes a client has Then the call to the public endpoint with a component-qualified name fails with an error whose text holds {publicRouteRefusal: "Could not find public function"} And the call to the endpoint that takes a component path fails with an error whose text holds {componentRouteRefusal: "BadDeployKey"} +And the call over the WebSocket protocol with a component path gets no answer within {socketWaitMs: 3000} ms And the table the component's mutation writes holds {rowsAfterRefusal: 0} rows And the same mutation called with the harness's admin access writes {rowsAfterAdminCall: 1} row ``` @@ -28,6 +29,8 @@ And the same mutation called with the harness's admin access writes {rowsAfterAd ## Verification — executable - Runs in the native tier on the fixture composition; every test owns its disposable backend. -- Each refusal is identified by the text of Convex's own error, so a call that fails for another reason does not pass. -- The test repeats both refused calls from a client with no token and asserts the same errors, and reads the component's table with the harness's admin access. +- Each HTTP refusal is identified by the text of Convex's own error, so a call that fails for another reason does not pass. +- `BadDeployKey` is the gate of the endpoint that takes a component path: it answers every caller without the admin key, whatever the path, so on that route the error says nothing about the function, and the admin call is what shows that the function exists. +- The WebSocket route has no error to show: on the pinned release the backend drops the connection and the client reconnects and tries again without end. The test shows no answer within the wait and no row. +- The test repeats the two HTTP calls from a client with no token and asserts the same errors, and reads the component's table with the harness's admin access. - The admin call comes last and on the same deployment, so the refused function is shown to exist and to write its row. diff --git a/design/specs/platform/native-harness.sdp.md b/design/specs/platform/native-harness.sdp.md index a545a5b..85095c1 100644 --- a/design/specs/platform/native-harness.sdp.md +++ b/design/specs/platform/native-harness.sdp.md @@ -31,7 +31,9 @@ The doc says that native tests prove component and nested-mutation behavior, sch - [extension] A composition that runs natively reads its issuer, its application ID and its key set from the deployment's environment variables, and the harness sets the key set to a data URI that holds the fixture issuer's public key (E-13, S13) - [extension] No caller under test carries the admin key, alone or acting as an identity, because a call made with it passes Convex's visibility check (E-13) - [extension] The fixture issuer is the identity source of a native run and its one named difference from production; it is recorded as the identity source and is not an adjusted configuration (E-13, Sc L2-9) -- [extension] The harness uses the admin key to deploy, to set environment variables, to read stored data and the function log, and to run fixture functions (E-15) +- [extension] The harness uses the admin key to deploy, to generate code, to set and read environment variables, to read stored data and the function log, and to run fixture functions (E-15) +- [extension] Every Convex CLI call the harness makes names the backend it started on the command line and gets an environment of its own, so that no variable of the caller's shell and no dotenv file selects another deployment (E-15) +- [extension] No generated secret, the instance secret or the admin key, reaches an error message, test output or a record (E-15) - [extension] An assertion about authority, identity or visibility is made through an ordinary client (E-15, E-13) - [extension] A native scenario runs on one of two compositions, the fixture composition or the production composition, and its example names which (E-15) - [extension] The fixture composition mounts the shared libraries beside fixture contexts and fixture functions; the production composition is the example application with no test-only function registered (E-15, Acceptance scenarios) @@ -41,13 +43,16 @@ The doc says that native tests prove component and nested-mutation behavior, sch - [extension] The shared libraries carry no test hook: they read no flag and take no callback that exists only for a test (E-15) - [extension] A fault enters a native run only through something the fixture composition supplies: a fixture context's declaration, a fixture function, or a dependency the libraries take from every composition (E-15) - [extension] A native assertion reads only what a client or the harness's admin access can read: a returned value, the data of a thrown error, stored documents and the function log (E-15) +- [extension] A table read or a function log read that may be incomplete fails (E-15) - [extension] Where a scenario asserts that something did not run, the test shows the refusal's own error, shows that nothing the thing writes is stored, and shows on the same deployment that the thing exists and runs when called with admin access; where no error tells a refusal from a failure, the fixture composition supplies a part that fails the call if it runs (E-15) - [extension] The native tier controls a run through four things: the backend process, its clients, the deployment's environment variables and, on the fixture composition, fixture functions (E-15) - [extension] The harness starts the backend on fresh storage, stops it, kills it, and restarts it on the same storage (E-15, S13) +- [extension] The harness registers the cleanup of a backend before the start of that backend can stall, and a run that ends or is interrupted kills every backend process it started and removes every backend directory (E-15) - [extension] A client under test may close, and may lose a response (E-15) - [extension] The native tier cannot move the backend's clock and cannot switch its scheduler off (E-15, S13) - [extension] A scenario that needs time to pass waits in real time or shortens a duration that its owning Spec makes configuration, and a run with a shortened duration is adjusted configuration (E-15, Acceptance scenarios) -- [extension] A native run's record names the backend that ran by its release and by the hash of the executable, the identity source, and the commit with whether the tree was clean (E-15, Acceptance scenarios) +- [extension] A native run's record names the backend that ran by the hash of its executable and, when that hash is the pinned one, by its release; it names the identity source, and the commit with whether the tree was clean (E-15, Acceptance scenarios) +- [extension] A run's record takes its facts from the backends that ran, and a measurement is stored on the test that recorded it (E-15, Acceptance scenarios) - [extension] A claim that a tier passed cites a record that is kept in the repository and was made on a clean tree at the commit it names (E-15, Acceptance scenarios) ## Design @@ -56,9 +61,34 @@ The harness is plain test support with no Convex function in it. The facts below - backend: [extension] the disposable backend is the `convex-local-backend` binary of one pinned release, checked against a recorded hash; the harness starts it on local ports with fresh storage, a generated instance secret and the admin key the binary derives from it, sets the environment variables, deploys the composition's function directory, and at the end of the test stops the process and removes the storage (E-15, S13) - identity: [extension] a caller is a `ConvexHttpClient` or a `ConvexClient` whose `setAuth` carries a token the harness signs; `auth.config.ts` declares one `customJwt` provider whose `issuer`, `applicationID` and `jwks` are environment variables, so `ctx.auth.getUserIdentity()` in a public entry returns the shape a production token yields and `establishActor`, the grants read and the namespace rules run unchanged (E-13, S13, D11) -- adminReads: [extension] the harness reads a table of the app or of a component, and the function log's completion records, with the admin key; a table read returns every document with every Convex value intact or fails, never a sample, and a log read returns every completion record after a point the test marks (E-15) +- adminReads: [extension] the harness reads a table of the app or of a component, and the function log's completion records, with the admin key; a table read returns every document with every Convex value intact or fails, never a sample, and is several queries, not one snapshot; a log read returns every completion record after a point the test marks or fails (E-15) +- tableRead: [extension] a table read runs the system query `_system/cli/tableData` over a WebSocket with the admin key, in pages until done, and checks an empty result against `_system/cli/tables`, because the backend answers a missing table with an empty page (E-15) +- functionLog: [extension] a log read polls `/api/stream_function_logs` from the mark; a mark is the machine clock in milliseconds rounded up and followed by a short wait; the pinned backend keeps its last 1000 entries in memory and loses them at a restart, so a read that returns 1000 entries fails (E-15) +- fnResolveExecutable: [extension] `resolveExecutable(options?: { cacheRoot?: string; fetch?: typeof fetch }): Promise` hashes the executable that will run, replaces a cache entry whose hash is not the pinned one, and takes the file `CONVEX_BACKEND_BINARY` names when it is set (E-15) +- typeExecutable: [extension] `interface Executable { path: string; sha256: string; release: string | null; source: "cache" | "CONVEX_BACKEND_BINARY" }`, where `release` is null unless the hash is the pinned one, because the binary cannot name its own release (E-15) +- pinFile: [extension] `harness/backend-release.json` holds the release and, per platform, the zip's file name, the zip's SHA-256 and the executable's SHA-256; two platforms are pinned, `darwin-arm64` and `linux-x64` (E-15, S13) +- fnCreateFixtureIssuer: [extension] `createFixtureIssuer(issuer?: string): Promise` makes an ES256 key pair and keeps the private key (E-13) +- typeFixtureIssuer: [extension] `interface FixtureIssuer { issuer: string; applicationID: string; jwks: string; token(subject: string): Promise }` (E-13) +- fnStartBackend: [extension] `startBackend(options: { executable: Executable; issuer: FixtureIssuer; parentDirectory?: string; signal?: AbortSignal }): Promise` starts the process on fresh storage and sets the issuer's environment variables; it does not deploy (E-15, E-13) +- typeBackend: [extension] `interface Backend { url: string; adminKey: string; issuer: FixtureIssuer; admin: AdminAccess; stop(): Promise; kill(): Promise; restart(): Promise; dispose(): Promise; facts(): BackendFacts }`; `stop` and `kill` keep the storage, and `dispose` alone removes it (E-15) +- backendReady: [extension] a backend is ready when `GET /instance_name` answers the random instance name it was started with and its process is alive; a backend that exits on a taken port is started again on other ports, three attempts at most (E-15) +- typeAdminAccess: [extension] `interface AdminAccess { deploy(): Promise; codegen(): Promise; setEnvironment(variables: Record): Promise; environment(): Promise>; run(name: string, args?: Record, options?: { component?: string }): Promise; readTable(table: string, options?: { component?: string; pageSize?: number }): Promise[]>; logMark(): Promise; completionsSince(mark: LogMark, until: (records: readonly CompletionRecord[]) => boolean, timeoutMs?: number): Promise }` holds every use of the admin key and takes no identity (E-15) +- typeCompletionRecord: [extension] `interface CompletionRecord { kind: "Completion"; udfType: string; identifier: string; componentPath: string | null; requestId: string; executionTime: number; cachedResult: boolean; error: string | null }`, with `executionTime` in seconds (E-15) +- fnConvexCli: [extension] `convexCli(target: { url: string; adminKey: string; home: string }, command: "deploy" | "codegen"): { file: string; args: string[]; cwd: string; env: Record; timeoutMs: number }` ends its arguments with `--url` and `--admin-key` and gives an environment of `PATH`, `HOME` and `TMPDIR` only; the harness calls the CLI for these two commands and nothing else (E-15) +- fnOrdinaryClient: [extension] `ordinaryClient(url: string, options?: { token?: string; fetch?: typeof fetch }): ConvexHttpClient` (E-13) +- fnOrdinarySocketClient: [extension] `ordinarySocketClient(url: string, options?: { token?: string; webSocket?: typeof WebSocket }): ConvexClient` (E-13) +- fnCountingFetch: [extension] `countingFetch(options: { loseResponses: boolean }): { fetch: typeof fetch; requests(): number }` delivers each request and, when it loses responses, throws after the backend has answered (E-15) +- fnHeldWebSocket: [extension] `heldWebSocket(): { WebSocket: typeof WebSocket; holdResponses(): void; pauseConnections(): void; release(): void }`; a socket that closes drops the responses it held (E-15) +- fnWatchQuery: [extension] `watchQuery>(client: ConvexClient, query: Query, args: FunctionArgs): { values: readonly FunctionReturnType[]; error: Error | undefined; until(matches: (value: FunctionReturnType) => boolean, description: string, timeoutMs?: number): Promise>; stop(): void }` (E-15) +- fnFixtureBackend: [extension] `fixtureBackend(options?: { issuer?: string }): Promise` starts a backend, deploys the fixture composition, and removes the backend when the test finishes, however it finishes (E-15, E-13) +- fnMeasure: [extension] `measure(name: string, value: JsonValue): void` stores a measurement on the running test (E-15) +- typeBackendFacts: [extension] `interface BackendFacts { composition: "fixture" | null; installedLayers: string[]; executable: { release: string | null; sha256: string; source: "cache" | "CONVEX_BACKEND_BINARY" }; identitySource: { kind: "fixture issuer"; issuer: string }; environment: string[]; dataset: "empty at start" }` (E-15, E-13) +- typeNativeTestEntry: [extension] `interface NativeTestEntry { name: string; file: string; result: "passed" | "failed" | "skipped"; durationMs: number | null; errors: string[]; backends: BackendFacts[]; measurements: { name: string; value: JsonValue }[] }` (E-15) +- typeNativeRunRecord: [extension] `interface NativeRunRecord { tier: "native"; commit: string; clean: boolean; command: string; startedAt: string; finishedAt: string; result: "passed" | "failed" | "interrupted"; versions: Record; tests: NativeTestEntry[]; unhandledErrors: string[] }`; it has no field for adjusted configuration until a slice adjusts one (E-15, Acceptance scenarios) +- fixtureLayout: [extension] the repository has one `package.json`; the root `convex.json` names `fixture/convex/` as the fixture composition's function directory, and the CLI runs in the repository root (E-15) +- fixtureComponent: [extension] the fixture composition mounts one plain component as `annex`; a fixture context gets its own name when a slice adds it (E-15) - localWriteRate: [extension] the pinned local backend holds and then refuses writes past a rate, with `TooManyWrites` and the text "limited to 4 MiB bytes written per 1 second"; one transaction that writes 8.8 MiB commits, so the rate is not a bound on a transaction; a test that writes more than the rate paces its transactions, and a throughput measured on this backend states the limit (E-15, S13) -- evidenceKept: [extension] the record of the run a Spec or a commit cites is committed under `evidence/`; other runs' records are not kept (E-15, Acceptance scenarios) +- evidenceKept: [extension] every native run writes its record under `evidence/runs/`, which git ignores; the record of the run a Spec or a commit cites is copied to `evidence/` and committed; other runs' records are not kept (E-15, Acceptance scenarios) ## Verification — reviewed diff --git a/docs/sdp-feedback.md b/docs/sdp-feedback.md index 4a8f885..5c98d96 100644 --- a/docs/sdp-feedback.md +++ b/docs/sdp-feedback.md @@ -4,6 +4,30 @@ A running file, addressed to the Protocol's maintainers. Each `adopt` unit and e Every number here was measured on this corpus on the date of its section. Re-run before relying on one. +## 2026-10-01, slice S0, the first build on the Protocol at `5993da7` + +### A Spec with designed stubs is a maturity the Protocol cannot state + +The owner's direction during this slice: the Protocol should support Specs at any level of maturity, and that includes a Spec whose key abstractions are designed as stubs. The earlier generation of this tooling, architect, did that, and the Protocol is its successor. + +What happened here. Before a build job, a design agent decides the key abstractions of a Spec: the exported interface of a module, the shape of a record, the names. The slice's first design brief gave that work no home in the corpus, so the agent wrote toward a long design file outside the repository and then prototyped the code, for about an hour, with nothing to tell it when the design was done. A Spec that can hold the stubs is what bounds a design pass: the design is done when the Spec's key abstractions are stated as stubs, and the implementer writes the rest. + +What the Protocol offers today, and where it stops: + +1. **Readiness has no rung for it.** The rungs are `idea`, `scoped`, `defined` and `ready`, and they describe the intent. A Spec at `defined` with no design and a Spec at `defined` whose interfaces are settled look the same in the graph, so the build backlog cannot tell which Specs an implementer can start on. +2. **A stub in code reads as `implemented`.** The only binding from code to a Spec is a `codeAnchor` with `satisfies`, and one resolving `satisfies` edge derives `implemented`. A file of signatures with no bodies would claim the Spec is realized, and on a Spec below `ready` it would raise the drift alarm. There is no anchor, or flag on an anchor, that says "this is the designed surface, not the implementation". +3. **A stub in the Spec is one line of prose.** A `## Design` entry is `key: value` on one physical line, and fenced code is refused there. This corpus writes signatures as inline code in such entries, 762 of them, and a multi-member interface on one line is hard to read and to review. Nothing checks a Design entry's signature against the exported one, so the two drift apart without a finding. Authored order for Design entries, already asked for below, matters more once the entries are stubs a reader walks in order. + +The owner's ask, which closes item 3: the Markdown carrier accepts a `ts` fenced block in a Spec, holding the design of its key contracts: the interfaces, types and function signatures a reader would otherwise find squeezed into one-line Design entries. Today the carrier accepts two fences, `gwt-vocabulary` under `## Example space` and `gwt` on an example, and refuses every other. + +What that leaves for the maintainers to decide: + +- Where the block is lawful: under `## Design`, under `## Contract`, or both, and whether one block per Spec or one per named contract. +- Whether the graph reads it. Read as data, each declared name could be an entry that entry search (recipe 23) finds, and a later check could compare a stub with the exported signature of the code anchored to the Spec. Read as content only, it is still what a reviewer and an implementer need. +- How a Spec with such a block shows in the graph, which is items 1 and 2: a `designed` fact or rung that the build backlog can filter on, and a code binding for a stub file that does not derive `implemented`. + +What this corpus does until then: the design agent proposes `## Design` entries and rules for the Specs a slice builds, signatures only, one line each, and the main thread writes them into the Spec. Stub files in code carry no anchor until they have bodies. When the carrier accepts a `ts` block, the 762 one-line entries that are declarations move into blocks, and the design pass writes its stubs there. + ## 2026-10-01, adopting pull request 26 at `5993da7` The corpus now depends on the Protocol at commit `5993da7b8f61ce00853b790cd9ffb428842b66d0`, the head of `feature/adopter-driven-hardening`. From d92c92c662be25988ddca2c70480270f5404946f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Thu, 1 Oct 2026 18:10:11 +0200 Subject: [PATCH 06/28] wip: the design pass's prototype applied as it stands The Fable design agent built this in a scratch worktree while designing the fold-in. It is applied unchanged as a head start: a reshaped harness, the fixture's component renamed to annex with modules named for what they hold, one package.json with a root convex.json, one codegen script, two CI jobs. Unreviewed. On this tree, 2026-10-01, backend precompiled-2026-09-28-5c7cb5b: codegen leaves no diff; lint and format pass; 21 tests pass in the type, pure and convex-test tiers; native, 7 of 9 files pass. The two red files bind example steps that were reworded after the patch was cut, and typecheck fails on the same two. Ten probe tests are not written yet. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 32 +- .gitignore | 2 +- .prettierignore | 2 +- README.md | 22 +- convex.json | 3 + design/README.md | 2 +- eslint.config.js | 2 +- evidence/README.md | 25 +- fixture/convex/_generated/api.d.ts | 28 +- .../convex/{probe => annex}/_generated/api.ts | 22 +- .../{probe => annex}/_generated/component.ts | 67 ++- .../{probe => annex}/_generated/dataModel.ts | 0 .../{probe => annex}/_generated/server.ts | 0 .../convex/{probe => annex}/convex.config.ts | 2 +- fixture/convex/annex/failures.ts | 17 + fixture/convex/annex/identity.ts | 7 + fixture/convex/annex/limits.ts | 49 ++ .../convex/{probe/probe5.ts => annex/list.ts} | 40 +- fixture/convex/annex/notes.ts | 10 + fixture/convex/annex/readCost.ts | 15 + fixture/convex/annex/schema.ts | 15 + fixture/convex/convex.config.ts | 4 +- fixture/convex/failures.ts | 71 +++ fixture/convex/identity.ts | 14 + fixture/convex/inspection.ts | 52 -- fixture/convex/limits.ts | 133 +++++ fixture/convex/list.ts | 51 ++ fixture/convex/{probe1.ts => markers.ts} | 6 +- fixture/convex/notes.ts | 10 + fixture/convex/probe/inspection.ts | 26 - fixture/convex/probe/probe2.ts | 16 - fixture/convex/probe/probe3.ts | 17 - fixture/convex/probe/probe4.ts | 51 -- fixture/convex/probe/schema.ts | 15 - fixture/convex/probe2.ts | 53 -- fixture/convex/probe3.ts | 62 -- fixture/convex/probe4.ts | 121 ---- fixture/convex/probe5.ts | 42 -- fixture/convex/readCost.ts | 70 +++ fixture/convex/schema.ts | 11 +- fixture/package.json | 8 - harness/admin.ts | 289 ++++++++++ harness/backend-release.json | 14 +- harness/backend.ts | 532 +++++++----------- harness/child.ts | 53 ++ harness/clients.ts | 205 ++++++- harness/evidence.ts | 225 ++++---- harness/executable.ts | 121 ++++ harness/identity.ts | 39 +- harness/native-run.ts | 44 ++ harness/native.ts | 53 ++ harness/wait.ts | 10 +- package-lock.json | 206 +------ package.json | 8 +- scripts/codegen.mjs | 35 +- scripts/codegen.ts | 4 - ...identity-reaches-internal-function.test.ts | 113 ++-- .../native/component-auth.observation.test.ts | 21 - tests/native/component-auth.test.ts | 24 + ...obe-3-component-call-from-mutation.test.ts | 141 ----- ....probe-3-component-call-from-query.test.ts | 141 ----- ...kend-restart-with-pending-mutation.test.ts | 210 ++++--- ...lient-closed-with-pending-mutation.test.ts | 160 +++--- ...onfirmed.probe-1-http-client-retry.test.ts | 122 ++-- ...obe-4-component-call-shares-limits.test.ts | 170 ------ ....probe-4-nested-call-shares-limits.test.ts | 170 ------ ...-have-limits.probe-4-nesting-depth.test.ts | 69 --- ...oundary.probe-2-component-boundary.test.ts | 127 ----- ...t-boundary.probe-2-nested-mutation.test.ts | 127 ----- ...robe-5-hook-splits-across-boundary.test.ts | 206 +++++++ ...tive.probe-5-pages-stay-contiguous.test.ts | 99 ---- ...ive.probe-5-split-required-relayed.test.ts | 118 ---- ...ctive.probe-5-subscription-updates.test.ts | 71 --- ...xture-issuer-token-yields-identity.test.ts | 94 ++-- tests/native/list-pages.ts | 54 ++ tests/native/marker-rows.ts | 20 + ...-client-refused-component-function.test.ts | 124 ++-- ...y-client-refused-internal-function.test.ts | 86 +-- tests/native/probe1-transport.ts | 77 --- tests/native/probe5-hook.test.ts | 182 ------ tests/native/probe5-support.ts | 107 ---- tests/native/world.ts | 29 - tests/pure/child.test.ts | 51 ++ tests/pure/clients.test.ts | 91 +++ tests/pure/convex-cli.test.ts | 36 ++ tests/pure/executable.test.ts | 69 +++ tests/pure/fixture-issuer.test.ts | 40 ++ tests/pure/identity.test.ts | 31 - tests/pure/native-run.test.ts | 23 + .../simulator/boundaries.observation.test.ts | 39 -- tests/simulator/boundaries.test.ts | 40 ++ tests/simulator/probe1-transport.test.ts | 62 -- tests/types/api.test-d.ts | 22 +- tsconfig.json | 3 +- vitest.config.ts | 3 +- 95 files changed, 2939 insertions(+), 3466 deletions(-) create mode 100644 convex.json rename fixture/convex/{probe => annex}/_generated/api.ts (69%) rename fixture/convex/{probe => annex}/_generated/component.ts (69%) rename fixture/convex/{probe => annex}/_generated/dataModel.ts (100%) rename fixture/convex/{probe => annex}/_generated/server.ts (100%) rename fixture/convex/{probe => annex}/convex.config.ts (54%) create mode 100644 fixture/convex/annex/failures.ts create mode 100644 fixture/convex/annex/identity.ts create mode 100644 fixture/convex/annex/limits.ts rename fixture/convex/{probe/probe5.ts => annex/list.ts} (56%) create mode 100644 fixture/convex/annex/notes.ts create mode 100644 fixture/convex/annex/readCost.ts create mode 100644 fixture/convex/annex/schema.ts create mode 100644 fixture/convex/failures.ts create mode 100644 fixture/convex/identity.ts delete mode 100644 fixture/convex/inspection.ts create mode 100644 fixture/convex/limits.ts create mode 100644 fixture/convex/list.ts rename fixture/convex/{probe1.ts => markers.ts} (57%) create mode 100644 fixture/convex/notes.ts delete mode 100644 fixture/convex/probe/inspection.ts delete mode 100644 fixture/convex/probe/probe2.ts delete mode 100644 fixture/convex/probe/probe3.ts delete mode 100644 fixture/convex/probe/probe4.ts delete mode 100644 fixture/convex/probe/schema.ts delete mode 100644 fixture/convex/probe2.ts delete mode 100644 fixture/convex/probe3.ts delete mode 100644 fixture/convex/probe4.ts delete mode 100644 fixture/convex/probe5.ts create mode 100644 fixture/convex/readCost.ts delete mode 100644 fixture/package.json create mode 100644 harness/admin.ts create mode 100644 harness/child.ts create mode 100644 harness/executable.ts create mode 100644 harness/native-run.ts create mode 100644 harness/native.ts delete mode 100644 scripts/codegen.ts delete mode 100644 tests/native/component-auth.observation.test.ts create mode 100644 tests/native/component-auth.test.ts delete mode 100644 tests/native/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-mutation.test.ts delete mode 100644 tests/native/f04-nested-calls-cost-more-than-helpers.probe-3-component-call-from-query.test.ts delete mode 100644 tests/native/f13-transactions-have-limits.probe-4-component-call-shares-limits.test.ts delete mode 100644 tests/native/f13-transactions-have-limits.probe-4-nested-call-shares-limits.test.ts delete mode 100644 tests/native/f13-transactions-have-limits.probe-4-nesting-depth.test.ts delete mode 100644 tests/native/f14-convex-error-survives-nested-and-component-boundary.probe-2-component-boundary.test.ts delete mode 100644 tests/native/f14-convex-error-survives-nested-and-component-boundary.probe-2-nested-mutation.test.ts create mode 100644 tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-hook-splits-across-boundary.test.ts delete mode 100644 tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-pages-stay-contiguous.test.ts delete mode 100644 tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-split-required-relayed.test.ts delete mode 100644 tests/native/f15-parent-query-over-component-query-stays-reactive.probe-5-subscription-updates.test.ts create mode 100644 tests/native/list-pages.ts create mode 100644 tests/native/marker-rows.ts delete mode 100644 tests/native/probe1-transport.ts delete mode 100644 tests/native/probe5-hook.test.ts delete mode 100644 tests/native/probe5-support.ts delete mode 100644 tests/native/world.ts create mode 100644 tests/pure/child.test.ts create mode 100644 tests/pure/clients.test.ts create mode 100644 tests/pure/convex-cli.test.ts create mode 100644 tests/pure/executable.test.ts create mode 100644 tests/pure/fixture-issuer.test.ts delete mode 100644 tests/pure/identity.test.ts create mode 100644 tests/pure/native-run.test.ts delete mode 100644 tests/simulator/boundaries.observation.test.ts create mode 100644 tests/simulator/boundaries.test.ts delete mode 100644 tests/simulator/probe1-transport.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8af4b24..00576fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [main] jobs: - design: + checks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -17,30 +17,10 @@ jobs: python-version: "3.x" - run: npm ci - run: python3 design/tools/check.py - compiled: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: npm - - run: npm ci - run: npm run typecheck - - run: npm run test:types - run: npm run lint - run: npm run format:check - pure-and-simulator: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: npm - - run: npm ci - - run: npm run test:pure - - run: npm run test:simulator + - run: npm test native: runs-on: ubuntu-latest steps: @@ -49,9 +29,15 @@ jobs: with: node-version: 24 cache: npm + - uses: actions/cache@v6 + with: + path: .cache + key: convex-local-backend-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('harness/backend-release.json') }} - run: npm ci + - run: npm run codegen + - run: git diff --exit-code -- fixture/convex - run: npm run test:native - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: always() with: name: native-evidence diff --git a/.gitignore b/.gitignore index 541528d..a4189ba 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ node_modules/ generated/ .cache/ evidence/runs/ -fixture/.env.local +.env.local diff --git a/.prettierignore b/.prettierignore index 32c9e15..6a5a479 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,5 +3,5 @@ docs/ vendor/ generated/ **/_generated/ -evidence/ +evidence/**/*.json package-lock.json diff --git a/README.md b/README.md index dc05a43..b384848 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,21 @@ The design has been through three review rounds and carries open findings, all l ## Running the code -Use Node 24 and run `npm ci` in the repository root. Each tier has its own command: +Use Node 24 and run `npm ci` in the repository root. The repository has one `package.json`. The root `convex.json` names `fixture/convex` as the function directory of the fixture app. -- Compiled: `npm run typecheck` and `npm run test:types`. -- Pure: `npm run test:pure`. -- Simulator: `npm run test:simulator`, using `convex-test`. -- Native backend: `npm run test:native`, with a disposable backend per test. +Each tier has its own command and its own directory: -`npm test` runs the three tiers that need no backend. `npm run codegen` starts a disposable backend, deploys the fixture app, regenerates both Convex API directories and stops the backend. Native commands download the pinned backend into `.cache/`, or use the binary named by `CONVEX_BACKEND_BINARY`. The Linux asset hash must be filled in `harness/backend-release.json` before its first download. Native runs write evidence under `evidence/runs/`. +- Domain tier: `npm run test:pure` runs `tests/pure`. These tests need no backend and no simulator. Until slice S1 adds a domain, they test the pure parts of the harness. +- Simulator tier: `npm run test:simulator` runs `tests/simulator` on `convex-test`. Every test name starts with `convex-test` and the version it ran on. A simulator pass is not native proof. +- Native tier: `npm run test:native` runs `tests/native`. Every test starts its own disposable Convex backend, deploys the fixture app to it and removes it when the test ends. +- Compile check: `npm run typecheck` compiles the fixture app, the harness and the tests. `npm run test:types` runs the type tests in `tests/types`. A compile check says nothing about a running backend. + +`npm test` runs the type tests, the domain tier and the simulator tier. None of them needs a backend. + +The native tier downloads the pinned backend release into `.cache/` on first use and checks the hash of the executable on every run. `harness/backend-release.json` pins the release for macOS on Apple silicon and for Linux on x86-64. On another platform, or to run another build, set `CONVEX_BACKEND_BINARY` to a `convex-local-backend` executable. The run's record then names the release only when the executable's hash is the pinned one. + +The harness gives every Convex CLI call the address and the admin key of the backend it started, and an environment of its own. A deploy key or a deployment named in your shell or in a `.env` file is never used. + +`npm run codegen` starts a disposable backend and rewrites `fixture/convex/_generated` and `fixture/convex/annex/_generated`. Run it after you add or rename a fixture function or change its arguments, and commit the result. CI fails when the committed files differ from what codegen writes. + +Every native run writes a record to `evidence/runs/`. [`evidence/README.md`](evidence/README.md) says what a record holds and which records are kept. diff --git a/convex.json b/convex.json new file mode 100644 index 0000000..4d29aa8 --- /dev/null +++ b/convex.json @@ -0,0 +1,3 @@ +{ + "functions": "fixture/convex/" +} diff --git a/design/README.md b/design/README.md index af56a4c..fe02ce2 100644 --- a/design/README.md +++ b/design/README.md @@ -45,7 +45,7 @@ python3 design/tools/check.py Expected result on the integrated corpus: ``` -187 specs · 5 packs · 17 anchors → 209 nodes · 954 edges (0 errors, 0 warnings) +187 specs · 5 packs · 8 anchors → 200 nodes · 945 edges (0 errors, 0 warnings) validate: 0 errors · 0 warnings (conformance + honesty over the one graph) ``` diff --git a/eslint.config.js b/eslint.config.js index 8dc60b9..d3b541f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,7 +7,7 @@ export default tseslint.config( ...config, files: ["fixture/convex/**/*.ts"], })), - // The acceptance contract requires this deployment-time config to read process.env. + // A composition that runs natively reads its auth provider from the deployment's environment. { files: ["fixture/convex/auth.config.ts"], rules: { "@convex-dev/no-process-env": "off" }, diff --git a/evidence/README.md b/evidence/README.md index e386e49..e92886c 100644 --- a/evidence/README.md +++ b/evidence/README.md @@ -1,5 +1,26 @@ # Run evidence -The native Vitest reporter writes one JSON record per invocation to `runs/`. These files are ignored by git. Each record names the commit, dirty tree, fixture composition, versions, configuration, dataset, identity source, command and test results. +Every native run writes one JSON record to `evidence/runs/`. Git ignores that directory. When a Spec or a commit cites a run, copy that run's record to `evidence/` and commit it. No other record is kept. -A test calls `recordMeasurement(testName, measurementName, value)` from `harness/evidence.ts` to add a measurement to its result. Use the Spec ID for a bound example, or the full test name for an observation. Big integers are stored as an object with a `bigint` string. +A record that backs a claim says `"clean": true`. It was made on a tree with no uncommitted and no untracked file, at the commit it names. A record that says `"clean": false` backs no claim. + +## What a record holds + +- `commit` and `clean`: the commit that was checked out when the run started, and whether the tree was clean then. +- `command`, `startedAt`, `finishedAt` and `result`. +- `versions`: Node and the packages the tiers depend on, read from the installed packages. +- `tests`: one entry per test, with its result, its error messages, its backends and its measurements. + +A test's `backends` has one entry per backend the test started. The harness writes the entry from the backend that ran: + +- `executable`: the SHA-256 of the executable, where it came from and its release. The release is named only when the hash is the one `harness/backend-release.json` pins. Otherwise it is `null`. +- `identitySource`: the fixture issuer that the backend's environment variables named. The fixture issuer is the one difference from production in how a caller gets an identity. It is not adjusted configuration. +- `composition` and `installedLayers`: what the harness deployed. +- `environment`: the names of the environment variables the harness set. +- `dataset`: every backend starts on empty storage, so a test's data is what the test wrote. + +## Measurements + +A test records a measurement with `measure(name, value)` from `harness/native.ts`. The measurement is stored on the test that was running and appears under that test's entry. A value is plain JSON. A test that records a 64-bit integer or bytes writes it as text. + +A probe records sizes such as timings and counts, and asserts only what its example binds. A size in a record is one machine's reading on one run. diff --git a/fixture/convex/_generated/api.d.ts b/fixture/convex/_generated/api.d.ts index ea9cbba..18c936a 100644 --- a/fixture/convex/_generated/api.d.ts +++ b/fixture/convex/_generated/api.d.ts @@ -8,12 +8,13 @@ * @module */ -import type * as inspection from "../inspection.js"; -import type * as probe1 from "../probe1.js"; -import type * as probe2 from "../probe2.js"; -import type * as probe3 from "../probe3.js"; -import type * as probe4 from "../probe4.js"; -import type * as probe5 from "../probe5.js"; +import type * as failures from "../failures.js"; +import type * as identity from "../identity.js"; +import type * as limits from "../limits.js"; +import type * as list from "../list.js"; +import type * as markers from "../markers.js"; +import type * as notes from "../notes.js"; +import type * as readCost from "../readCost.js"; import type { ApiFromModules, @@ -22,12 +23,13 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ - inspection: typeof inspection; - probe1: typeof probe1; - probe2: typeof probe2; - probe3: typeof probe3; - probe4: typeof probe4; - probe5: typeof probe5; + failures: typeof failures; + identity: typeof identity; + limits: typeof limits; + list: typeof list; + markers: typeof markers; + notes: typeof notes; + readCost: typeof readCost; }>; /** @@ -57,5 +59,5 @@ export declare const internal: FilterApi< >; export declare const components: { - probe: import("../probe/_generated/component.js").ComponentApi<"probe">; + annex: import("../annex/_generated/component.js").ComponentApi<"annex">; }; diff --git a/fixture/convex/probe/_generated/api.ts b/fixture/convex/annex/_generated/api.ts similarity index 69% rename from fixture/convex/probe/_generated/api.ts rename to fixture/convex/annex/_generated/api.ts index 1562d07..e9076bc 100644 --- a/fixture/convex/probe/_generated/api.ts +++ b/fixture/convex/annex/_generated/api.ts @@ -8,11 +8,12 @@ * @module */ -import type * as inspection from "../inspection.js"; -import type * as probe2 from "../probe2.js"; -import type * as probe3 from "../probe3.js"; -import type * as probe4 from "../probe4.js"; -import type * as probe5 from "../probe5.js"; +import type * as failures from "../failures.js"; +import type * as identity from "../identity.js"; +import type * as limits from "../limits.js"; +import type * as list from "../list.js"; +import type * as notes from "../notes.js"; +import type * as readCost from "../readCost.js"; import type { ApiFromModules, @@ -22,11 +23,12 @@ import type { import { anyApi, componentsGeneric } from "convex/server"; const fullApi: ApiFromModules<{ - inspection: typeof inspection; - probe2: typeof probe2; - probe3: typeof probe3; - probe4: typeof probe4; - probe5: typeof probe5; + failures: typeof failures; + identity: typeof identity; + limits: typeof limits; + list: typeof list; + notes: typeof notes; + readCost: typeof readCost; }> = anyApi as any; /** diff --git a/fixture/convex/probe/_generated/component.ts b/fixture/convex/annex/_generated/component.ts similarity index 69% rename from fixture/convex/probe/_generated/component.ts rename to fixture/convex/annex/_generated/component.ts index e5dec05..b4d9f9b 100644 --- a/fixture/convex/probe/_generated/component.ts +++ b/fixture/convex/annex/_generated/component.ts @@ -23,56 +23,50 @@ import type { FunctionReference } from "convex/server"; */ export type ComponentApi = { - inspection: { - fail: FunctionReference<"mutation", "internal", {}, null, Name>; - identity: FunctionReference<"query", "internal", {}, any, Name>; - write: FunctionReference<"mutation", "internal", {}, null, Name>; - }; - probe2: { - fail: FunctionReference< + failures: { + throwAfterWrite: FunctionReference< "mutation", "internal", - { data: any; ordinary: boolean }, + { data: any; kind: "convexError" | "plainError" }, null, Name >; }; - probe3: { - read: FunctionReference< - "query", - "internal", - { id: string; nonce: number }, - any, - Name - >; - seed: FunctionReference<"mutation", "internal", {}, string, Name>; + identity: { + caller: FunctionReference<"query", "internal", {}, any, Name>; }; - probe4: { - read: FunctionReference< + limits: { + countBlobs: FunctionReference< "query", "internal", - { count: number; group: string }, - any, + { group: string }, + number, Name >; - seed: FunctionReference< + insertBlobs: FunctionReference< "mutation", "internal", { count: number; group: string; size: number }, null, Name >; - writtenCount: FunctionReference<"query", "internal", {}, number, Name>; + readBlobs: FunctionReference< + "query", + "internal", + { count: number; group: string }, + any, + Name + >; }; - probe5: { - change: FunctionReference< + list: { + first: FunctionReference<"query", "internal", {}, any, Name>; + insert: FunctionReference< "mutation", "internal", - { id: string; value: string }, + { rows: Array<{ label: string; position: number }> }, null, Name >; - first: FunctionReference<"query", "internal", {}, any, Name>; page: FunctionReference< "query", "internal", @@ -89,6 +83,13 @@ export type ComponentApi = any, Name >; + relabel: FunctionReference< + "mutation", + "internal", + { id: string; label: string }, + null, + Name + >; remove: FunctionReference< "mutation", "internal", @@ -96,12 +97,18 @@ export type ComponentApi = null, Name >; - write: FunctionReference< - "mutation", + }; + notes: { + add: FunctionReference<"mutation", "internal", {}, null, Name>; + }; + readCost: { + readOne: FunctionReference< + "query", "internal", - { rows: Array<{ position: number; value: string }> }, + { call: number; id: string }, null, Name >; + seed: FunctionReference<"mutation", "internal", {}, string, Name>; }; }; diff --git a/fixture/convex/probe/_generated/dataModel.ts b/fixture/convex/annex/_generated/dataModel.ts similarity index 100% rename from fixture/convex/probe/_generated/dataModel.ts rename to fixture/convex/annex/_generated/dataModel.ts diff --git a/fixture/convex/probe/_generated/server.ts b/fixture/convex/annex/_generated/server.ts similarity index 100% rename from fixture/convex/probe/_generated/server.ts rename to fixture/convex/annex/_generated/server.ts diff --git a/fixture/convex/probe/convex.config.ts b/fixture/convex/annex/convex.config.ts similarity index 54% rename from fixture/convex/probe/convex.config.ts rename to fixture/convex/annex/convex.config.ts index 284b25d..4078f07 100644 --- a/fixture/convex/probe/convex.config.ts +++ b/fixture/convex/annex/convex.config.ts @@ -1,2 +1,2 @@ import { defineComponent } from "convex/server"; -export default defineComponent("probe"); +export default defineComponent("annex"); diff --git a/fixture/convex/annex/failures.ts b/fixture/convex/annex/failures.ts new file mode 100644 index 0000000..d730f19 --- /dev/null +++ b/fixture/convex/annex/failures.ts @@ -0,0 +1,17 @@ +import { ConvexError, v } from "convex/values"; +import { mutation } from "./_generated/server.js"; +export const throwAfterWrite = mutation({ + args: { + kind: v.union(v.literal("convexError"), v.literal("plainError")), + data: v.any(), + }, + returns: v.null(), + handler: async (ctx, { kind, data }) => { + await ctx.db.insert("throwerWrites", { by: "thrower" }); + if (kind === "plainError") + throw Object.assign(new Error("plain failure"), { + addedProperty: "present", + }); + throw new ConvexError(data); + }, +}); diff --git a/fixture/convex/annex/identity.ts b/fixture/convex/annex/identity.ts new file mode 100644 index 0000000..e573439 --- /dev/null +++ b/fixture/convex/annex/identity.ts @@ -0,0 +1,7 @@ +import { v } from "convex/values"; +import { query } from "./_generated/server.js"; +export const caller = query({ + args: {}, + returns: v.any(), + handler: (ctx) => ctx.auth.getUserIdentity(), +}); diff --git a/fixture/convex/annex/limits.ts b/fixture/convex/annex/limits.ts new file mode 100644 index 0000000..263c059 --- /dev/null +++ b/fixture/convex/annex/limits.ts @@ -0,0 +1,49 @@ +import type { TransactionMetrics } from "convex/server"; +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server.js"; +export const insertBlobs = mutation({ + args: { group: v.string(), count: v.number(), size: v.number() }, + returns: v.null(), + handler: async (ctx, { group, count, size }) => { + for (let i = 0; i < count; i++) + await ctx.db.insert("blobs", { group, bytes: new ArrayBuffer(size) }); + return null; + }, +}); +export const readBlobs = query({ + args: { group: v.string(), count: v.number() }, + returns: v.any(), + handler: async ( + ctx, + { group, count }, + ): Promise<{ + documents: number; + payloadBytes: number; + before: TransactionMetrics; + after: TransactionMetrics; + }> => { + const before = await ctx.meta.getTransactionMetrics(); + const documents = await ctx.db + .query("blobs") + .withIndex("by_group", (q) => q.eq("group", group)) + .take(count); + const after = await ctx.meta.getTransactionMetrics(); + return { + documents: documents.length, + payloadBytes: documents.reduce((sum, d) => sum + d.bytes.byteLength, 0), + before, + after, + }; + }, +}); +export const countBlobs = query({ + args: { group: v.string() }, + returns: v.number(), + handler: async (ctx, { group }) => + ( + await ctx.db + .query("blobs") + .withIndex("by_group", (q) => q.eq("group", group)) + .collect() + ).length, +}); diff --git a/fixture/convex/probe/probe5.ts b/fixture/convex/annex/list.ts similarity index 56% rename from fixture/convex/probe/probe5.ts rename to fixture/convex/annex/list.ts index d0f0b25..8d45b4c 100644 --- a/fixture/convex/probe/probe5.ts +++ b/fixture/convex/annex/list.ts @@ -1,45 +1,45 @@ -import { v } from "convex/values"; -import { paginationOptsValidator } from "convex/server"; import { paginator } from "convex-helpers/server/pagination"; -import { query, mutation } from "./_generated/server.js"; +import { paginationOptsValidator } from "convex/server"; +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server.js"; import schema from "./schema.js"; export const page = query({ args: { paginationOpts: paginationOptsValidator }, returns: v.any(), - handler: (ctx, a) => + handler: (ctx, { paginationOpts }) => paginator(ctx.db, schema) .query("rows") .withIndex("by_position") - .paginate(a.paginationOpts), + .paginate(paginationOpts), +}); +export const first = query({ + args: {}, + returns: v.any(), + handler: (ctx) => ctx.db.query("rows").withIndex("by_position").first(), }); -export const write = mutation({ +export const insert = mutation({ args: { - rows: v.array(v.object({ position: v.number(), value: v.string() })), + rows: v.array(v.object({ position: v.number(), label: v.string() })), }, returns: v.null(), - handler: async (ctx, a) => { - for (const row of a.rows) await ctx.db.insert("rows", row); + handler: async (ctx, { rows }) => { + for (const row of rows) await ctx.db.insert("rows", row); return null; }, }); export const remove = mutation({ args: { id: v.id("rows") }, returns: v.null(), - handler: async (ctx, a) => { - await ctx.db.delete("rows", a.id); + handler: async (ctx, { id }) => { + await ctx.db.delete("rows", id); return null; }, }); -export const change = mutation({ - args: { id: v.id("rows"), value: v.string() }, +export const relabel = mutation({ + args: { id: v.id("rows"), label: v.string() }, returns: v.null(), - handler: async (ctx, a) => { - await ctx.db.patch("rows", a.id, { value: a.value }); + handler: async (ctx, { id, label }) => { + await ctx.db.patch("rows", id, { label }); return null; }, }); -export const first = query({ - args: {}, - returns: v.any(), - handler: (ctx) => ctx.db.query("rows").withIndex("by_position").first(), -}); diff --git a/fixture/convex/annex/notes.ts b/fixture/convex/annex/notes.ts new file mode 100644 index 0000000..2d88a89 --- /dev/null +++ b/fixture/convex/annex/notes.ts @@ -0,0 +1,10 @@ +import { v } from "convex/values"; +import { mutation } from "./_generated/server.js"; +export const add = mutation({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + await ctx.db.insert("notes", { source: "annex" }); + return null; + }, +}); diff --git a/fixture/convex/annex/readCost.ts b/fixture/convex/annex/readCost.ts new file mode 100644 index 0000000..ee085a6 --- /dev/null +++ b/fixture/convex/annex/readCost.ts @@ -0,0 +1,15 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server.js"; +export const seed = mutation({ + args: {}, + returns: v.id("samples"), + handler: (ctx) => ctx.db.insert("samples", { value: 7 }), +}); +export const readOne = query({ + args: { id: v.id("samples"), call: v.number() }, + returns: v.null(), + handler: async (ctx, { id }) => { + await ctx.db.get("samples", id); + return null; + }, +}); diff --git a/fixture/convex/annex/schema.ts b/fixture/convex/annex/schema.ts new file mode 100644 index 0000000..efd0984 --- /dev/null +++ b/fixture/convex/annex/schema.ts @@ -0,0 +1,15 @@ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; +export default defineSchema({ + notes: defineTable({ source: v.string() }), + throwerWrites: defineTable({ by: v.string() }), + samples: defineTable({ value: v.number() }), + blobs: defineTable({ group: v.string(), bytes: v.bytes() }).index( + "by_group", + ["group"], + ), + rows: defineTable({ position: v.number(), label: v.string() }).index( + "by_position", + ["position"], + ), +}); diff --git a/fixture/convex/convex.config.ts b/fixture/convex/convex.config.ts index 75f31ca..9c3b435 100644 --- a/fixture/convex/convex.config.ts +++ b/fixture/convex/convex.config.ts @@ -1,5 +1,5 @@ import { defineApp } from "convex/server"; -import probe from "./probe/convex.config.js"; +import annex from "./annex/convex.config.js"; const app = defineApp(); -app.use(probe); +app.use(annex); export default app; diff --git a/fixture/convex/failures.ts b/fixture/convex/failures.ts new file mode 100644 index 0000000..5364187 --- /dev/null +++ b/fixture/convex/failures.ts @@ -0,0 +1,71 @@ +import { ConvexError, v } from "convex/values"; +import { components, internal } from "./_generated/api.js"; +import { internalMutation, mutation } from "./_generated/server.js"; +import type { MutationCtx } from "./_generated/server.js"; +export interface Caught { + isConvexError: boolean; + data: unknown; + message: string; + addedProperty: string | null; +} +const thrown = { + kind: v.union(v.literal("convexError"), v.literal("plainError")), + data: v.any(), +}; +const parentArgs = { + ...thrown, + boundary: v.union(v.literal("nested"), v.literal("annex")), +}; +export const throwAfterWrite = internalMutation({ + args: thrown, + returns: v.null(), + handler: async (ctx, { kind, data }) => { + await ctx.db.insert("throwerWrites", { by: "thrower" }); + if (kind === "plainError") + throw Object.assign(new Error("plain failure"), { + addedProperty: "present", + }); + throw new ConvexError(data); + }, +}); +function callThrower( + ctx: MutationCtx, + a: { + boundary: "nested" | "annex"; + kind: "convexError" | "plainError"; + data: unknown; + }, +): Promise { + return ctx.runMutation( + a.boundary === "nested" + ? internal.failures.throwAfterWrite + : components.annex.failures.throwAfterWrite, + { kind: a.kind, data: a.data }, + ); +} +export const catching = mutation({ + args: parentArgs, + returns: v.any(), + handler: async (ctx, a): Promise => { + await ctx.db.insert("parentWrites", { by: "catching parent" }); + let caught: unknown; + try { + await callThrower(ctx, a); + } catch (error) { + caught = error; + } + return { + isConvexError: caught instanceof ConvexError, + data: caught instanceof ConvexError ? (caught.data as unknown) : null, + message: caught === undefined ? "nothing was thrown" : String(caught), + addedProperty: + (caught as { addedProperty?: string } | undefined)?.addedProperty ?? + null, + }; + }, +}); +export const passing = mutation({ + args: parentArgs, + returns: v.null(), + handler: (ctx, a): Promise => callThrower(ctx, a), +}); diff --git a/fixture/convex/identity.ts b/fixture/convex/identity.ts new file mode 100644 index 0000000..2c650d7 --- /dev/null +++ b/fixture/convex/identity.ts @@ -0,0 +1,14 @@ +import { v } from "convex/values"; +import { components } from "./_generated/api.js"; +import { query } from "./_generated/server.js"; +export const caller = query({ + args: {}, + returns: v.any(), + handler: (ctx) => ctx.auth.getUserIdentity(), +}); +export const callerSeenByAnnex = query({ + args: {}, + returns: v.any(), + handler: (ctx): Promise => + ctx.runQuery(components.annex.identity.caller, {}), +}); diff --git a/fixture/convex/inspection.ts b/fixture/convex/inspection.ts deleted file mode 100644 index 4fe3e11..0000000 --- a/fixture/convex/inspection.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ConvexError, v } from "convex/values"; -import { components, internal } from "./_generated/api.js"; -import { internalMutation, mutation, query } from "./_generated/server.js"; -export const identity = query({ - args: {}, - returns: v.any(), - handler: (ctx) => ctx.auth.getUserIdentity(), -}); -export const componentIdentity = query({ - args: {}, - returns: v.any(), - handler: (ctx) => ctx.runQuery(components.probe.inspection.identity, {}), -}); -export const writeInternal = internalMutation({ - args: {}, - returns: v.null(), - handler: async (ctx) => { - await ctx.db.insert("writes", { marker: "internal" }); - return null; - }, -}); -export const failInternal = internalMutation({ - args: {}, - returns: v.null(), - handler: async () => { - throw new ConvexError({ - code: "fixtureFailure", - details: { expected: 2, current: 3 }, - }); - }, -}); -export const catchFailure = mutation({ - args: { boundary: v.union(v.literal("nested"), v.literal("component")) }, - returns: v.any(), - handler: async (ctx, { boundary }) => { - try { - await ctx.runMutation( - boundary === "nested" - ? internal.inspection.failInternal - : components.probe.inspection.fail, - {}, - ); - } catch (error) { - return { - isConvexError: error instanceof ConvexError, - data: error instanceof ConvexError ? error.data : null, - message: String(error), - }; - } - return null; - }, -}); diff --git a/fixture/convex/limits.ts b/fixture/convex/limits.ts new file mode 100644 index 0000000..61c5508 --- /dev/null +++ b/fixture/convex/limits.ts @@ -0,0 +1,133 @@ +import type { TransactionMetrics } from "convex/server"; +import { v } from "convex/values"; +import { api, components, internal } from "./_generated/api.js"; +import { internalQuery, mutation, query } from "./_generated/server.js"; +export interface BlobRead { + documents: number; + payloadBytes: number; + before: TransactionMetrics; + after: TransactionMetrics; +} +export interface ReadThenCall { + parentDocuments: number; + before: TransactionMetrics; + after: TransactionMetrics; + child: BlobRead; +} +const boundary = v.union(v.literal("nested"), v.literal("annex")); +const blobs = { group: v.string(), count: v.number(), size: v.number() }; +export const insertBlobs = mutation({ + args: blobs, + returns: v.null(), + handler: async (ctx, { group, count, size }) => { + for (let i = 0; i < count; i++) + await ctx.db.insert("blobs", { group, bytes: new ArrayBuffer(size) }); + return null; + }, +}); +export const insertAnnexBlobs = mutation({ + args: blobs, + returns: v.null(), + handler: (ctx, a): Promise => + ctx.runMutation(components.annex.limits.insertBlobs, a), +}); +export const readBlobs = internalQuery({ + args: { group: v.string(), count: v.number() }, + returns: v.any(), + handler: async (ctx, { group, count }): Promise => { + const before = await ctx.meta.getTransactionMetrics(); + const documents = await ctx.db + .query("blobs") + .withIndex("by_group", (q) => q.eq("group", group)) + .take(count); + const after = await ctx.meta.getTransactionMetrics(); + return { + documents: documents.length, + payloadBytes: documents.reduce((sum, d) => sum + d.bytes.byteLength, 0), + before, + after, + }; + }, +}); +export const childAlone = query({ + args: { through: boundary, count: v.number() }, + returns: v.any(), + handler: (ctx, { through, count }): Promise => + ctx.runQuery( + through === "nested" + ? internal.limits.readBlobs + : components.annex.limits.readBlobs, + { group: "child", count }, + ), +}); +export const readThenCall = mutation({ + args: { through: boundary, parentCount: v.number(), childCount: v.number() }, + returns: v.any(), + handler: async (ctx, a): Promise => { + const own = await ctx.db + .query("blobs") + .withIndex("by_group", (q) => q.eq("group", "parent")) + .take(a.parentCount); + const before = await ctx.meta.getTransactionMetrics(); + const child: BlobRead = await ctx.runQuery( + a.through === "nested" + ? internal.limits.readBlobs + : components.annex.limits.readBlobs, + { group: "child", count: a.childCount }, + ); + const after = await ctx.meta.getTransactionMetrics(); + return { parentDocuments: own.length, before, after, child }; + }, +}); +export const writeThenCall = mutation({ + args: { + through: boundary, + group: v.string(), + parentCount: v.number(), + childCount: v.number(), + size: v.number(), + }, + returns: v.null(), + handler: async (ctx, a): Promise => { + for (let i = 0; i < a.parentCount; i++) + await ctx.db.insert("blobs", { + group: a.group, + bytes: new ArrayBuffer(a.size), + }); + await ctx.runMutation( + a.through === "nested" + ? api.limits.insertBlobs + : components.annex.limits.insertBlobs, + { group: a.group, count: a.childCount, size: a.size }, + ); + return null; + }, +}); +export const countBlobs = query({ + args: { through: boundary, group: v.string() }, + returns: v.number(), + handler: async (ctx, { through, group }): Promise => { + const own = await ctx.db + .query("blobs") + .withIndex("by_group", (q) => q.eq("group", group)) + .collect(); + return through === "nested" + ? own.length + : own.length + + (await ctx.runQuery(components.annex.limits.countBlobs, { group })); + }, +}); +export const recurse = mutation({ + args: { label: v.string(), stack: v.number() }, + returns: v.number(), + handler: async (ctx, { label, stack }): Promise => { + await ctx.db.insert("markers", { trial: label }); + return stack <= 1 + ? 1 + : 1 + + (await ctx.runMutation(api.limits.recurse, { + label, + stack: stack - 1, + })); + }, +}); diff --git a/fixture/convex/list.ts b/fixture/convex/list.ts new file mode 100644 index 0000000..a675989 --- /dev/null +++ b/fixture/convex/list.ts @@ -0,0 +1,51 @@ +import { paginationOptsValidator } from "convex/server"; +import type { PaginationResult } from "convex/server"; +import { v } from "convex/values"; +import { components } from "./_generated/api.js"; +import { mutation, query } from "./_generated/server.js"; +export interface ListRow { + _id: string; + _creationTime: number; + position: number; + label: string; +} +export type ListPage = PaginationResult; +const row = v.object({ position: v.number(), label: v.string() }); +export const page = query({ + args: { + paginationOpts: paginationOptsValidator, + maximumRowsRead: v.optional(v.number()), + }, + returns: v.any(), + handler: (ctx, { paginationOpts, maximumRowsRead }): Promise => + ctx.runQuery(components.annex.list.page, { + paginationOpts: { + ...paginationOpts, + ...(maximumRowsRead === undefined ? {} : { maximumRowsRead }), + }, + }), +}); +export const first = query({ + args: {}, + returns: v.any(), + handler: (ctx): Promise => + ctx.runQuery(components.annex.list.first, {}), +}); +export const insert = mutation({ + args: { rows: v.array(row) }, + returns: v.null(), + handler: (ctx, a): Promise => + ctx.runMutation(components.annex.list.insert, a), +}); +export const remove = mutation({ + args: { id: v.string() }, + returns: v.null(), + handler: (ctx, a): Promise => + ctx.runMutation(components.annex.list.remove, a), +}); +export const relabel = mutation({ + args: { id: v.string(), label: v.string() }, + returns: v.null(), + handler: (ctx, a): Promise => + ctx.runMutation(components.annex.list.relabel, a), +}); diff --git a/fixture/convex/probe1.ts b/fixture/convex/markers.ts similarity index 57% rename from fixture/convex/probe1.ts rename to fixture/convex/markers.ts index 177a6dd..d9831ab 100644 --- a/fixture/convex/probe1.ts +++ b/fixture/convex/markers.ts @@ -1,10 +1,10 @@ import { v } from "convex/values"; import { mutation } from "./_generated/server.js"; -export const marker = mutation({ +export const insert = mutation({ args: { trial: v.string() }, returns: v.null(), - handler: async (ctx, args) => { - await ctx.db.insert("markers", args); + handler: async (ctx, { trial }) => { + await ctx.db.insert("markers", { trial }); return null; }, }); diff --git a/fixture/convex/notes.ts b/fixture/convex/notes.ts new file mode 100644 index 0000000..5b4c368 --- /dev/null +++ b/fixture/convex/notes.ts @@ -0,0 +1,10 @@ +import { v } from "convex/values"; +import { internalMutation } from "./_generated/server.js"; +export const addInternal = internalMutation({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + await ctx.db.insert("notes", { source: "internal" }); + return null; + }, +}); diff --git a/fixture/convex/probe/inspection.ts b/fixture/convex/probe/inspection.ts deleted file mode 100644 index 2cf75c9..0000000 --- a/fixture/convex/probe/inspection.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ConvexError, v } from "convex/values"; -import { mutation, query } from "./_generated/server.js"; -export const write = mutation({ - args: {}, - returns: v.null(), - handler: async (ctx) => { - await ctx.db.insert("writes", { marker: "component" }); - return null; - }, -}); -export const fail = mutation({ - args: {}, - returns: v.null(), - handler: async () => { - throw new ConvexError({ - code: "fixtureFailure", - details: { expected: 2, current: 3 }, - }); - }, -}); -// An observation on one release, not a platform promise about component auth. -export const identity = query({ - args: {}, - returns: v.any(), - handler: (ctx) => ctx.auth.getUserIdentity(), -}); diff --git a/fixture/convex/probe/probe2.ts b/fixture/convex/probe/probe2.ts deleted file mode 100644 index 4ec9b93..0000000 --- a/fixture/convex/probe/probe2.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ConvexError, v } from "convex/values"; -import { mutation } from "./_generated/server.js"; -export const fail = mutation({ - args: { data: v.any(), ordinary: v.boolean() }, - returns: v.null(), - handler: async (ctx, { data, ordinary }) => { - await ctx.db.insert("probe2Writes", { marker: "child" }); - if (ordinary) { - const error = Object.assign(new Error("ordinary probe error"), { - probeProperty: "present", - }); - throw error; - } - throw new ConvexError(data); - }, -}); diff --git a/fixture/convex/probe/probe3.ts b/fixture/convex/probe/probe3.ts deleted file mode 100644 index 85d2061..0000000 --- a/fixture/convex/probe/probe3.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { v } from "convex/values"; -import { query, mutation } from "./_generated/server.js"; -import type { QueryCtx } from "./_generated/server.js"; -import type { Id } from "./_generated/dataModel.js"; -export const seed = mutation({ - args: {}, - returns: v.id("probe3Docs"), - handler: (ctx) => ctx.db.insert("probe3Docs", { value: 7 }), -}); -async function helper(ctx: QueryCtx, id: Id<"probe3Docs">) { - return ctx.db.get("probe3Docs", id); -} -export const read = query({ - args: { id: v.id("probe3Docs"), nonce: v.number() }, - returns: v.any(), - handler: (ctx, { id }) => helper(ctx, id), -}); diff --git a/fixture/convex/probe/probe4.ts b/fixture/convex/probe/probe4.ts deleted file mode 100644 index c155987..0000000 --- a/fixture/convex/probe/probe4.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { TransactionMetrics } from "convex/server"; -export interface ReadResult { - before: TransactionMetrics; - after: TransactionMetrics; - count: number; - payloadBytes: number; -} -import { v } from "convex/values"; -import { query, mutation } from "./_generated/server.js"; -export const seed = mutation({ - args: { group: v.string(), count: v.number(), size: v.number() }, - returns: v.null(), - handler: async (ctx, a) => { - for (let i = 0; i < a.count; i++) - await ctx.db.insert("blobs", { - group: a.group, - bytes: new ArrayBuffer(a.size), - }); - return null; - }, -}); -export const read = query({ - args: { group: v.string(), count: v.number() }, - returns: v.any(), - handler: async (ctx, a): Promise => { - const before = await ctx.meta.getTransactionMetrics(); - const docs = await ctx.db - .query("blobs") - .withIndex("by_group", (q) => q.eq("group", a.group)) - .take(a.count); - const after = await ctx.meta.getTransactionMetrics(); - return { - before, - after, - count: docs.length, - payloadBytes: docs.reduce((n, d) => n + d.bytes.byteLength, 0), - }; - }, -}); - -export const writtenCount = query({ - args: {}, - returns: v.number(), - handler: async (ctx) => - ( - await ctx.db - .query("blobs") - .withIndex("by_group", (q) => q.eq("group", "written")) - .take(100) - ).length, -}); diff --git a/fixture/convex/probe/schema.ts b/fixture/convex/probe/schema.ts deleted file mode 100644 index b51c2cd..0000000 --- a/fixture/convex/probe/schema.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; -export default defineSchema({ - writes: defineTable({ marker: v.string() }), - probe2Writes: defineTable({ marker: v.string() }), - probe3Docs: defineTable({ value: v.number() }), - blobs: defineTable({ bytes: v.bytes(), group: v.string() }).index( - "by_group", - ["group"], - ), - rows: defineTable({ position: v.number(), value: v.string() }).index( - "by_position", - ["position"], - ), -}); diff --git a/fixture/convex/probe2.ts b/fixture/convex/probe2.ts deleted file mode 100644 index 4c1c5f9..0000000 --- a/fixture/convex/probe2.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ConvexError, v } from "convex/values"; -import { mutation } from "./_generated/server.js"; -import { api, components } from "./_generated/api.js"; -export const fail = mutation({ - args: { data: v.any(), ordinary: v.boolean() }, - returns: v.null(), - handler: async (ctx, { data, ordinary }) => { - await ctx.db.insert("probe2Writes", { marker: "child" }); - if (ordinary) { - const error = Object.assign(new Error("ordinary probe error"), { - probeProperty: "present", - }); - throw error; - } - throw new ConvexError(data); - }, -}); -const parentArgs = { - data: v.any(), - ordinary: v.boolean(), - component: v.boolean(), -}; -export const catching = mutation({ - args: parentArgs, - returns: v.any(), - handler: async (ctx, args) => { - let caught = null; - try { - await ctx.runMutation( - args.component ? components.probe.probe2.fail : api.probe2.fail, - { data: args.data, ordinary: args.ordinary }, - ); - } catch (error) { - caught = { - isConvexError: error instanceof ConvexError, - data: error instanceof ConvexError ? error.data : null, - message: String(error), - property: (error as { probeProperty?: string }).probeProperty ?? null, - }; - } - await ctx.db.insert("probe2Writes", { marker: "parent" }); - return caught; - }, -}); -export const passing = mutation({ - args: parentArgs, - returns: v.null(), - handler: async (ctx, args): Promise => - ctx.runMutation( - args.component ? components.probe.probe2.fail : api.probe2.fail, - { data: args.data, ordinary: args.ordinary }, - ), -}); diff --git a/fixture/convex/probe3.ts b/fixture/convex/probe3.ts deleted file mode 100644 index 25c0df6..0000000 --- a/fixture/convex/probe3.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { v } from "convex/values"; -import { query, mutation } from "./_generated/server.js"; -import type { QueryCtx } from "./_generated/server.js"; -import type { Id } from "./_generated/dataModel.js"; -import { api, components } from "./_generated/api.js"; -export const seed = mutation({ - args: {}, - returns: v.id("probe3Docs"), - handler: (ctx) => ctx.db.insert("probe3Docs", { value: 7 }), -}); -async function helper(ctx: QueryCtx, id: Id<"probe3Docs">) { - return ctx.db.get("probe3Docs", id); -} -export const read = query({ - args: { id: v.id("probe3Docs"), nonce: v.number() }, - returns: v.any(), - handler: (ctx, { id }) => helper(ctx, id), -}); -const args = { - id: v.id("probe3Docs"), - componentId: v.string(), - reads: v.number(), - nonce: v.number(), - path: v.union( - v.literal("helper"), - v.literal("nested"), - v.literal("component"), - ), -}; -async function work( - ctx: QueryCtx, - a: { - id: Id<"probe3Docs">; - componentId: string; - reads: number; - nonce: number; - path: "helper" | "nested" | "component"; - }, -): Promise { - for (let i = 0; i < a.reads; i++) { - if (a.path === "helper") await helper(ctx, a.id); - else if (a.path === "nested") - await ctx.runQuery(api.probe3.read, { id: a.id, nonce: a.nonce + i }); - else - await ctx.runQuery(components.probe.probe3.read, { - id: a.componentId, - nonce: a.nonce + i, - }); - } - return null; -} -export const parentQuery = query({ args, returns: v.null(), handler: work }); -export const parentMutation = mutation({ - args, - returns: v.null(), - handler: work, -}); -export const seedComponent = mutation({ - args: {}, - returns: v.string(), - handler: (ctx) => ctx.runMutation(components.probe.probe3.seed, {}), -}); diff --git a/fixture/convex/probe4.ts b/fixture/convex/probe4.ts deleted file mode 100644 index 856bd98..0000000 --- a/fixture/convex/probe4.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { TransactionMetrics } from "convex/server"; -import type { ReadResult } from "./probe/probe4.js"; -import { v } from "convex/values"; -import { query, mutation } from "./_generated/server.js"; -import { api, components } from "./_generated/api.js"; -export const seed = mutation({ - args: { group: v.string(), count: v.number(), size: v.number() }, - returns: v.null(), - handler: async (ctx, a) => { - for (let i = 0; i < a.count; i++) - await ctx.db.insert("blobs", { - group: a.group, - bytes: new ArrayBuffer(a.size), - }); - return null; - }, -}); -export const read = query({ - args: { group: v.string(), count: v.number() }, - returns: v.any(), - handler: async (ctx, a) => { - const before = await ctx.meta.getTransactionMetrics(); - const docs = await ctx.db - .query("blobs") - .withIndex("by_group", (q) => q.eq("group", a.group)) - .take(a.count); - const after = await ctx.meta.getTransactionMetrics(); - return { - before, - after, - count: docs.length, - payloadBytes: docs.reduce((n, d) => n + d.bytes.byteLength, 0), - }; - }, -}); -const splitArgs = { - component: v.boolean(), - parent: v.number(), - child: v.number(), -}; -export const seedComponent = mutation({ - args: { group: v.string(), count: v.number(), size: v.number() }, - returns: v.null(), - handler: (ctx, a) => ctx.runMutation(components.probe.probe4.seed, a), -}); -export const parentRead = mutation({ - args: splitArgs, - returns: v.any(), - handler: async ( - ctx, - a, - ): Promise<{ - parentCount: number; - before: TransactionMetrics; - after: TransactionMetrics; - child: ReadResult; - }> => { - const parent = await ctx.db - .query("blobs") - .withIndex("by_group", (q) => q.eq("group", "parent")) - .take(a.parent); - const before = await ctx.meta.getTransactionMetrics(); - const child = await ctx.runQuery( - a.component ? components.probe.probe4.read : api.probe4.read, - { group: "child", count: a.child }, - ); - const after = await ctx.meta.getTransactionMetrics(); - return { parentCount: parent.length, before, after, child }; - }, -}); -export const childAlone = query({ - args: { component: v.boolean(), count: v.number() }, - returns: v.any(), - handler: (ctx, a): Promise => - ctx.runQuery(a.component ? components.probe.probe4.read : api.probe4.read, { - group: "child", - count: a.count, - }), -}); -export const parentWrite = mutation({ - args: { ...splitArgs, size: v.number() }, - returns: v.null(), - handler: async (ctx, a) => { - for (let i = 0; i < a.parent; i++) - await ctx.db.insert("blobs", { - group: "written", - bytes: new ArrayBuffer(a.size), - }); - await ctx.runMutation( - a.component ? components.probe.probe4.seed : api.probe4.seed, - { group: "written", count: a.child, size: a.size }, - ); - return null; - }, -}); -export const depth = mutation({ - args: { depth: v.number() }, - returns: v.null(), - handler: async (ctx, { depth }): Promise => { - if (depth > 1) - await ctx.runMutation(api.probe4.depth, { depth: depth - 1 }); - await ctx.db.insert("writes", { marker: `depth-${depth}` }); - return null; - }, -}); - -export const writtenCount = query({ - args: { component: v.boolean() }, - returns: v.number(), - handler: async (ctx, { component }): Promise => { - const count = ( - await ctx.db - .query("blobs") - .withIndex("by_group", (q) => q.eq("group", "written")) - .take(100) - ).length; - return component - ? count + (await ctx.runQuery(components.probe.probe4.writtenCount, {})) - : count; - }, -}); diff --git a/fixture/convex/probe5.ts b/fixture/convex/probe5.ts deleted file mode 100644 index 017d351..0000000 --- a/fixture/convex/probe5.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { v } from "convex/values"; -import { paginationOptsValidator } from "convex/server"; -import { query, mutation } from "./_generated/server.js"; -import { components } from "./_generated/api.js"; -export const page = query({ - args: { - paginationOpts: paginationOptsValidator, - maximumRowsRead: v.optional(v.number()), - }, - returns: v.any(), - handler: (ctx, a) => - ctx.runQuery(components.probe.probe5.page, { - paginationOpts: { - ...a.paginationOpts, - ...(a.maximumRowsRead === undefined - ? {} - : { maximumRowsRead: a.maximumRowsRead }), - }, - }), -}); -export const write = mutation({ - args: { - rows: v.array(v.object({ position: v.number(), value: v.string() })), - }, - returns: v.null(), - handler: (ctx, a) => ctx.runMutation(components.probe.probe5.write, a), -}); -export const remove = mutation({ - args: { id: v.string() }, - returns: v.null(), - handler: (ctx, a) => ctx.runMutation(components.probe.probe5.remove, a), -}); -export const change = mutation({ - args: { id: v.string(), value: v.string() }, - returns: v.null(), - handler: (ctx, a) => ctx.runMutation(components.probe.probe5.change, a), -}); -export const first = query({ - args: {}, - returns: v.any(), - handler: (ctx) => ctx.runQuery(components.probe.probe5.first, {}), -}); diff --git a/fixture/convex/readCost.ts b/fixture/convex/readCost.ts new file mode 100644 index 0000000..29ad9b3 --- /dev/null +++ b/fixture/convex/readCost.ts @@ -0,0 +1,70 @@ +import { v } from "convex/values"; +import { components, internal } from "./_generated/api.js"; +import type { Id } from "./_generated/dataModel.js"; +import { internalQuery, mutation, query } from "./_generated/server.js"; +import type { QueryCtx } from "./_generated/server.js"; +function readSample(ctx: QueryCtx, id: Id<"samples">) { + return ctx.db.get("samples", id); +} +export const seed = mutation({ + args: {}, + returns: v.object({ own: v.id("samples"), annex: v.string() }), + handler: async (ctx): Promise<{ own: Id<"samples">; annex: string }> => ({ + own: await ctx.db.insert("samples", { value: 7 }), + annex: await ctx.runMutation(components.annex.readCost.seed, {}), + }), +}); +export const readOne = internalQuery({ + args: { id: v.id("samples"), call: v.number() }, + returns: v.null(), + handler: async (ctx, { id }) => { + await readSample(ctx, id); + return null; + }, +}); +const parentArgs = { + path: v.union( + v.literal("helper"), + v.literal("nested"), + v.literal("component"), + ), + reads: v.number(), + own: v.id("samples"), + annex: v.string(), + nonce: v.number(), +}; +async function readRepeatedly( + ctx: QueryCtx, + a: { + path: "helper" | "nested" | "component"; + reads: number; + own: Id<"samples">; + annex: string; + nonce: number; + }, +): Promise { + for (let call = 0; call < a.reads; call++) { + if (a.path === "helper") await readSample(ctx, a.own); + else if (a.path === "nested") + await ctx.runQuery(internal.readCost.readOne, { + id: a.own, + call: a.nonce + call, + }); + else + await ctx.runQuery(components.annex.readCost.readOne, { + id: a.annex, + call: a.nonce + call, + }); + } + return null; +} +export const viaMutation = mutation({ + args: parentArgs, + returns: v.null(), + handler: readRepeatedly, +}); +export const viaQuery = query({ + args: parentArgs, + returns: v.null(), + handler: readRepeatedly, +}); diff --git a/fixture/convex/schema.ts b/fixture/convex/schema.ts index c62e35f..dbaa270 100644 --- a/fixture/convex/schema.ts +++ b/fixture/convex/schema.ts @@ -1,12 +1,13 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ - writes: defineTable({ marker: v.string() }), - probe2Writes: defineTable({ marker: v.string() }), - probe3Docs: defineTable({ value: v.number() }), - blobs: defineTable({ bytes: v.bytes(), group: v.string() }).index( + notes: defineTable({ source: v.string() }), + markers: defineTable({ trial: v.string() }).index("by_trial", ["trial"]), + throwerWrites: defineTable({ by: v.string() }), + parentWrites: defineTable({ by: v.string() }), + samples: defineTable({ value: v.number() }), + blobs: defineTable({ group: v.string(), bytes: v.bytes() }).index( "by_group", ["group"], ), - markers: defineTable({ trial: v.string() }).index("by_trial", ["trial"]), }); diff --git a/fixture/package.json b/fixture/package.json deleted file mode 100644 index 5d36fc1..0000000 --- a/fixture/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "libar-platform-fixture", - "private": true, - "type": "module", - "devDependencies": { - "convex": "1.46.0" - } -} diff --git a/harness/admin.ts b/harness/admin.ts new file mode 100644 index 0000000..8ba5936 --- /dev/null +++ b/harness/admin.ts @@ -0,0 +1,289 @@ +import { BaseConvexClient, ConvexHttpClient } from "convex/browser"; +import type { Value } from "convex/values"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { redact, runChild } from "./child.js"; +export interface AdminTarget { + url: string; + adminKey: string; + home: string; + secrets: readonly string[]; + signal?: AbortSignal; +} +export interface AdminState { + deployed: boolean; + environment: Set; +} +export interface LogMark { + readonly cursorMs: number; +} +export interface CompletionRecord { + kind: "Completion"; + udfType: "Query" | "Mutation" | "Action" | "HttpAction"; + identifier: string; + componentPath: string | null; + requestId: string; + executionId: string; + parentExecutionId: string | null; + timestamp: number; + executionTime: number; + cachedResult: boolean; + error: string | null; + caller: string; + usageStats: Record; +} +export interface AdminAccess { + deploy(): Promise; + codegen(): Promise; + setEnvironment(variables: Readonly>): Promise; + environment(): Promise>; + run( + name: string, + args?: Record, + options?: { component?: string }, + ): Promise; + readTable( + table: string, + options?: { component?: string; pageSize?: number }, + ): Promise[]>; + logMark(): Promise; + completionsSince( + mark: LogMark, + until: (records: readonly CompletionRecord[]) => boolean, + timeoutMs?: number, + ): Promise; +} +export interface CliCall { + file: string; + args: string[]; + cwd: string; + env: Record; + timeoutMs: number; +} +const repositoryRoot = join(import.meta.dirname, ".."); +export function convexCli( + target: Pick, + command: "deploy" | "codegen", +): CliCall { + const selection = ["--url", target.url, "--admin-key", target.adminKey]; + return { + file: process.execPath, + args: [ + join(repositoryRoot, "node_modules/convex/bin/main.js"), + ...(command === "deploy" + ? ["deploy", "--yes", "--codegen", "disable", "--typecheck", "disable"] + : ["codegen", "--typecheck", "disable"]), + ...selection, + ], + cwd: repositoryRoot, + env: { PATH: process.env.PATH ?? "", HOME: target.home, TMPDIR: tmpdir() }, + timeoutMs: command === "deploy" ? 60000 : 120000, + }; +} +// Convex 1.46.0 implements these members and strips them from its declarations. +type AdminHttpClient = ConvexHttpClient & { + setAdminAuth(key: string): void; + function( + name: string, + componentPath: string | undefined, + args: Record, + ): Promise; +}; +type AdminSocketClient = { + setAdminAuth(key: string): void; + subscribe( + name: string, + args: Record, + options: { componentPath?: string }, + ): unknown; + localQueryResultByToken(token: string): Value | undefined; + close(): Promise; +}; +export function createAdminAccess( + target: AdminTarget, + state: AdminState, +): AdminAccess { + const http = new ConvexHttpClient(target.url) as AdminHttpClient; + http.setAdminAuth(target.adminKey); + const authorization = { Authorization: `Convex ${target.adminKey}` }; + async function cli(command: "deploy" | "codegen") { + const call = convexCli(target, command); + await runChild(`convex ${command}`, call.file, call.args, { + cwd: call.cwd, + env: call.env, + timeoutMs: call.timeoutMs, + secrets: target.secrets, + ...(target.signal === undefined ? {} : { signal: target.signal }), + }); + } + function systemQuery( + name: string, + args: Record, + component: string | undefined, + timeoutMs = 10000, + ): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const settle = (finish: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + void client.close(); + finish(); + }; + const client = new BaseConvexClient( + target.url, + (tokens) => { + for (const token of tokens) { + let value: Value | undefined; + try { + value = client.localQueryResultByToken(token); + } catch (error) { + return settle(() => reject(error)); + } + if (value !== undefined) return settle(() => resolve(value)); + } + }, + { unsavedChangesWarning: false }, + ) as unknown as AdminSocketClient; + const timer = setTimeout( + () => + settle(() => + reject( + new Error(`Admin read ${name} got no answer in ${timeoutMs} ms`), + ), + ), + timeoutMs, + ); + client.setAdminAuth(target.adminKey); + client.subscribe( + name, + args, + component === undefined ? {} : { componentPath: component }, + ); + }); + } + return { + async deploy() { + await cli("deploy"); + state.deployed = true; + }, + codegen: () => cli("codegen"), + async setEnvironment(variables) { + const response = await fetch( + `${target.url}/api/update_environment_variables`, + { + method: "POST", + headers: { ...authorization, "Content-Type": "application/json" }, + body: JSON.stringify({ + changes: Object.entries(variables).map(([name, value]) => ({ + name, + value, + })), + }), + signal: AbortSignal.timeout(10000), + }, + ); + if (!response.ok) + throw new Error( + `Setting environment variables failed with status ${response.status}: ${redact(await response.text(), target.secrets)}`, + ); + for (const name of Object.keys(variables)) state.environment.add(name); + }, + async environment() { + const rows = (await systemQuery( + "_system/cli/queryEnvironmentVariables", + {}, + undefined, + )) as { name: string; value: string }[]; + return Object.fromEntries(rows.map((row) => [row.name, row.value])); + }, + run: (name, args = {}, options = {}) => + http.function(name, options.component, args), + async readTable(table, options = {}) { + const documents: Record[] = []; + let cursor: string | null = null; + for (;;) { + const page = (await systemQuery( + "_system/cli/tableData", + { + table, + order: "asc", + paginationOpts: { cursor, numItems: options.pageSize ?? 1000 }, + }, + options.component, + )) as { + page: Record[]; + isDone: boolean; + continueCursor: string; + }; + documents.push(...page.page); + if (page.isDone) break; + cursor = page.continueCursor; + } + if (documents.length === 0) { + const tables = ( + (await systemQuery( + "_system/cli/tables", + { paginationOpts: { cursor: null, numItems: 10000 } }, + options.component, + )) as { page: { name: string }[] } + ).page + .map((entry) => entry.name) + .sort(); + if (!tables.includes(table)) + throw new Error( + `No table "${table}" in ${options.component === undefined ? "the app" : `component "${options.component}"`}. Tables: ${tables.join(", ")}`, + ); + } + return documents; + }, + async logMark() { + // The log's cursor is a time in milliseconds on this machine's clock. Round up, then wait + // past it, so that every earlier record is at or before the mark and every later one after. + const cursorMs = Date.now() + 1; + await sleep(2); + return { cursorMs }; + }, + async completionsSince(mark, until, timeoutMs = 10000) { + const records: CompletionRecord[] = []; + const deadline = Date.now() + timeoutMs; + let cursor = mark.cursorMs; + while (!until(records)) { + const left = deadline - Date.now(); + if (left <= 0) + throw new Error( + `The function log held ${records.length} completion records ${timeoutMs} ms after the mark, and the test waited for more`, + ); + let response: Response; + try { + response = await fetch( + `${target.url}/api/stream_function_logs?cursor=${cursor}`, + { headers: authorization, signal: AbortSignal.timeout(left) }, + ); + } catch (error) { + if ((error as Error).name === "TimeoutError") continue; + throw error; + } + if (!response.ok) + throw new Error( + `Reading the function log failed with status ${response.status}`, + ); + const body = (await response.json()) as { + entries: { kind: string }[]; + newCursor: number; + }; + if (body.entries.length >= 1000) + throw new Error( + `One function log read returned ${body.entries.length} entries. The backend keeps its last 1000, so records may be lost. Read the log more often.`, + ); + for (const entry of body.entries) + if (entry.kind === "Completion") + records.push(entry as CompletionRecord); + cursor = body.newCursor; + } + return records; + }, + }; +} diff --git a/harness/backend-release.json b/harness/backend-release.json index dcc81cb..8d64a63 100644 --- a/harness/backend-release.json +++ b/harness/backend-release.json @@ -1,9 +1,15 @@ { "release": "precompiled-2026-09-28-5c7cb5b", "assets": { - "convex-local-backend-aarch64-apple-darwin.zip": "c1613bb4a3ac884871184020662c865a045cde95ad781fff002a6bd25f2f2890", - "convex-local-backend-x86_64-unknown-linux-gnu.zip": "5b4adbe9fc2f9985c66eb12b19dadedf2700d8796c839ddba09802196099f241", - "convex-local-backend-aarch64-unknown-linux-gnu.zip": "d13679e5abb77637218547b8109ec903ae5e9a276addb2bec438d54457a6772c", - "convex-local-backend-x86_64-apple-darwin.zip": "8ca9b573069b504ce533394bb126ba2c88529f260138f54171d8f4b39b9ebe3b" + "darwin-arm64": { + "file": "convex-local-backend-aarch64-apple-darwin.zip", + "zipSha256": "c1613bb4a3ac884871184020662c865a045cde95ad781fff002a6bd25f2f2890", + "executableSha256": "c6290f5a86bc2f6b31a931833c25106ccc06c0c8c9f0894c35f2d7cecf1fd676" + }, + "linux-x64": { + "file": "convex-local-backend-x86_64-unknown-linux-gnu.zip", + "zipSha256": "5b4adbe9fc2f9985c66eb12b19dadedf2700d8796c839ddba09802196099f241", + "executableSha256": "7ebc6a4696499640c2f2255395990b4f8b901b045388a0ced2ac765b81ebadae" + } } } diff --git a/harness/backend.ts b/harness/backend.ts index 3e646aa..f7c797b 100644 --- a/harness/backend.ts +++ b/harness/backend.ts @@ -1,89 +1,45 @@ -import { spawn, execFile as execFileCallback } from "node:child_process"; +import { spawn } from "node:child_process"; import type { ChildProcess } from "node:child_process"; -import { promisify } from "node:util"; +import { randomBytes, randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; -import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { - chmod, - mkdir, - mkdtemp, - readFile, - rm, - writeFile, - access, -} from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { setTimeout as delay } from "node:timers/promises"; -import { createIdentity } from "./identity.js"; -import type { FixtureIdentity } from "./identity.js"; - -const execFile = promisify(execFileCallback); -export const root = fileURLToPath(new URL("../", import.meta.url)); -const cli = join(root, "node_modules/convex/bin/main.js"); -export interface BackendRelease { - release: string; - assets: Record; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { createAdminAccess } from "./admin.js"; +import type { AdminAccess, AdminState } from "./admin.js"; +import { redact, runChild } from "./child.js"; +import type { Executable } from "./executable.js"; +import type { FixtureIssuer } from "./identity.js"; +export const fixtureComposition = { + name: "fixture", + functions: "fixture/convex", + installedLayers: [] as readonly string[], +} as const; +export interface BackendFacts { + composition: "fixture" | null; + installedLayers: string[]; + executable: Pick; + identitySource: { kind: "fixture issuer"; issuer: string }; + environment: string[]; + dataset: "empty at start"; } -export async function backendRelease(): Promise { - return JSON.parse( - await readFile(join(root, "harness/backend-release.json"), "utf8"), - ) as BackendRelease; +export interface StartOptions { + executable: Executable; + issuer: FixtureIssuer; + parentDirectory?: string; + signal?: AbortSignal; } -export async function backendBinary(): Promise { - if (process.env.CONVEX_BACKEND_BINARY) - return resolve(process.env.CONVEX_BACKEND_BINARY); - const asset = - process.platform === "darwin" && process.arch === "arm64" - ? "convex-local-backend-aarch64-apple-darwin.zip" - : process.platform === "linux" && process.arch === "x64" - ? "convex-local-backend-x86_64-unknown-linux-gnu.zip" - : undefined; - if (!asset) - throw new Error( - `Unsupported backend platform: ${process.platform}/${process.arch}`, - ); - const release = await backendRelease(); - const hash = release.assets[asset]; - if (!hash) - throw new Error( - `Missing SHA-256 for backend asset ${asset}; the orchestrator must supply it in harness/backend-release.json`, - ); - const cache = join(root, ".cache", release.release, asset); - const binary = join(cache, "convex-local-backend"); - try { - await access(binary); - return binary; - } catch { - /* download below */ - } - await mkdir(cache, { recursive: true }); - // Each downloader extracts in its own directory. Parallel tests never see a partial binary. - const work = await mkdtemp(join(cache, "download-")); - try { - const response = await fetch( - `https://github.com/get-convex/convex-backend/releases/download/${release.release}/${asset}`, - ); - if (!response.ok) - throw new Error(`Backend download failed: ${response.status} ${asset}`); - const bytes = Buffer.from(await response.arrayBuffer()); - if (createHash("sha256").update(bytes).digest("hex") !== hash) - throw new Error(`SHA-256 mismatch for ${asset}`); - const zip = join(work, "backend.zip"); - await writeFile(zip, bytes); - await execFile("unzip", ["-q", zip, "-d", work]); - const extracted = join(work, "convex-local-backend"); - await chmod(extracted, 0o755); - const { copyFile, rename } = await import("node:fs/promises"); - const staging = join(cache, `binary-${randomUUID()}`); - await copyFile(extracted, staging); - await chmod(staging, 0o755); - await rename(staging, binary); - return binary; - } finally { - await rm(work, { recursive: true, force: true }); - } +export interface Backend { + readonly url: string; + readonly adminKey: string; + readonly issuer: FixtureIssuer; + readonly admin: AdminAccess; + stop(): Promise; + kill(): Promise; + restart(): Promise; + dispose(): Promise; + facts(): BackendFacts; } async function freePort(): Promise { const server = createServer(); @@ -92,289 +48,213 @@ async function freePort(): Promise { server.listen(0, "127.0.0.1", ok); }); const address = server.address(); - if (!address || typeof address === "string") - throw new Error("No allocated TCP port"); - await new Promise((ok, fail) => - server.close((error) => (error ? fail(error) : ok())), - ); + await new Promise((ok) => server.close(() => ok())); + if (address === null || typeof address === "string") + throw new Error("The operating system gave no port"); return address.port; } -function exited(child: ChildProcess): Promise { +function exitOf(child: ChildProcess, timeoutMs: number): Promise { if (child.exitCode !== null || child.signalCode !== null) - return Promise.resolve(); - return new Promise((ok, fail) => { - child.once("exit", () => ok()); - child.once("error", fail); + return Promise.resolve(true); + return new Promise((done) => { + const timer = setTimeout(() => done(false), timeoutMs); + child.once("exit", () => { + clearTimeout(timer); + done(true); + }); }); } -export interface FunctionLogRecord { - identifier?: string; - componentPath?: string; - executionTime?: number; - usageStats?: unknown; - [key: string]: unknown; -} -export class Backend { - private child?: ChildProcess; - private output = ""; - private logProcesses = new Set(); - private constructor( - readonly binary: string, - readonly directory: string, - readonly port: number, - readonly sitePort: number, - readonly instanceName: string, - private readonly secret: string, - readonly adminKey: string, - readonly identity: FixtureIdentity, - ) {} - get url() { - return `http://127.0.0.1:${this.port}`; - } - get environment() { - return { - ...process.env, - CONVEX_SELF_HOSTED_URL: this.url, - CONVEX_SELF_HOSTED_ADMIN_KEY: this.adminKey, - }; - } - static async start(issuer?: string): Promise { - const binary = await backendBinary(); - const directory = await mkdtemp(join(tmpdir(), "libar-backend-")); - try { - const port = await freePort(); - let sitePort = await freePort(); - while (sitePort === port) sitePort = await freePort(); - const name = `fixture-${randomUUID()}`; - const secret = randomBytes(32).toString("hex"); - const { stdout } = await execFile(binary, [ - "keygen", - "admin-key", - "--instance-name", - name, - "--instance-secret", - secret, - ]); - const backend = new Backend( - binary, - directory, - port, - sitePort, - name, - secret, - stdout.trim(), - await createIdentity(issuer), - ); - try { - await backend.restart(); - return backend; - } catch (error) { - await backend.stop(); - throw error; - } - } catch (error) { - await rm(directory, { recursive: true, force: true }); - throw error; - } - } - async restart(): Promise { - if ( - this.child && - this.child.exitCode === null && - this.child.signalCode === null - ) - throw new Error("Backend is already running"); - this.output = ""; - const child = spawn( - this.binary, +export async function startBackend(options: StartOptions): Promise { + const directory = await mkdtemp( + join(options.parentDirectory ?? tmpdir(), "backend-"), + ); + const home = join(directory, "home"); + const instanceName = `libar-${randomUUID()}`; + const instanceSecret = randomBytes(32).toString("hex"); + const secrets: string[] = [instanceSecret]; + let child: ChildProcess | undefined; + let output = ""; + let port = 0; + let sitePort = 0; + let disposed = false; + const state: AdminState = { deployed: false, environment: new Set() }; + const running = () => + child !== undefined && child.exitCode === null && child.signalCode === null; + async function spawnAndWait(): Promise<"ready" | "port taken"> { + output = ""; + const spawned = spawn( + options.executable.path, [ "--port", - String(this.port), + String(port), "--site-proxy-port", - String(this.sitePort), + String(sitePort), "--interface", "127.0.0.1", "--instance-name", - this.instanceName, + instanceName, "--instance-secret", - this.secret, + instanceSecret, "--local-storage", - join(this.directory, "storage"), - join(this.directory, "backend.sqlite3"), + join(directory, "storage"), + "--disable-beacon", + join(directory, "backend.sqlite3"), ], { stdio: ["ignore", "pipe", "pipe"] }, ); - this.child = child; - let spawnError: Error | undefined; - child.on("error", (error) => { - spawnError = error; - }); - child.stdout?.on("data", (chunk: Buffer) => { - this.output = (this.output + chunk.toString()).slice(-16000); - }); - child.stderr?.on("data", (chunk: Buffer) => { - this.output = (this.output + chunk.toString()).slice(-16000); - }); + child = spawned; + const keep = (chunk: Buffer) => { + output = (output + chunk.toString()).slice(-16000); + }; + spawned.stdout?.on("data", keep); + spawned.stderr?.on("data", keep); + spawned.on("error", (error) => keep(Buffer.from(String(error)))); + if (spawned.pid !== undefined) + await writeFile(join(directory, "pid"), String(spawned.pid)); const deadline = Date.now() + 30000; while (Date.now() < deadline) { - if (spawnError) throw spawnError; - if (child.exitCode !== null || child.signalCode !== null) - throw new Error(`Backend exited before /version: ${this.output}`); + options.signal?.throwIfAborted(); + if (spawned.exitCode !== null || spawned.signalCode !== null) { + if (output.includes("Address already in use")) return "port taken"; + throw new Error( + `The backend exited with code ${spawned.exitCode} before it was ready: ${redact(output, secrets)}`, + ); + } try { + const response = await fetch(`http://127.0.0.1:${port}/instance_name`, { + signal: AbortSignal.timeout(1000), + }); if ( - ( - await fetch(`${this.url}/version`, { - signal: AbortSignal.timeout(1000), - }) - ).ok + response.ok && + (await response.text()) === instanceName && + spawned.exitCode === null ) - return; + return "ready"; } catch { - /* not ready */ + // Not listening yet. } - await delay(50); + await sleep(50); } - throw new Error(`Backend /version timed out: ${this.output}`); - } - async command(args: string[]): Promise { - const { stdout } = await execFile(process.execPath, [cli, ...args], { - cwd: join(root, "fixture"), - env: this.environment, - maxBuffer: 16 * 1024 * 1024, - }); - return stdout; + throw new Error( + `The backend was not ready in 30000 ms: ${redact(output, secrets)}`, + ); } - async deploy(): Promise { - const envFile = join(this.directory, "auth.env"); - await writeFile( - envFile, - `AUTH_ISSUER=${this.identity.issuer}\nAUTH_APPLICATION_ID=${this.identity.applicationID}\nAUTH_JWKS=${this.identity.jwks}\n`, - { mode: 0o600 }, + async function end(signal: "SIGINT" | "SIGKILL"): Promise { + if (child === undefined || !running()) return; + child.kill(signal); + if (await exitOf(child, 5000)) return; + if (signal === "SIGINT") { + child.kill("SIGKILL"); + if (await exitOf(child, 5000)) return; + } + throw new Error( + `Backend process ${child.pid} did not exit within 5000 ms after SIGKILL`, ); - await this.command(["env", "set", "--from-file", envFile]); - await this.command([ - "deploy", - "-y", - "--codegen", - "disable", - "--typecheck", - "disable", - ]); } - async readTable(table: string, componentPath?: string): Promise { - const component = componentPath ? ["--component", componentPath] : []; - const output = await this.command([ - "data", - table, - "--format", - "json", - ...component, - ]); - if (output.trim()) return JSON.parse(output) as unknown[]; - // The CLI prints nothing for an empty table and nothing for a table that does not exist. - const tables = (await this.command(["data", ...component])).split("\n"); - if (!tables.includes(table)) + async function dispose(): Promise { + if (disposed) return; + disposed = true; + const left: string[] = []; + await end("SIGKILL").catch((error: Error) => left.push(error.message)); + await rm(directory, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }).catch((error: Error) => + left.push(`could not remove ${directory}: ${error.message}`), + ); + if (left.length > 0) throw new Error( - `No table ${table} in ${componentPath ?? "the app"}: ${tables.join(" ")}`, + `Backend disposal left something behind: ${left.join("; ")}`, ); - return []; } - logs() { - const records: FunctionLogRecord[] = []; - const errors: string[] = []; - const child = spawn( - process.execPath, - [cli, "logs", "--success", "--jsonl"], + try { + await mkdir(home); + const adminKey = ( + await runChild( + "convex-local-backend keygen", + options.executable.path, + [ + "keygen", + "admin-key", + "--instance-name", + instanceName, + "--instance-secret", + instanceSecret, + ], + { + timeoutMs: 10000, + secrets, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }, + ) + ).trim(); + secrets.push(adminKey); + for (let attempt = 1; ; attempt++) { + port = await freePort(); + do sitePort = await freePort(); + while (sitePort === port); + if ((await spawnAndWait()) === "ready") break; + if (attempt === 3) + throw new Error( + `The backend could not bind its ports in 3 attempts: ${redact(output, secrets)}`, + ); + } + const url = `http://127.0.0.1:${port}`; + const admin = createAdminAccess( { - cwd: join(root, "fixture"), - env: this.environment, - stdio: ["ignore", "pipe", "pipe"], + url, + adminKey, + home, + secrets, + ...(options.signal === undefined ? {} : { signal: options.signal }), }, + state, ); - this.logProcesses.add(child); - let pending = ""; - child.on("error", (error) => errors.push(String(error))); - child.stderr?.on("data", (chunk: Buffer) => errors.push(chunk.toString())); - child.stdout?.on("data", (chunk: Buffer) => { - pending += chunk.toString(); - const lines = pending.split("\n"); - pending = lines.pop() ?? ""; - for (const line of lines) { - if (!line.trim()) continue; - try { - records.push(JSON.parse(line) as FunctionLogRecord); - } catch { - errors.push(`Invalid function log JSON: ${line}`); - } - } + await admin.setEnvironment({ + AUTH_ISSUER: options.issuer.issuer, + AUTH_APPLICATION_ID: options.issuer.applicationID, + AUTH_JWKS: options.issuer.jwks, }); return { - records, - errors, - async waitFor( - predicate: (record: FunctionLogRecord) => boolean, - timeout = 10000, - ) { - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const record = records.find(predicate); - if (record) return record; - if (child.exitCode !== null || child.signalCode !== null) - throw new Error(`Function log stream exited: ${errors.join("\n")}`); - await delay(25); - } - throw new Error(`Function log record timed out: ${errors.join("\n")}`); - }, - stop: async () => { - child.kill("SIGINT"); - await exited(child); - this.logProcesses.delete(child); + url, + adminKey, + issuer: options.issuer, + admin, + stop: () => end("SIGINT"), + kill: () => end("SIGKILL"), + async restart() { + if (running()) + throw new Error( + "The backend is still running. Stop or kill it before restart.", + ); + if ((await spawnAndWait()) === "port taken") + throw new Error( + `The backend could not restart on port ${port}: ${redact(output, secrets)}`, + ); }, + dispose, + facts: () => ({ + composition: state.deployed ? fixtureComposition.name : null, + installedLayers: state.deployed + ? [...fixtureComposition.installedLayers] + : [], + executable: { + release: options.executable.release, + sha256: options.executable.sha256, + source: options.executable.source, + }, + identitySource: { + kind: "fixture issuer", + issuer: options.issuer.issuer, + }, + environment: [...state.environment].sort(), + dataset: "empty at start", + }), }; - } - async kill(): Promise { - if (this.child) { - this.child.kill("SIGKILL"); - await exited(this.child); - } - } - async stop(): Promise { - for (const child of this.logProcesses) { - child.kill("SIGINT"); - await exited(child); - } - this.logProcesses.clear(); - if (this.child) { - const child = this.child; - child.kill("SIGINT"); - const timer = setTimeout(() => child.kill("SIGKILL"), 5000); - try { - await exited(child); - } finally { - clearTimeout(timer); - } - } - await rm(this.directory, { recursive: true, force: true }); - } -} -export async function startFixture(issuer?: string): Promise { - const backend = await Backend.start(issuer); - try { - await backend.deploy(); - return backend; } catch (error) { - await backend.stop(); + await dispose().catch(() => undefined); throw error; } } -export async function withBackend( - run: (backend: Backend) => Promise, - issuer?: string, -): Promise { - const backend = await startFixture(issuer); - try { - return await run(backend); - } finally { - await backend.stop(); - } -} diff --git a/harness/child.ts b/harness/child.ts new file mode 100644 index 0000000..263754d --- /dev/null +++ b/harness/child.ts @@ -0,0 +1,53 @@ +import { execFile } from "node:child_process"; +export interface ChildOptions { + timeoutMs: number; + cwd?: string; + env?: Readonly>; + secrets?: readonly string[]; + signal?: AbortSignal; +} +export function redact(text: string, secrets: readonly string[]): string { + let result = text; + for (const secret of secrets) + if (secret !== "") result = result.split(secret).join("[redacted]"); + return result; +} +export function runChild( + name: string, + file: string, + args: readonly string[], + options: ChildOptions, +): Promise { + return new Promise((resolve, reject) => { + execFile( + file, + [...args], + { + cwd: options.cwd, + env: { ...(options.env ?? { PATH: process.env.PATH ?? "" }) }, + timeout: options.timeoutMs, + killSignal: "SIGKILL", + maxBuffer: 16 * 1024 * 1024, + signal: options.signal, + }, + (error, stdout, stderr) => { + if (error === null) return resolve(stdout); + if (options.signal?.aborted) return reject(options.signal.reason); + if (error.killed) + return reject( + new Error(`${name} did not finish in ${options.timeoutMs} ms`), + ); + if (typeof error.code !== "number") + return reject( + new Error(`${name} could not start: ${String(error.code)}`), + ); + const output = (stderr.trim() === "" ? stdout : stderr).slice(-4000); + reject( + new Error( + `${name} failed with exit code ${error.code}: ${redact(output, options.secrets ?? [])}`, + ), + ); + }, + ); + }); +} diff --git a/harness/clients.ts b/harness/clients.ts index f874aea..615a5d4 100644 --- a/harness/clients.ts +++ b/harness/clients.ts @@ -1,37 +1,188 @@ import { ConvexClient, ConvexHttpClient } from "convex/browser"; -import type { UserIdentity } from "convex/server"; -// Convex 1.46.0 implements these methods but strips them from its public declarations. -// Keep the escape hatch limited to the native harness. import type { - FunctionReference, FunctionArgs, + FunctionReference, FunctionReturnType, - UserIdentityAttributes, } from "convex/server"; -export type HarnessHttpClient = ConvexHttpClient & { - setAdminAuth(key: string, identity?: UserIdentityAttributes): void; - function>( - name: F | string, - componentPath: string | undefined, - args: FunctionArgs, - ): Promise>; -}; -export function httpClient(url: string, token?: string) { - const client = new ConvexHttpClient(url) as HarnessHttpClient; - if (token) client.setAuth(token); - return client; -} -export function websocketClient(url: string, token?: string) { - const client = new ConvexClient(url); - if (token) client.setAuth(async () => token); +import { waitUntil } from "./wait.js"; +export function ordinaryClient( + url: string, + options: { token?: string; fetch?: typeof globalThis.fetch } = {}, +): ConvexHttpClient { + const client = new ConvexHttpClient( + url, + options.fetch === undefined ? {} : { fetch: options.fetch }, + ); + if (options.token !== undefined) client.setAuth(options.token); return client; } -export function adminClient( +export function ordinarySocketClient( url: string, - adminKey: string, - identity?: UserIdentity, -) { - const client = new ConvexHttpClient(url) as HarnessHttpClient; - client.setAdminAuth(adminKey, identity); + options: { token?: string; webSocket?: typeof WebSocket } = {}, +): ConvexClient { + const client = new ConvexClient( + url, + options.webSocket === undefined + ? {} + : { webSocketConstructor: options.webSocket }, + ); + const token = options.token; + if (token !== undefined) client.setAuth(async () => token); return client; } +export interface CountingFetch { + readonly fetch: typeof globalThis.fetch; + requests(): number; +} +export function countingFetch(options: { + loseResponses: boolean; +}): CountingFetch { + let requests = 0; + return { + requests: () => requests, + fetch: async (input, init) => { + requests++; + const response = await fetch(input, init); + if (!options.loseResponses) return response; + // The backend has answered. Read the answer to the end, then lose it. + await response.arrayBuffer(); + throw new TypeError("The response was lost"); + }, + }; +} +export interface HeldWebSocket { + readonly WebSocket: typeof WebSocket; + holdResponses(): void; + pauseConnections(): void; + release(): void; +} +export function heldWebSocket(): HeldWebSocket { + const NativeWebSocket = globalThis.WebSocket; + let holding = false; + let paused = false; + const sockets = new Set(); + class Held { + onopen: WebSocket["onopen"] = null; + onclose: WebSocket["onclose"] = null; + onerror: WebSocket["onerror"] = null; + onmessage: WebSocket["onmessage"] = null; + private native: WebSocket | undefined; + private held: MessageEvent[] = []; + private closed = false; + private url: string | URL; + constructor(url: string | URL) { + this.url = url; + sockets.add(this); + if (!paused) this.connect(); + } + connect() { + if (this.native !== undefined || this.closed) return; + const native = new NativeWebSocket(this.url); + this.native = native; + native.onopen = (event) => this.onopen?.call(native, event); + native.onerror = (event) => this.onerror?.call(native, event); + native.onclose = (event) => { + this.held = []; + sockets.delete(this); + this.onclose?.call(native, event); + }; + native.onmessage = (event) => { + if (holding) this.held.push(event); + else this.onmessage?.call(native, event); + }; + } + deliver() { + const native = this.native; + if (native === undefined) return; + for (const event of this.held) this.onmessage?.call(native, event); + this.held = []; + } + send(data: string) { + if (this.native === undefined) + throw new Error("The socket is not connected"); + this.native.send(data); + } + close() { + this.closed = true; + this.held = []; + if (this.native !== undefined) return this.native.close(); + sockets.delete(this); + queueMicrotask(() => + this.onclose?.call( + this as unknown as WebSocket, + new CloseEvent("close"), + ), + ); + } + } + return { + WebSocket: Held as unknown as typeof WebSocket, + holdResponses() { + holding = true; + }, + pauseConnections() { + paused = true; + }, + release() { + holding = false; + paused = false; + for (const socket of sockets) { + socket.connect(); + socket.deliver(); + } + }, + }; +} +export interface QueryWatch { + readonly values: readonly T[]; + readonly error: Error | undefined; + until( + matches: (value: T) => boolean, + description: string, + timeoutMs?: number, + ): Promise; + stop(): void; +} +export function watchQuery>( + client: ConvexClient, + query: Query, + args: FunctionArgs, +): QueryWatch> { + type Result = FunctionReturnType; + const values: Result[] = []; + let error: Error | undefined; + let stopped = false; + const unsubscribe = client.onUpdate( + query, + args, + (value) => { + values.push(value); + }, + (failure) => { + error = failure; + }, + ); + return { + values, + get error() { + return error; + }, + async until(matches, description, timeoutMs = 5000) { + await waitUntil( + description, + () => { + if (error !== undefined) throw error; + return values.length > 0 && matches(values[values.length - 1]); + }, + timeoutMs, + ); + return values[values.length - 1]; + }, + stop() { + // ConvexClient throws when a subscription is removed twice. + if (stopped) return; + stopped = true; + unsubscribe(); + }, + }; +} diff --git a/harness/evidence.ts b/harness/evidence.ts index 9cc67d8..23e0c02 100644 --- a/harness/evidence.ts +++ b/harness/evidence.ts @@ -1,125 +1,136 @@ import { execFileSync } from "node:child_process"; -import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; -import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { Reporter, TestCase } from "vitest/node"; -import { backendRelease, root } from "./backend.js"; -import { fixtureIssuer } from "./identity.js"; -const runID = process.env.NATIVE_RUN_ID ?? randomUUID(); -process.env.NATIVE_RUN_ID = runID; -function encode(_key: string, value: unknown) { - return typeof value === "bigint" ? { bigint: value.toString() } : value; +import type { BackendFacts } from "./backend.js"; +export type JsonValue = + null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; +export interface Measurement { + name: string; + value: JsonValue; } -export async function recordMeasurement( - test: string, - name: string, - value: unknown, -): Promise { - const directory = join( - root, - "evidence/runs", - `${process.env.NATIVE_RUN_ID ?? runID}.measurements`, - ); - await mkdir(directory, { recursive: true }); - await writeFile( - join(directory, `${randomUUID()}.json`), - JSON.stringify({ test, name, value }, encode), - ); +export interface NativeTestFacts { + backends: BackendFacts[]; + measurements: Measurement[]; +} +export interface NativeTestEntry extends NativeTestFacts { + name: string; + file: string; + result: "passed" | "failed" | "skipped"; + durationMs: number | null; + errors: string[]; +} +export interface NativeRunRecord { + tier: "native"; + commit: string; + clean: boolean; + command: string; + startedAt: string; + finishedAt: string; + result: "passed" | "failed" | "interrupted"; + versions: Record; + tests: NativeTestEntry[]; + unhandledErrors: string[]; +} +declare module "vitest" { + interface TaskMeta { + native?: NativeTestFacts; + } +} +const repositoryRoot = join(import.meta.dirname, ".."); +const versioned: Record = { + convex: "convex", + "convex-helpers": "convex-helpers", + "convex-test": "convex-test", + vitest: "vitest", + typescript: "@typescript/native", + react: "react", + "happy-dom": "happy-dom", +}; +function git(...args: string[]): string | undefined { + try { + return execFileSync("git", args, { + cwd: repositoryRoot, + encoding: "utf8", + }).trim(); + } catch { + return undefined; + } } export default class EvidenceReporter implements Reporter { - private nativeRun = false; - onTestRunStart(...args: Parameters>) { - this.nativeRun = args[0].some( + private native = false; + private startedAt = ""; + private commit = "unknown"; + private clean = false; + private tests: NativeTestEntry[] = []; + onTestRunStart( + specifications: Parameters>[0], + ) { + this.native = specifications.some( (specification) => specification.project.name === "native", ); + this.startedAt = new Date().toISOString(); + this.commit = git("rev-parse", "HEAD") ?? "unknown"; + this.clean = git("status", "--porcelain") === ""; + this.tests = []; } - private tests: { name: string; result: unknown }[] = []; onTestCaseResult(test: TestCase) { - if (test.project.name === "native") - this.tests.push({ name: test.fullName, result: test.result() }); + if (test.project.name !== "native") return; + const result = test.result(); + if (result.state === "pending") return; + this.tests.push({ + name: test.fullName, + file: test.module.relativeModuleId, + result: result.state, + durationMs: test.diagnostic()?.duration ?? null, + errors: (result.errors ?? []).map((error) => error.message), + backends: test.meta().native?.backends ?? [], + measurements: test.meta().native?.measurements ?? [], + }); } async onTestRunEnd( - ...args: Parameters> + ...[, unhandledErrors, reason]: Parameters< + NonNullable + > ) { - if (!this.nativeRun) return; - const [, unhandledErrors, reason] = args; - const directory = join(root, "evidence/runs"); - await mkdir(directory, { recursive: true }); - const measurementsDirectory = join(directory, `${runID}.measurements`); - let measurements: { test: string; name: string; value: unknown }[] = []; - try { - measurements = await Promise.all( - (await readdir(measurementsDirectory)).map( - async (file) => - JSON.parse( - await readFile(join(measurementsDirectory, file), "utf8"), - ) as { test: string; name: string; value: unknown }, - ), - ); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - const packages = [ - "convex", - "convex-test", - "convex-helpers", - "vitest", - "@typescript/native", - ]; - const versions = Object.fromEntries( - await Promise.all( - packages.map(async (name) => { - const metadata = JSON.parse( - await readFile( - join(root, "node_modules", name, "package.json"), - "utf8", - ), - ) as { version: string }; - return [ - name === "@typescript/native" ? "TypeScript" : name, - metadata.version, - ]; - }), - ), - ); - const record = { - tier: "native backend", - reason, - unhandledErrors, - commit: execFileSync("git", ["rev-parse", "HEAD"], { - cwd: root, - encoding: "utf8", - }).trim(), - dirty: !!execFileSync("git", ["status", "--porcelain"], { - cwd: root, - encoding: "utf8", - }).trim(), - installedLayers: [], - composition: "fixture", - backend: { - ...(await backendRelease()), - binaryOverride: process.env.CONVEX_BACKEND_BINARY ?? null, - }, - versions: { ...versions, Node: process.version }, - configuration: { - adjustments: { identitySource: fixtureIssuer }, - description: - "Fixture composition only; no platform layer or production composition exists yet.", - }, - dataset: "Fresh empty storage per test; only rows written by that test.", - identitySource: fixtureIssuer, - command: process.env.npm_lifecycle_script ?? process.argv.join(" "), - tests: this.tests.map((test) => ({ - ...test, - measurements: measurements.filter((measurement) => - test.name.includes(measurement.test), - ), - })), + if (!this.native) return; + const versions: Record = { node: process.version }; + for (const [name, packageName] of Object.entries(versioned)) + versions[name] = ( + JSON.parse( + await readFile( + join(repositoryRoot, "node_modules", packageName, "package.json"), + "utf8", + ), + ) as { version: string } + ).version; + // npm sets both for `npm run