diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b9ff0..6ea372b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,57 @@ While pre-1.0, the public API may change between 0.x releases. ## [Unreleased] +### Added + +- **SSR support (experimental, ADR-0011)** — built on TanStack DB's merged + SSR API (`DbClient` `dehydrate()`/`hydrate()`, PR + [#1564](https://github.com/TanStack/db/pull/1564), shipped in + `@tanstack/db` 0.8.0). + - **Server:** `readSyncSnapshot(req, request)` — one consistent + `{rows, cursor}` read over the DO binding, no WebSocket. The required + `request` runs through `parseAttachment`: one auth gate for the socket + and the read path. The cursor is a durable high-water mark + (`max(currentSeq, drainCursor)` — robust to retention pruning); `"0"` + honestly means "no resume point". RPC rows normalize BLOB + `ArrayBuffer → Uint8Array` for wire-codec parity (ADR-0017). + - **Client:** `SsrSnapshotTransport` (read-only; per-request; swapped at + the new structural `Transport` seam), syncMeta cursor round-trip + (`{v, cursor, where-fingerprint}`; fail-loud-but-safe import/merge), + `since` on the first sub, `seedCursor` (a late chunk regresses and + replays via a forced reconnect), always-armed eager snapshot reconcile + (authoritative set semantics — no flash-to-empty, no stranded deletes), + on-demand transient catch-up with honest truncate for unresumable rows. + - **Wire (additive):** `uptodate` gains optional `sub` (a catch-up's + terminal is sub-scoped); the first `sub` may carry `since`. +- **New upstream contracts adopted:** `commit()` receipts + (`SyncAppliedReceipt`, 0.8.5) — subset loads settle only once rows are + visible; `markError` (0.8.2) — a failed first connect fails `preload()` + loud with the cause instead of hanging (a retried `preload()` recovers); + per-subset load failures reject that subset's promise (0.8.4); + `withCollectionConfigFactory` (0.8.0) — `doCollectionOptions` configs work + as `collectionOptions(id, …)` descriptors with fresh adapter state per + `DbClient`. + +### Changed + +- **Peer dependency: `@tanstack/db >= 0.8.5`** (was `>= 0.6.0`) — the SSR + hooks shipped in 0.8.0; 0.8.5 carries the `commit()`-receipt contract and + descriptor reuse this adapter adopts. The 0.6-era API is otherwise + unchanged: the full pre-lift suite passes on 0.8.5 without modification. +- The transport ignores STREAM frames from an abandoned socket + (identity-guarded message dispatch): only the current socket speaks for the + stream; dropped frames are re-covered by the resubscribe catch-up from the + applied cursor. ID-scoped receipts (`committed`/`rejected`/`page`) still + settle their waiters from a stale socket — they are not re-covered by any + replay — but never advance the cursor. + +### Fixed + +- `unsubscribe` during an in-flight `subscribe`'s connect no longer sends the + sub after the socket opens — previously the server persisted a ghost + subscription (ADR-0019) with no local consumer until the socket dropped. + Pre-existing; surfaced by the SSR lift's adversary review. + ## [0.6.0] — 2026-07-27 ### Added diff --git a/README.md b/README.md index f5c52bc..9481042 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,57 @@ same socket: `transport.call.(args)` (typed sugar) or the low-level `transport.sendCall("clearRoom", undefined)` — both mint the txId for you and resolve with the command's result on `committed`. +### 4. SSR (experimental) + +Built on TanStack DB's SSR support (`DbClient` `dehydrate()`/`hydrate()` and +the `exportSyncMeta`/`importSyncMeta`/`mergeSyncMeta` sync hooks, shipped in +`@tanstack/db` 0.8.0 — this adapter requires ≥ 0.8.5). Why/how trade-offs +live in [ADR-0011](./docs/adr/0011-ssr-dehydrate-hydrate.md). + +On the worker, render through a **per-request** `DbClient` backed by one +snapshot read per subscription — no WebSocket from the render path: + +```ts +// Route loader / server handler (per request!) +import { DbClient, collectionOptions } from "@tanstack/db" +import { doCollectionOptions, SsrSnapshotTransport } from "tanstack-durable-object-sync/client" + +const stub = env.CHAT_DO.get(env.CHAT_DO.idFromName(sessionId)) +// `request` is the incoming (claims-bearing) Request — the DO runs it through +// parseAttachment, the SAME auth gate as the WebSocket upgrade. +const transport = new SsrSnapshotTransport({ + read: (req) => stub.readSyncSnapshot(req, request), +}) +const db = new DbClient() +const messages = db.collection( + collectionOptions("messages", () => + doCollectionOptions({ transport, table: "messages", getKey: (m) => m.id }), + ), +) +await messages.preload() +return { dbState: db.dehydrate() } // rows + our resume cursor (opaque syncMeta) +``` + +In the browser, hydrate before going live. The collection is ready +immediately with the dehydrated rows (stale-while-revalidate); the first sub +resumes from the dehydrated cursor, so the catch-up applies exactly what +changed while the HTML was in flight — updates *and* deletes: + +```ts +const db = new DbClient() +db.hydrate(dbState) // or from @tanstack/react-db +const messages = db.collection( + collectionOptions("messages", () => + doCollectionOptions({ transport: wsTransport, table: "messages", getKey: (m) => m.id }), + ), +) +``` + +Mutations during SSR throw (`SsrReadOnlyError`). `readSyncSnapshot` is callable +by any worker holding the DO binding, and its required `request` argument runs +through `parseAttachment` — **one auth gate for both the socket and the read +path**, so a tenant check in `parseAttachment` can't be bypassed by SSR. + --- ## Examples @@ -288,6 +339,10 @@ browser-verified. an inbox) behind one Worker: one transport per DO, each typed by its own `Api` so `transport.call.*` is scoped to that DO's commands, and a cross-DO feed merged client-side (the DO never joins — ADR-0001). +- **[`examples/ssr`](./examples/ssr)** — TanStack Start on Cloudflare with + `routerWithDbClient`: a loader-preloaded page (rows in the server HTML, + WebSocket resumes from the dehydrated cursor) and a Suspense-streaming page + (`useLiveSuspenseQuery`, result streamed into the document). ADR-0011. > [!TIP] > Using on-demand with `orderBy` + `limit`? Add a **range index** on the order diff --git a/docs/adr/0011-ssr-dehydrate-hydrate.md b/docs/adr/0011-ssr-dehydrate-hydrate.md new file mode 100644 index 0000000..b2e0d26 --- /dev/null +++ b/docs/adr/0011-ssr-dehydrate-hydrate.md @@ -0,0 +1,354 @@ +# 0011 — SSR: dehydrate on the worker, hydrate to the cursor + +**Status:** Accepted (experimental — designed against TanStack DB draft PR +[#1564](https://github.com/TanStack/db/pull/1564), lifted onto the PR **as +merged** in `@tanstack/db` 0.8.0; see *Amendments* below). Generalizes +ADR-0002 C1's flush-before-`committed` barrier to *all* cursor-advancing +emissions (C1′ below). + +## Context + +TanStack DB's draft SSR PR adds `DbClient` with row-level +`dehydrate()`/`hydrate()` and three opaque sync-config hooks — +`exportSyncMeta(): unknown`, `importSyncMeta(meta)`, +`mergeSyncMeta(current, incoming)`. Facts about the upstream design that +constrain ours (verified against the PR, not its docs — it has none for +adapter authors): + +- Hydration applies rows as **synced upserts** (`committed: true, + immediate: true`) **before** `importSyncMeta` runs. An adapter cannot veto + row application; correctness must come from post-connect catch-up. +- Dehydrated state has **no tombstones**. It is a snapshot *at* the exported + cursor, never a delta. All delete-correctness is the adapter's problem. +- Hydration does **not** mark the collection ready; readiness stays under the + adapter's `markReady()`. +- The hooks live on the (potentially module-scoped, request-shared) sync + config. Our options creator takes the transport as an argument, so on the + server our options are **per-request by construction** — we sidestep the + upstream cross-request-leak hazard, and document per-request creation as a + requirement. +- TanStack sync-write semantics: a sync `insert` for an existing key throws + `DuplicateKeySyncError` unless values deep-equal; a sync `update` for a + missing key upserts (our move-in path already relies on this). + +Our model is one ordered stream per DO and a **single client cursor** +(`appliedSeq`) advanced only at commit boundaries (ADR-0001/0002). The server +already serves `since` on *any* sub — windowed catch-up within the retention +floor, honest `reset` below it (ADR-0009). SSR support is therefore mostly: +get a snapshot + cursor out of the DO without a WebSocket, round-trip the +cursor through the dehydrated state, and make the first sub carry `since`. + +## Decision + +### D1 — Socketless snapshot read: `readSyncSnapshot` RPC + +`SyncDurableObject` gains a public RPC method: + +```ts +readSyncSnapshot( + req: { collection: string; where?: unknown; orderBy?: unknown; limit?: number }, + request: Request, // REQUIRED — runs through parseAttachment, the one auth gate +): Promise<{ rows: Array>; cursor: string }> +``` + +Same compile path as the `fetch` frame (`compileSubsetQuery`); the gate awaits +*before* the reads, so rows and cursor are still taken at one position +(synchronous SQLite between them). Throws on unknown collection or unsupported +predicate — fail loud; RPC propagates. + +Trust model: the binding limits callers to first-party workers, and the +REQUIRED `request` argument runs through **`parseAttachment` — the same gate +as the WS upgrade**. The worker passes the claims-bearing Request it already +forges (or forwards) for the socket path; a rejecting `parseAttachment` +rejects the read. Two paths, one gate: an author's tenant check cannot be +silently bypassed by the snapshot read (grill-session finding — an earlier +draft had no gate here, inverting the WS path's safe-by-default shape). The +minted claims are also the seam where uniform read-scoping would land, on +subs and snapshots alike — note that today *neither* path filters rows by +identity; `parseAttachment` is connection/read-level gating, and the +client-supplied `where` is shaping, not security. + +**The exported cursor is a durable high-water mark** — `max(MAX(_sync_changes +.seq), drain_cursor)` — *not* bare `currentSeq()`, because retention can prune +the changelog empty while the table has rows, and a bogus cursor `0` against +live rows would let a delete that lands between render and hydration strand a +stale row forever (adversarial-review finding). Cursor `"0"` therefore honestly +means "no resume point": the client omits `since` and reconciles (D4). + +### D2 — `SsrSnapshotTransport`, and `Transport` as an interface + +What `doCollectionOptions` consumes becomes a structural `Transport` interface +(satisfied by `WebSocketTransport` unchanged). `SsrSnapshotTransport` implements +it for server rendering: constructor takes `read: (req) => Promise<{rows, +cursor}>` (the author passes `(req) => stub.readSyncSnapshot(req, request)`, +closing over the request's claims; no Cloudflare +types in the client build). `subscribe` performs one read and synthesizes +`onSnap*`/`onSnapEnd`; `connect()` resolves immediately (so on-demand +`loadSubset` during a server `preload()` works unchanged); its cursor is the +**min** across reads; `sendMut`/`sendCall`/`fetch` throw `SsrReadOnlyError`. +SSR is read-only. + +Min is not merely the *safe* joint resume point (replay is idempotent; +skipping is not) — it is *self-consistent-making*: a render's reads land at +slightly different positions (milliseconds of DO time apart), and the first +catch-up from min replays exactly that skew window, converging every +dehydrated row to one position. Because the changelog `seq` is one stream +across all collections on the DO, the min is also a coherent position for +every collection sharing the transport — no per-collection reset risk. +Per-table cursor tracking (`cursorFor(table)`) was considered and rejected: +permanent interface surface to avoid a transient milliseconds-wide replay. + +### D3 — syncMeta carries the cursor; the first sub carries `since` + +`doCollectionOptions` implements the hooks: + +- `exportSyncMeta → { v: 1, cursor: transport.appliedCursor, where? }` — + `where` is a fingerprint (the codec envelope) of the eager filter the rows + were dehydrated under. A cursor is only a sound resume point *for that + filter*: catch-up emits changed keys only, so an **unchanged** out-of-filter + hydrated row would never be reconciled away (second-review finding). +- `importSyncMeta` — validate (`v` unknown / malformed cursor → throw); a + fingerprint mismatch (deploy skew) refuses the cursor and downgrades to the + always-sound snapshot-reconcile path (`hydratedCursor = "0"`, transport + unseeded); otherwise stash `hydratedCursor` and `transport.seedCursor(c)`. +- `mergeSyncMeta → min(cursor)` — min is self-healing: a late/stale chunk's + rows are applied upstream before we're consulted, and a min cursor makes the + next catch-up replay exactly the clobbered window. + +`seedCursor(c)` may **regress** `appliedSeq` (claiming a *shorter* applied +prefix is always safe). A regress while LIVE cannot replay on the same socket: +boundary frames the server already sent (full duplex) would dispatch after the +regress and re-advance the cursor past the repair window (second-review +blocker). It therefore **forces a reconnect** — the old socket's queued +boundaries stop counting (`advance` suppressed; their data still applies, +idempotently) and the fresh socket resubscribes from the seed. One mechanism +for early and late hydration; no second cursor, no ack channel. + +With a `hydratedCursor` (consumed once at sync start; cleared in the sync +cleanup fn — after a collection GC the rows are wiped, so a retained cursor +would resume over an empty store and silently lose data): + +- **Eager**: the first sub carries `since`; `markReady()` immediately (rows are + present; catch-up arrives as `d`+`uptodate`, which never fires `snap-end`). + Be explicit about what this changes: **hydration redefines `ready` as + "renderable", not "synced"** — `isReady` is true on the server pass (no + socket will ever exist) and stays true offline with stale rows. That is the + stale-while-revalidate contract, deliberately. An app that wants a + "catching up → live" signal (a SyncIndicator) doesn't need new API: the + transport already exposes it — `awaitSeq(String(BigInt(dehydratedCursor) + + 1n))` resolves at the first post-hydration boundary, i.e. caught up. Not + README material (sharp-edged); recorded here for when someone asks. + Below the retention floor the server answers `reset` → truncate + fresh + snapshot — which DOES flash empty between the truncate's commit and the + snapshot's (unlike the cursor-`"0"` reconcile path). Accepted, not fixed: + the dehydrated cursor is seconds old, so falling below the floor requires + `changelogRetentionMs` (default 2 days) shorter than the HTML's flight time + — pathological config, not a reachable state. Unifying it would need the + client to skip the truncate and let snapshot set-semantics reconcile, but a + `reset` is also the only terminal for a REJECTED sub (no snapshot follows), + where skipping the truncate keeps stale rows forever — the one outcome + ranked worst throughout this design. The reset-cause ambiguity is harmless + today (rejection is dev-loud; below-floor is pathological) and becomes + worth a wire-level distinction — likely alongside the incarnation epoch — + when client-side persistence (an LRU'd local db) makes days-old cursors + routine. Future scope, deliberately not now. +- **On-demand**: **one transient unfiltered catch-up sub** + (`since = hydratedCursor`, no `where`) that unsubscribes at *its own* + sub-scoped terminal — never at a broadcast boundary, which can precede its + frames. The dehydrated rows are the union of the server-loaded subsets; + per-subset `since` is unsound for any subset the dehydrated state didn't + cover, and subset-tracking still leaves overlapping-`where` stale-delete + holes. One unfiltered catch-up covers every changed key (always-emit ⇒ + synthetic deletes included) in the seconds-wide render→hydrate window. + **Semantic cost, accepted and documented**: changes to rows outside any + hydrated subset land in the collection during that window (bounded by + change volume). The leaked rows' staleness is **unobservable**: a live + query whose predicate matches one has a server sub with that same + predicate, whose snapshot/deltas converge it at observation time + (update-if-exists) — stale only while nothing looks, fresh by the time + anything does. Eager rendering of stale/leaked data is acceptable against + the snappy client-first UI it buys; the residual cost is memory, bounded + by seconds of change volume. `markReady()` **gates on the catch-up sub frame being + sent** (not completed): `loadSubset` subs fire only after ready, so on the + single ordered socket the catch-up always precedes subset snapshots + (second-review finding — `connect().then(markReady)` alone races). + Wire note: the transient's teardown depends on the server scoping the + catch-up terminal (`uptodate.sub`); against a pre-0011 server the terminal + arrives unscoped and the transient sub never tears down (an unfiltered + live sub leaks until the socket drops). Matter-of-fact, not mitigated: + pre-1.0, client/server version skew is not a supported configuration — + the worker ships the bundle and the DO from one deploy. + When the hydrated rows are **unresumable** — cursor `"0"`, or the server + `reset`s the catch-up below the floor — on-demand **truncates** them + (the reset path also unsubscribes immediately so the trailing unfiltered + resnapshot is dropped unhandled). A full-table snapshot was rejected here — + and the principled line between this and the tolerated catch-up leak above + (both are "stale while unobserved, fresh when observed") is **on-demand's + memory contract**: memory proportional to what you observe. A seconds-wide + window of changed keys respects that contract asymptotically; a full-table + snapshot breaks it categorically — unbounded in table size, on the mode + whose purpose is not loading the table. The truncate refuses to convert + on-demand into accidental-eager. Eager keeps the no-flash reconcile; + on-demand keeps honesty. + +### D4 — Snapshot reconciliation (and two pre-existing bugs fixed) + +Adversarial review (gpt-5.5) rejected the obvious "insert-if-absent" guard for +snapshots — `snap-end` advances the cursor, so *skipping* a fresher snapshot +value and then dropping the socket loses that write forever. Instead: + +- **C1′ (server)**: `broadcaster.flushOne(ws)` before any synchronous + cursor-advancing emission (`handleSub` snapshot, `emitCatchUp`). C1 said + "deltas flush before `committed`"; C1′ says **a socket's pending coalesced + deltas always precede any cursor boundary on that socket**. This fixes a + pre-existing bug independent of SSR: a multi-collection reconnect's catch-up + `uptodate` could advance the cursor past another collection's still-buffered + delta (drop before the tick ⇒ lost write). +- **`onSnap` writes update-if-exists** (snapshot value wins). With C1′ a + snapshot value is never staler than the held row, so this converges; it also + absorbs `DuplicateKeySyncError` when a subset snapshot lands over hydrated + rows that changed since dehydration. (`loadMore`'s page path keeps its + insert-if-absent: `page` frames never advance the cursor, and a page *can* + be staler than a held row.) +- **Key-reconcile is ALWAYS armed for eager subs** (grill-session + generalization; never for on-demand subset subs, whose snapshot must not + delete other subsets' rows): an eager snapshot is authoritative set + semantics over synced rows, period — at `snap-end`, held synced keys + absent from the snapshot are deleted. For the normal empty-at-first- + snapshot flow it is a no-op (and boundary-free: `begin` opens only when a + delete is due); for ANY path where synced rows precede a snapshot — + hydration with no resume point, a refused foreign-filter cursor, meta that + failed validation — it is what prevents a server-deleted held row from + being stale forever. An EMPTY snapshot still reconciles (zero keys is an + authoritative set — second-review blocker). Honest set semantics without a + truncate's flash-to-empty (SSR exists for first paint). Presence checks + steer by `syncedData`, never the combined view — optimistic overlays are + invisible to sync writes by design. +- **The syncMeta hooks fail loud but SAFE** (grill-session finding): upstream + applies a chunk's rows BEFORE `mergeSyncMeta`/`importSyncMeta` run — a + validation throw cannot veto them, so throwing alone would leave applied + rows with no reconcile intent (and, on-demand, no truncate): stale + forever. Both hooks set `hydratedCursor = "0"` (the always-sound + snapshot-reconcile / truncate route) BEFORE throwing — the version skew + still surfaces to the app, and the state left behind converges. This is + also the gradual-upgrade path: a future `v: 2` payload degrades old + clients safely and loudly; no per-version fallback logic. +- **`onDelta` maps `insert` → `update` when the key exists** — catch-up emits + the latest CDC op per key, so a delete-then-reinsert since the cursor arrives + as `insert` against a held key and would throw. Pre-existing on reconnect + catch-up too; fixed for both. + +### D5 — Packaging + +Peer dependency `@tanstack/db >= 0.8.5`: the syncMeta hooks shipped in 0.8.0 +(unchanged from the draft), and 0.8.5 carries the `commit()`-receipt contract +and descriptor-reuse-by-id this adapter adopts (see Amendments). No +self-branding via `Symbol.for` — the returned config carries upstream's own +`withCollectionConfigFactory` marker, so `collectionOptions(id, () => +doCollectionOptions(...))` descriptors materialize fresh adapter state per +`DbClient`. (The draft era vendored PR-branch tarballs; those are gone — +everything builds against released packages.) Everything lands as +**experimental** in the changelog. + +## Known limitations + +- **No incarnation epoch.** A cursor from a pre-storage-reset DO whose new + changelog already reaches past it would catch up silently-wrong. The exposure + window for SSR is seconds and requires a storage reset inside it; fixing it + properly is a protocol rev (an epoch in `_sync_meta` + a hello/epoch frame), + deliberately deferred. Pre-existing for in-page reconnects too. +- ~~**Upstream is a draft.**~~ Resolved: PR #1564 merged 2026-08-17 and + shipped in `@tanstack/db` 0.8.0 with the three hook signatures **byte-for- + byte unchanged**. The semantics *around* them did change — see Amendments. + +## Consequences + +- SSR first paint with no WebSocket from the render path, no idle timers, no + hibernation impact (the RPC is a plain request). +- The single-cursor inversion survives intact: `since` at first sub is a + bootstrap parameter, `seedCursor` only ever claims a shorter prefix, and + confirmation still rides the one stream. +- C1′ and the `onDelta` normalization harden reconnect for all clients, SSR or + not. + +## Amendments — 2026-08 lift onto merged upstream (`@tanstack/db` 0.8.5) + +The design above was written against the draft PR. The PR merged (0.8.0, +2026-08-17) with the hook names/signatures intact but reshaped semantics +around them, and this repo's `main` moved 0.4.0 → 0.6.0 (ADR-0015..0019) +underneath the branch. The lift changed the following — each a deliberate +decision, not drift: + +- **`exportSyncMeta` can return `undefined`, and the `Transport` seam gains + `hasPosition`.** Merged upstream consults `exportSyncMeta()` for the + *current* meta on **every** hydrated chunk and routes the incoming meta + through `mergeSyncMeta` when current exists (draft: merge only across + chunks). A fresh browser transport exporting `{cursor:"0"}` would win the + MIN-merge against every real dehydrated cursor — silently downgrading all + hydration to the snapshot-reconcile path, making D3's cursor resume dead + code. The claim basis is now: the transport's position if it has one + (`hasPosition`), else the unconsumed `hydratedCursor`, else **no meta at + all**. `"0"` stays a *real* claim exactly where it is one — an SSR read + against a DO with no history (`SsrSnapshotTransport.hasPosition` is true + after any read, even at 0; a live `WebSocketTransport` can never claim 0). +- **`readSyncSnapshot` lives on the `Syncable` mixin as a public method** — + DO RPC dispatches only on public instance members, so this is one + deliberate addition to ADR-0015's four-method collision surface. The gate + is the mixin's configured `parseAttachment` hook (same contract). D1's + hand-rolled ordering note is obsolete: `compileSubsetQuery` now defaults + `ORDER BY rowid` for every subset read (ADR-0015 era). RPC rows also + normalize BLOB `ArrayBuffer → Uint8Array` — structured clone would leak the + bare buffer where the wire codec normalizes it (ADR-0017 parity). +- **D3's forced regress-reconnect is re-derived against ADR-0016.** It is + *voluntary* — not a network failure — so it bypasses the backoff policy + entirely: no attempt consumed, no delay, and a custom `reconnectDelay` + policy cannot declare it terminal. A failed open falls back into the normal + policy-driven retry. The draft-era `suppressAdvance` flag is **replaced by a + socket-identity guard on message dispatch** (only the current socket speaks + for the STREAM): the flag protected the cursor but still let an abandoned + socket's queued frames dispatch data, and its reset-at-install left a race + window. Stream frames (`snap`/`snap-end`/`d`/`uptodate`/`reset`) from a + stale socket are dropped — the resubscribe catch-up re-covers them + idempotently. ID-scoped receipts (`committed`/`rejected`/`page`) are NOT + re-covered by any replay, so a stale socket may still settle those waiters + — it just never advances the cursor (codex adversary: a committed mutation + must not be reported as timed out because a late hydration chunk forced a + reconnect first). Main's own ADR-0016 machinery already carried the + scheduling-time `reconnecting` flag and the stale-close guard this branch + originally invented, so those SSR commits dropped out. +- **New upstream contracts adopted** (released after the draft): `commit()` + receipts (`SyncAppliedReceipt`, 0.8.5) — snapshot terminals and cursor + load-more settle `loadSubset` only once rows are visible; `markError` + (0.8.2) — a failed ready-gate/first-subscribe fails `preload()` loud with + the cause, and a retried `preload()` recovers (error → ready); subset-load + failures reject that subset's promise (0.8.4 `loadSubset:error`), not the + collection; `withCollectionConfigFactory` (0.8.0) as in D5. Not adopted + (documented follow-up): `LoadSubsetOptions.signal` — cooperative + cancellation of a shared refcounted sub needs its own design; loads + complete correctly without it. +- **A second codex adversary round on the lift itself** hardened five more + edges, each pinned by a test: `parseSyncMeta` rejects a NEGATIVE cursor (it + would ride `since` to the server, draw a full snapshot the on-demand + catch-up handler discards, and leak the transient sub forever); + `mergeSyncMeta` with mismatched fingerprints yields the honest `"0"` under + our fingerprint (MIN alone could let the matching side's cursor smuggle + foreign-filter rows past import's check); `exportSyncMeta` is + settlement-gated (while any commit receipt is unsettled it claims the last + fully-settled position — the boundary cursor is not yet proof of applied + rows, and a dehydrate in that window must under-claim); a REJECTED receipt + fails its subset load / readiness rather than resolving it; and the + on-demand ready-gate failure path heals — the catch-up terminal and every + completed subset also `markReady()` (idempotent), so error → ready recovery + actually happens. Plus one pre-existing transport fix: `unsubscribe` during + an in-flight `subscribe`'s connect no longer sends a ghost sub the server + would persist (ADR-0019) with no local consumer. +- **Upstream now natively reconciles hydration-seeded keys** (a later adapter + `insert` of a seeded key is applied as an update — `hydrationSeedKeys`, + 0.8.0). D4's held-key upsert conversion stays: it is belt-and-braces on the + hydration path and load-bearing for the mid-session reconnect catch-up, + which upstream's seed tracking does not cover. +- **Not lifted:** the upstream live-query layer (`preloadLiveQuery`, + `HydrationBoundary`, `@tanstack/react-router-with-db`) needs nothing from + the adapter — dehydrated live-query results ride upstream's own state, and + source-collection hydration is exactly the D3 path. The example app is the + right home for that surface. diff --git a/docs/adr/README.md b/docs/adr/README.md index 112f09d..cd3f6fe 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,6 +18,7 @@ explains the displacement. | [0008](./0008-orphaned-cdc-triggers.md) | Orphaned CDC triggers when a collection is removed | Accepted | | [0009](./0009-changelog-time-retention.md) | Changelog time-based retention; reset stale reconnects | Accepted | | [0010](./0010-typed-mutations-collection-manifest.md) | Typed mutations via a collection-row manifest on `SyncRegistry` | Accepted (manifest superseded by 0014) | +| [0011](./0011-ssr-dehydrate-hydrate.md) | SSR: dehydrate on the worker, hydrate to the cursor | Accepted (experimental; generalizes 0002 C1 → C1′; amended for merged upstream + 0015/0016 lift) | | [0012](./0012-wire-input-hardening.md) | Wire-input hardening: frame-shape guards, inbound limits, sanitized execute errors | Accepted | | [0013](./0013-predicate-floor-one-evaluator.md) | Filtered-subscription membership: one evaluator is the source of truth; the floor is the verified-agreeing set | Accepted | | [0014](./0014-object-sync-schema.md) | `defineSync`: one schema value, mutations on the collection, commands on the connection | Accepted (supersedes 0001 D11 builder; closes 0010 manifest) | diff --git a/examples/ssr/.gitignore b/examples/ssr/.gitignore new file mode 100644 index 0000000..0293926 --- /dev/null +++ b/examples/ssr/.gitignore @@ -0,0 +1 @@ +.tanstack/ diff --git a/examples/ssr/README.md b/examples/ssr/README.md new file mode 100644 index 0000000..89d67e6 --- /dev/null +++ b/examples/ssr/README.md @@ -0,0 +1,93 @@ +# ssr — tanstack-durable-object-sync example + +Server-side rendering end to end (ADR-0011): a TanStack Start app on Cloudflare +Workers reads a `todos` collection from the sync DO **without a WebSocket**, +dehydrates it into the router payload, hydrates in the browser for an instant +first paint, then goes live over the socket and converges — catch-up from the +dehydrated cursor delivers whatever changed while the HTML was in flight. +Stale-while-revalidate, never a flash of empty. + +Built on the **released** TanStack DB SSR API (`@tanstack/db` ≥ 0.8.5, +`@tanstack/react-db` ≥ 0.3.5) and the official +[`@tanstack/react-router-with-db`](https://www.npmjs.com/package/@tanstack/react-router-with-db) +Start adapter — `routerWithDbClient(router, dbClient)` handles DbProvider, +dehydrate/hydrate through the router, and Suspense query streaming. The only +app-specific part is this library's transport seam. + +The example depends on the local package (`"tanstack-durable-object-sync": +"file:../.."`), so build it first: + +## Run + +```sh +npm run build --prefix ../.. # build the library's dist/ (file: dep) +npm install +npm run dev # vite dev with the Cloudflare plugin (runs in workerd) +``` + +Open the printed URL (default http://localhost:5173). + +- `npm run build` — production build (client + worker) +- `npm run typecheck` — `tsc --noEmit` +- `npm run deploy` — build then `wrangler deploy` + +## Pages + +`/` is a plain landing page. + +### `/live-query` — loader preload + `useLiveQuery` + +The baseline SSR round trip. The route loader materializes the collection on +the request's DbClient and calls `preload()` — one snapshot read from the DO +via `readSyncSnapshot`. `routerWithDbClient` dehydrates the normalized rows +**plus the resume cursor** (our opaque `syncMeta`) into the router payload and +hydrates the browser client from it. Rows are in the raw HTML; the status line +flips `ssr → hydrated` on mount and `catching up → live` once the WebSocket +resumes from the dehydrated cursor and converges — updates *and* deletes made +while the HTML was in flight are applied. Adds and toggles are optimistic. +Open a second tab to watch them sync. + +### `/live-suspense-query` — Suspense streaming + `useLiveSuspenseQuery` + +No loader preload: the query is discovered mid-render. On the server the +component suspends while the snapshot transport reads the DO, and +`routerWithDbClient` streams the pending query result into the document — the +streamed shell shows the fallback, then the rows arrive, then the browser's +live WebSocket result replaces the snapshot once sync converges. The +"show only open" toggle changes the structured query IR (a new derived query +identity), which re-suspends until the new query computes. + +## Shape + +One worker serves everything (`src/server.ts`): WebSocket upgrades on `/sync/*` +go straight to the DO; every other request is the Start app via +`@tanstack/react-start/server-entry`. + +- `src/todos-do.ts` — `TodosDO` (`todos` table + insert/update/delete + mutations, `defineSync` object-schema API), seeded with three rows on first + create. `readSyncSnapshot` comes with `SyncDurableObject`. +- `src/lib/todos.ts` — ONE shared collection descriptor: + `collectionOptions("todos", (client) => doCollectionOptions({...}))`. The + factory runs once per DbClient and pulls that environment's transport out of + the client's dependency bag. The descriptor id matches the table name + (`todos`) everywhere — that match routes dehydrated rows back into the + collection on hydrate. +- `src/router.tsx` — the wiring (ADR-0011 D2 seam, released form). `getRouter()` + runs per request on the server and once per tab in the browser; it builds a + `DbClient` carrying the transport dependency — `SsrSnapshotTransport` over + `stub.readSyncSnapshot(req, getRequest())` on the server (the incoming + Request goes through `parseAttachment`, the SAME auth gate as the WS + upgrade), `WebSocketTransport` to `/sync/main` in the browser — and hands + both to `routerWithDbClient`. No manual DbProvider, HydrationBoundary, or + serverFn: the adapter dehydrates the server client into the router stream + and hydrates the browser client from it. +- `src/routes/live-query.tsx`, `src/routes/live-suspense-query.tsx` — the two + consumers. Reads go through the descriptor in `from`; imperative writes + through `useDbClient().collection(todosCollection)`. + +The vite config dedupes `@tanstack/db`: the `file:` link would otherwise +resolve the library's peer import from the repo root's node_modules — a second +physical copy, which breaks the Symbol-branded `collectionOptions`. + +The library's own `tests/ssr-*.test.ts` pin the dehydrate → hydrate → converge +contract; this example is the in-framework showcase. diff --git a/examples/ssr/package-lock.json b/examples/ssr/package-lock.json new file mode 100644 index 0000000..b4e2a11 --- /dev/null +++ b/examples/ssr/package-lock.json @@ -0,0 +1,4591 @@ +{ + "name": "tanstack-do-db-ssr-example", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tanstack-do-db-ssr-example", + "dependencies": { + "@tanstack/db": "^0.8.5", + "@tanstack/react-db": "^0.3.5", + "@tanstack/react-router": "^1.170.0", + "@tanstack/react-router-with-db": "^0.1.0", + "@tanstack/react-start": "^1.168.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tanstack-durable-object-sync": "file:../.." + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.40.0", + "@cloudflare/workers-types": "^5.20260825.1", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.1.0", + "typescript": "^5.9", + "vite": "^7.3.0", + "wrangler": "^4" + } + }, + "../..": { + "version": "0.6.0", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.0.0" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "0.18.7", + "@cloudflare/workers-types": "^4.20260518.1", + "@tanstack/db": "^0.8.5", + "typescript": "^5.7", + "vitest": "4.1.10", + "wrangler": "^4" + }, + "peerDependencies": { + "@tanstack/db": ">=0.8.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/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==", + "license": "MIT", + "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/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/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==", + "license": "MIT", + "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/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/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==", + "license": "MIT", + "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/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vite-plugin": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.54.0.tgz", + "integrity": "sha512-9jQEA7t4QvjsLbdBPwtTquLMFi5XRy16/6CBJ0BbOgWqx3vPWv0gpwD5attTsbbQq1bL52cTKZmZLqgZCPSViQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cloudflare/unenv-preset": "2.16.1", + "miniflare": "5.20260825.0-alpha", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260825.1", + "wrangler": "4.126.0", + "ws": "8.21.0" + }, + "bin": { + "cf-vite": "bin/cf-vite" + }, + "peerDependencies": { + "vite": "^6.1.0 || ^7.0.0 || ^8.0.0", + "wrangler": "^4.126.0" + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260825.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260825.1.tgz", + "integrity": "sha512-oHu38dwaUuzAilyTb0QkQ1YxU/kzqzIQybCvQKAhiK1CGtQS9h0MmjIZYogv3g8cFGGY2k+Wxs0wV9hHK8z78g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260825.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260825.1.tgz", + "integrity": "sha512-ak5zh8YGEjxQQ78bVo7gzU+tcg2fSFxMIjOPZtWk56a/rIYLbGu6ECcliqnYfMUlragg68H0JuVpfdr3BR5Alw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260825.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260825.1.tgz", + "integrity": "sha512-bNQvzz6NemWAwixDRz1fQa5T+E5lS4xpB7A/H/72ULxrjVpHmq8CGFPSbdmRp3dvgBjZTgp7wHdGLISLSVd9Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260825.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260825.1.tgz", + "integrity": "sha512-a5E61YsnNCHHQMnmYsbVXInzeYqFqAMwm/wo16dWW4klXDr6T1bm7u1h5G7ZkxVM7+rtc69oe0yVHjDEqzpYVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260825.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260825.1.tgz", + "integrity": "sha512-EokzVY2suzRSzeURi2HpHnySR5mo6aF5V1klFIqFOZp2YJyXXTI8AvQgYzhlmGTZY3LNL41jjkUQunOM2OErgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260826.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260826.1.tgz", + "integrity": "sha512-hyW0QHvJYKhsMTAx1YZkbhcXBr17Brl1xZwvVExYmB5pAExJCCzAEouKNGwag2H+yBlqLay3X3kZoiJoGu2DRw==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@oozcitak/dom": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz", + "integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==", + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "^2.0.2", + "@oozcitak/url": "^3.0.0", + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/infra": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz", + "integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==", + "license": "MIT", + "dependencies": { + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/url": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz", + "integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==", + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/util": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz", + "integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==", + "license": "MIT", + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", + "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz", + "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz", + "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz", + "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz", + "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz", + "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz", + "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz", + "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz", + "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz", + "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz", + "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz", + "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz", + "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz", + "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz", + "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz", + "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz", + "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz", + "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz", + "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz", + "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz", + "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz", + "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz", + "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz", + "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz", + "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tanstack/db": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@tanstack/db/-/db-0.8.5.tgz", + "integrity": "sha512-0bzsEWb9B0f6ADQjW/W1KCS/f8HBFLPz/Wqq52yqSjmvyKDAEvfOzaKdMXF38j3eR/oigwKj4qmw3Soni+BLNg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@tanstack/db-ivm": "0.1.19", + "@tanstack/pacer-lite": "^0.2.1" + }, + "peerDependencies": { + "typescript": ">=4.7" + } + }, + "node_modules/@tanstack/db-ivm": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@tanstack/db-ivm/-/db-ivm-0.1.19.tgz", + "integrity": "sha512-3VGvgNXAPSVqny/pL8Y6n/txnaZzV49WmgSQ9N5eyCQNgMtuOEOJHKDatqqCy3gDxVu6pkhSHDKxcNFOtxMouw==", + "license": "MIT", + "dependencies": { + "fractional-indexing": "^3.2.0", + "sorted-btree": "^1.8.1" + }, + "peerDependencies": { + "typescript": ">=4.7" + } + }, + "node_modules/@tanstack/history": { + "version": "1.162.1", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.1.tgz", + "integrity": "sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.2.2.tgz", + "integrity": "sha512-eQ1MyLKCHyXiH7NbdmB80W77OhiMgGBUb+qDx/8WMGbwg5Lf/NlfD0TfNYAqY77i8V3AxoDoYdICrQE5ADw4Yw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-db": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-db/-/react-db-0.3.5.tgz", + "integrity": "sha512-O5zjaaZRWNYF+j0S3nidUsp2jSO0ib5MiqH65a72pJ8OAB+sLRMN15GkoY/UEKWJhTAVCjm33yIQ4bb7Oc7h/Q==", + "license": "MIT", + "dependencies": { + "@tanstack/db": "0.8.5", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.32", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.32.tgz", + "integrity": "sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.1", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.171.27", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-router-with-db": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-router-with-db/-/react-router-with-db-0.1.0.tgz", + "integrity": "sha512-yZdsrkW3//J1TZ7m/K8tr8B5K8lQU/2zazhlJ9ywVL+wW9+nsFkhRaeqwGK/LagRMP6rKpQx4jy5QSY4mcFh+g==", + "license": "MIT", + "peerDependencies": { + "@tanstack/react-db": ">=0.2.1", + "@tanstack/react-router": ">=1.43.2", + "@tanstack/router-core": ">=1.127.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@tanstack/react-start": { + "version": "1.168.49", + "resolved": "https://registry.npmjs.org/@tanstack/react-start/-/react-start-1.168.49.tgz", + "integrity": "sha512-iQb1ZoEHqvZMGLR4G7v3tTtgL06yv/bwxvGP3waVHxVn7bRpyopM44YbOluaGkcJzc13ZTvdLKWYNAN3bxMX/Q==", + "license": "MIT", + "dependencies": { + "@tanstack/react-router": "1.170.32", + "@tanstack/react-start-client": "1.168.30", + "@tanstack/react-start-rsc": "0.1.48", + "@tanstack/react-start-server": "1.167.37", + "@tanstack/router-utils": "1.162.2", + "@tanstack/start-client-core": "1.170.27", + "@tanstack/start-plugin-core": "1.171.39", + "@tanstack/start-server-core": "1.169.31", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=22.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": "^2.0.0", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0", + "vite": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@vitejs/plugin-rsc": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-start-client": { + "version": "1.168.30", + "resolved": "https://registry.npmjs.org/@tanstack/react-start-client/-/react-start-client-1.168.30.tgz", + "integrity": "sha512-qsZuykUl1EF0/rc1bin1RtjFzz07YMOTBzhOstSDzbOVm/WKf1QKFTN+qAZi74xlbXSWNuT2MiFRyg4RKwj8iw==", + "license": "MIT", + "dependencies": { + "@tanstack/react-router": "1.170.32", + "@tanstack/router-core": "1.171.27", + "@tanstack/start-client-core": "1.170.27" + }, + "engines": { + "node": ">=22.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-start-rsc": { + "version": "0.1.48", + "resolved": "https://registry.npmjs.org/@tanstack/react-start-rsc/-/react-start-rsc-0.1.48.tgz", + "integrity": "sha512-UglRdTMuF3c4dvzL/gh4dMVbMWHsPy8ZgTQdT2qpTlyt1b/3m+R40tBFdsfTgw76VTXivW+qV/ih0PN9000XTw==", + "license": "MIT", + "dependencies": { + "@tanstack/react-router": "1.170.32", + "@tanstack/router-core": "1.171.27", + "@tanstack/router-utils": "1.162.2", + "@tanstack/start-client-core": "1.170.27", + "@tanstack/start-fn-stubs": "1.162.0", + "@tanstack/start-plugin-core": "1.171.39", + "@tanstack/start-storage-context": "1.167.29", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=22.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rspack/core": ">=2.0.0-0", + "@vitejs/plugin-rsc": ">=0.5.30", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0", + "react-server-dom-rspack": ">=0.0.2" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "@vitejs/plugin-rsc": { + "optional": true + }, + "react-server-dom-rspack": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-start-server": { + "version": "1.167.37", + "resolved": "https://registry.npmjs.org/@tanstack/react-start-server/-/react-start-server-1.167.37.tgz", + "integrity": "sha512-cODHpFU8vIm7AdHii9W3NEuwyruNmT5wLDZjRsJLT9Jp+7FACnfJrRbvxnp1ldSv/9mxcHKi/OgwbdHQeMZcAQ==", + "license": "MIT", + "dependencies": { + "@tanstack/react-router": "1.170.32", + "@tanstack/router-core": "1.171.27", + "@tanstack/start-server-core": "1.169.31" + }, + "engines": { + "node": ">=22.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.27", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.27.tgz", + "integrity": "sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.1", + "cookie-es": "^3.0.0", + "seroval": "^1.6.2", + "seroval-plugins": "^1.6.2" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator": { + "version": "1.167.33", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.167.33.tgz", + "integrity": "sha512-Z3lCWIPuRUMPmuI8Mm48x/s49TxmHOaFVZ52j1W1QKYrsFHyT6U/h9bqfHJDxfQ8kz7y9q+W1YPKZ15Ee7yuCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.27", + "@tanstack/router-utils": "1.162.2", + "@tanstack/virtual-file-routes": "1.162.0", + "jiti": "^2.7.0", + "magic-string": "^0.30.21", + "prettier": "^3.5.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-plugin": { + "version": "1.168.35", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.168.35.tgz", + "integrity": "sha512-foDAZKFqHXae+oFbIgcsSvy2QCVRn7XdS3nhwcRvD+ed6JrKPUP/1lQMsZLJqWycgR1vkZF7gs955KGa0NZQ0w==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.27", + "@tanstack/router-generator": "1.167.33", + "@tanstack/router-utils": "1.162.2", + "chokidar": "^5.0.0", + "unplugin": "^3.0.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": ">=1.0.2 || ^2.0.0", + "@tanstack/react-router": "^1.170.32", + "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", + "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", + "webpack": ">=5.92.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "vite": { + "optional": true + }, + "vite-plugin-solid": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-utils": { + "version": "1.162.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.162.2.tgz", + "integrity": "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "ansis": "^4.1.0", + "babel-dead-code-elimination": "^1.0.12", + "diff": "^8.0.2", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/start-client-core": { + "version": "1.170.27", + "resolved": "https://registry.npmjs.org/@tanstack/start-client-core/-/start-client-core-1.170.27.tgz", + "integrity": "sha512-Ro6ZSM0NgYKDMxM0e8qyU4mBfnld2Zb74BA/9f4i35C0Y3IAA8Zxs/DIPfOo9VRYWp5c5n5Q8dRBn2qn0vtPhQ==", + "license": "MIT", + "dependencies": { + "@tanstack/router-core": "1.171.27", + "@tanstack/start-fn-stubs": "1.162.0", + "@tanstack/start-storage-context": "1.167.29", + "seroval": "^1.6.2" + }, + "engines": { + "node": ">=22.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/start-fn-stubs": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/start-fn-stubs/-/start-fn-stubs-1.162.0.tgz", + "integrity": "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/start-plugin-core": { + "version": "1.171.39", + "resolved": "https://registry.npmjs.org/@tanstack/start-plugin-core/-/start-plugin-core-1.171.39.tgz", + "integrity": "sha512-Zyj6G4MDFLXcHYhPavhewCBo8dsxi3qvPk31zl/QtTzYzOY9rJHDDBGarKsJq20ziChmkGn/sttYqb4vGCxJDA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "7.27.1", + "@babel/core": "^7.28.5", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.27", + "@tanstack/router-generator": "1.167.33", + "@tanstack/router-plugin": "1.168.35", + "@tanstack/router-utils": "1.162.2", + "@tanstack/start-server-core": "1.169.31", + "exsolve": "^1.0.7", + "lightningcss": "^1.32.0", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "seroval": "^1.6.2", + "source-map": "^0.7.6", + "srvx": "^0.11.9", + "tinyglobby": "^0.2.15", + "ufo": "^1.5.4", + "vitefu": "^1.1.1", + "xmlbuilder2": "^4.0.3", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=22.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": "^2.0.0", + "vite": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@tanstack/start-server-core": { + "version": "1.169.31", + "resolved": "https://registry.npmjs.org/@tanstack/start-server-core/-/start-server-core-1.169.31.tgz", + "integrity": "sha512-56w8l+Fao01YCrmv0hzNxL3b3FRmqLRGdE11izZNQsW+1CcSYuehAM5khWKABuI1DI/UIBLGV7BwL4Dlg0eHCw==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.1", + "@tanstack/router-core": "1.171.27", + "@tanstack/start-client-core": "1.170.27", + "@tanstack/start-storage-context": "1.167.29", + "fetchdts": "^0.1.6", + "h3-v2": "npm:h3@2.0.1-rc.20", + "seroval": "^1.6.2" + }, + "engines": { + "node": ">=22.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/start-storage-context": { + "version": "1.167.29", + "resolved": "https://registry.npmjs.org/@tanstack/start-storage-context/-/start-storage-context-1.167.29.tgz", + "integrity": "sha512-8qfprC5774XMRDQlMogkfiGpFLiBf0xDG4bMFUbfkSzpCAQwpLbgGY4Zwft22O9rKYq2vUXusKAdVjvJTUEquQ==", + "license": "MIT", + "dependencies": { + "@tanstack/router-core": "1.171.27" + }, + "engines": { + "node": ">=22.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-file-routes": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.162.0.tgz", + "integrity": "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ansis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", + "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.415", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", + "integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==", + "license": "ISC" + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "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==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetchdts": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/fetchdts/-/fetchdts-0.1.7.tgz", + "integrity": "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==", + "license": "MIT" + }, + "node_modules/fractional-indexing": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.4.0.tgz", + "integrity": "sha512-8J3glhz2rrpKG6KmI7wmJo3zH1VjeOpN+vTJSw1fOyO+Viqq3zX6/5NGh6oaZB2qIAYdOYuu5Dz9xp4faOO0Pg==", + "license": "CC0-1.0", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/h3-v2": { + "name": "h3", + "version": "2.0.1-rc.20", + "resolved": "https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz", + "integrity": "sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==", + "license": "MIT", + "dependencies": { + "rou3": "^0.8.1", + "srvx": "^0.11.13" + }, + "bin": { + "h3": "bin/h3.mjs" + }, + "engines": { + "node": ">=20.11.1" + }, + "peerDependencies": { + "crossws": "^0.4.1" + }, + "peerDependenciesMeta": { + "crossws": { + "optional": true + } + } + }, + "node_modules/isbot": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.1.tgz", + "integrity": "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "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" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "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" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "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" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "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" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/miniflare": { + "version": "5.20260825.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260825.0-alpha.tgz", + "integrity": "sha512-ZwlF6LuX43ilx9EwMRDKHenoGXiNdcKSyGx5aPaJhuozujVZasb2lRR7t3ojJZSnVzwDPsBBYCu8lLnc+/KIgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260825.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "devOptional": 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", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz", + "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.0", + "@rollup/rollup-android-arm64": "4.63.0", + "@rollup/rollup-darwin-arm64": "4.63.0", + "@rollup/rollup-darwin-x64": "4.63.0", + "@rollup/rollup-freebsd-arm64": "4.63.0", + "@rollup/rollup-freebsd-x64": "4.63.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", + "@rollup/rollup-linux-arm-musleabihf": "4.63.0", + "@rollup/rollup-linux-arm64-gnu": "4.63.0", + "@rollup/rollup-linux-arm64-musl": "4.63.0", + "@rollup/rollup-linux-loong64-gnu": "4.63.0", + "@rollup/rollup-linux-loong64-musl": "4.63.0", + "@rollup/rollup-linux-ppc64-gnu": "4.63.0", + "@rollup/rollup-linux-ppc64-musl": "4.63.0", + "@rollup/rollup-linux-riscv64-gnu": "4.63.0", + "@rollup/rollup-linux-riscv64-musl": "4.63.0", + "@rollup/rollup-linux-s390x-gnu": "4.63.0", + "@rollup/rollup-linux-x64-gnu": "4.63.0", + "@rollup/rollup-linux-x64-musl": "4.63.0", + "@rollup/rollup-openbsd-x64": "4.63.0", + "@rollup/rollup-openharmony-arm64": "4.63.0", + "@rollup/rollup-win32-arm64-msvc": "4.63.0", + "@rollup/rollup-win32-ia32-msvc": "4.63.0", + "@rollup/rollup-win32-x64-gnu": "4.63.0", + "@rollup/rollup-win32-x64-msvc": "4.63.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rou3": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.8.1.tgz", + "integrity": "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.4.tgz", + "integrity": "sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.6.4.tgz", + "integrity": "sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/sharp/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/sorted-btree": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sorted-btree/-/sorted-btree-1.8.1.tgz", + "integrity": "sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/srvx": { + "version": "0.11.22", + "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.22.tgz", + "integrity": "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ==", + "license": "MIT", + "bin": { + "srvx": "bin/srvx.mjs" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tanstack-durable-object-sync": { + "resolved": "../..", + "link": true + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/unplugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/workerd": { + "version": "1.20260825.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260825.1.tgz", + "integrity": "sha512-ccS6TEaaRxgONKawiYGnFYUVGfr2pmN1b7mrNtw0ADVhFaYsEIVoCHnQ4UjhM9EJDzuaNgAWFY82nzVrjWpuOA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260825.1", + "@cloudflare/workerd-darwin-arm64": "1.20260825.1", + "@cloudflare/workerd-linux-64": "1.20260825.1", + "@cloudflare/workerd-linux-arm64": "1.20260825.1", + "@cloudflare/workerd-windows-64": "1.20260825.1" + } + }, + "node_modules/wrangler": { + "version": "4.126.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.126.0.tgz", + "integrity": "sha512-glDq/nxaeQwue0XMIeupfwlu2jVxnFs+wjDFT71DQuURN5LsVdCwu0Af/1ep10U5xr1rKPGhHDEDkKG9nxE/dg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260825.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260825.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260825.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "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/xmlbuilder2": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz", + "integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==", + "license": "MIT", + "dependencies": { + "@oozcitak/dom": "^2.0.2", + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0", + "js-yaml": "^4.1.1" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/examples/ssr/package.json b/examples/ssr/package.json new file mode 100644 index 0000000..f21d89f --- /dev/null +++ b/examples/ssr/package.json @@ -0,0 +1,32 @@ +{ + "name": "tanstack-do-db-ssr-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "deploy": "npm run build && wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/db": "^0.8.5", + "@tanstack/react-db": "^0.3.5", + "@tanstack/react-router": "^1.170.0", + "@tanstack/react-router-with-db": "^0.1.0", + "@tanstack/react-start": "^1.168.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tanstack-durable-object-sync": "file:../.." + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.40.0", + "@cloudflare/workers-types": "^5.20260825.1", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.1.0", + "typescript": "^5.9", + "vite": "^7.3.0", + "wrangler": "^4" + } +} diff --git a/examples/ssr/src/lib/todos.ts b/examples/ssr/src/lib/todos.ts new file mode 100644 index 0000000..dd10fc4 --- /dev/null +++ b/examples/ssr/src/lib/todos.ts @@ -0,0 +1,32 @@ +// One collection descriptor, two transports (ADR-0011 D2): the server render +// reads a snapshot from the DO, the browser goes live over WebSocket. The +// descriptor is shared — `collectionOptions(id, factory)` gives every DbClient +// a fresh adapter config, and the factory pulls the environment's transport +// out of the client's dependency bag (injected in router.tsx). The id matches +// the table name ("todos") on every side — that match is what routes the +// dehydrated rows back into this collection on hydrate. + +import { collectionOptions } from "@tanstack/db" +import { doCollectionOptions } from "tanstack-durable-object-sync/client" +import type { Transport } from "tanstack-durable-object-sync/client" +import type { TodosApi } from "../todos-do.ts" + +export interface Todo { + id: string + text: string + /** SQLite INTEGER 0/1 — kept raw so optimistic and confirmed rows are identical. */ + done: number +} + +/** DbClient dependency key: `() => Transport` — snapshot transport on + * the server, WebSocket transport in the browser (see router.tsx). */ +export const TODOS_TRANSPORT = "todosTransport" + +export const todosCollection = collectionOptions("todos", (client) => { + const createTransport = client.requireDependency<() => Transport>(TODOS_TRANSPORT) + return doCollectionOptions({ + transport: createTransport(), + table: "todos", + getKey: (t) => t.id, + }) +}) diff --git a/examples/ssr/src/routeTree.gen.ts b/examples/ssr/src/routeTree.gen.ts new file mode 100644 index 0000000..7a4b181 --- /dev/null +++ b/examples/ssr/src/routeTree.gen.ts @@ -0,0 +1,104 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as LiveQueryRouteImport } from './routes/live-query' +import { Route as LiveSuspenseQueryRouteImport } from './routes/live-suspense-query' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const LiveQueryRoute = LiveQueryRouteImport.update({ + id: '/live-query', + path: '/live-query', + getParentRoute: () => rootRouteImport, +} as any) +const LiveSuspenseQueryRoute = LiveSuspenseQueryRouteImport.update({ + id: '/live-suspense-query', + path: '/live-suspense-query', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/live-query': typeof LiveQueryRoute + '/live-suspense-query': typeof LiveSuspenseQueryRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/live-query': typeof LiveQueryRoute + '/live-suspense-query': typeof LiveSuspenseQueryRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/live-query': typeof LiveQueryRoute + '/live-suspense-query': typeof LiveSuspenseQueryRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/live-query' | '/live-suspense-query' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/live-query' | '/live-suspense-query' + id: '__root__' | '/' | '/live-query' | '/live-suspense-query' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + LiveQueryRoute: typeof LiveQueryRoute + LiveSuspenseQueryRoute: typeof LiveSuspenseQueryRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/live-query': { + id: '/live-query' + path: '/live-query' + fullPath: '/live-query' + preLoaderRoute: typeof LiveQueryRouteImport + parentRoute: typeof rootRouteImport + } + '/live-suspense-query': { + id: '/live-suspense-query' + path: '/live-suspense-query' + fullPath: '/live-suspense-query' + preLoaderRoute: typeof LiveSuspenseQueryRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + LiveQueryRoute: LiveQueryRoute, + LiveSuspenseQueryRoute: LiveSuspenseQueryRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/examples/ssr/src/router.tsx b/examples/ssr/src/router.tsx new file mode 100644 index 0000000..43a9d9e --- /dev/null +++ b/examples/ssr/src/router.tsx @@ -0,0 +1,65 @@ +// The SSR seam (ADR-0011 D2), wired the released way: one DbClient per server +// request / per browser tab, handed to the router via context and +// `routerWithDbClient` — which wraps the app in DbProvider, dehydrates the +// server client into the router payload (rows + our resume cursor as opaque +// syncMeta), hydrates the browser client from it, and streams late Suspense +// query results. The only app-specific part is the transport dependency: a +// snapshot read against the DO on the server, a WebSocket in the browser. + +import { DbClient } from "@tanstack/react-db" +import { createRouter } from "@tanstack/react-router" +import { routerWithDbClient } from "@tanstack/react-router-with-db" +import { createIsomorphicFn } from "@tanstack/react-start" +import { SsrSnapshotTransport, WebSocketTransport } from "tanstack-durable-object-sync/client" +import type { SnapshotRead, Transport } from "tanstack-durable-object-sync/client" +import { TODOS_TRANSPORT } from "./lib/todos.ts" +import { routeTree } from "./routeTree.gen" +import type { Env, TodosApi } from "./todos-do.ts" + +export type RouterContext = { + dbClient: DbClient +} + +const createTransport = createIsomorphicFn() + .server( + (): (() => Transport) => + () => + // No WebSocket from the render path: each subscribe is ONE snapshot + // read over DO RPC. The worker-only modules are imported lazily so + // they never reach the browser bundle. + new SsrSnapshotTransport({ + read: async (req) => { + const [{ env }, { getRequest }] = await Promise.all([ + import("cloudflare:workers"), + import("@tanstack/react-start/server"), + ]) + const ns = (env as unknown as Env).TODOS_DO + const stub = ns.get(ns.idFromName("main")) as unknown as { + readSyncSnapshot: (r: Parameters[0], request: Request) => ReturnType + } + // The DO runs the incoming request through parseAttachment — the + // SAME auth gate the WS upgrade gets. This app has no auth, but the + // shape means an app that does can't bypass its own check via SSR. + return stub.readSyncSnapshot(req, getRequest()) + }, + }), + ) + .client( + (): (() => Transport) => + () => + new WebSocketTransport({ + url: `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/sync/main`, + }), + ) + +export function getRouter() { + // Per request on the server, once per tab in the browser. The collection + // factory (lib/todos.ts) pulls the matching transport out of this bag. + const dbClient = new DbClient({ [TODOS_TRANSPORT]: createTransport() }) + const router = createRouter({ + routeTree, + context: { dbClient }, + scrollRestoration: true, + }) + return routerWithDbClient(router, dbClient) +} diff --git a/examples/ssr/src/routes/__root.tsx b/examples/ssr/src/routes/__root.tsx new file mode 100644 index 0000000..0f5b41b --- /dev/null +++ b/examples/ssr/src/routes/__root.tsx @@ -0,0 +1,51 @@ +import { createRootRouteWithContext, HeadContent, Link, Outlet, Scripts } from "@tanstack/react-router" +import * as React from "react" +import type { RouterContext } from "../router.tsx" + +export const Route = createRootRouteWithContext()({ + head: () => ({ + meta: [ + { charSet: "utf-8" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + { title: "tanstack-durable-object-sync SSR todos" }, + ], + }), + shellComponent: RootDocument, + component: RootLayout, +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + + ) +} + +function RootLayout() { + return ( + <> + + + + ) +} diff --git a/examples/ssr/src/routes/index.tsx b/examples/ssr/src/routes/index.tsx new file mode 100644 index 0000000..a786634 --- /dev/null +++ b/examples/ssr/src/routes/index.tsx @@ -0,0 +1,30 @@ +// Landing page only — no DB here. The DbClient is app-scoped (router context, +// see router.tsx), so each showcase page exercises its own SSR path while +// client-side navigation between them shares one collection and one socket. + +import { createFileRoute, Link } from "@tanstack/react-router" + +export const Route = createFileRoute("/")({ + component: Landing, +}) + +function Landing() { + return ( +
+

tanstack-durable-object-sync SSR showcase

+

+ One todos collection in a Durable Object, server-rendered two ways. +

+
    +
  • + useLiveQuery — loader preload, dehydrate, + hydrate, converge live; rows are in the raw HTML. +
  • +
  • + useLiveSuspenseQuery — the query + suspends during the server render and its result is streamed. +
  • +
+
+ ) +} diff --git a/examples/ssr/src/routes/live-query.tsx b/examples/ssr/src/routes/live-query.tsx new file mode 100644 index 0000000..8ab81d5 --- /dev/null +++ b/examples/ssr/src/routes/live-query.tsx @@ -0,0 +1,87 @@ +// The loader-preload SSR path: the route loader materializes the todos +// collection on the request's DbClient and preloads it (one snapshot read from +// the DO). routerWithDbClient dehydrates the normalized rows PLUS the resume +// cursor (opaque syncMeta) into the router payload and hydrates the browser +// client from it — data is present from the first (server) render, then the +// WebSocket resumes from the dehydrated cursor and converges: catch-up applies +// exactly what changed while the HTML was in flight, updates AND deletes. +// Stale-while-revalidate, never a flash of empty. + +import { useDbClient, useLiveQuery } from "@tanstack/react-db" +import { createFileRoute } from "@tanstack/react-router" +import * as React from "react" +import { todosCollection } from "../lib/todos.ts" + +export const Route = createFileRoute("/live-query")({ + loader: async ({ context }) => { + // Explicit collection preload → normalized rows + syncMeta dehydrate. + // On client-side navigation this same line just awaits the live sync. + await context.dbClient.collection(todosCollection).preload() + }, + component: LiveQueryPage, +}) + +function LiveQueryPage() { + // Materialize through the ambient DbClient for imperative writes; the same + // descriptor in `from` resolves to the same collection instance. + const todos = useDbClient().collection(todosCollection) + const [hydrated, setHydrated] = React.useState(false) + const [text, setText] = React.useState("") + const { data, isReady } = useLiveQuery({ + query: (q) => q.from({ t: todosCollection }).orderBy(({ t }) => t.id, "asc"), + }) + + React.useEffect(() => setHydrated(true), []) + + const add = () => { + const t = text.trim() + if (!t) return + // Optimistic: appears instantly, confirmed on the single stream. + todos.insert({ id: crypto.randomUUID(), text: t, done: 0 }) + setText("") + } + + return ( +
+

useLiveQuery todos

+

+ {hydrated ? "hydrated" : "ssr"} + {" · "} + {isReady ? "live" : "catching up"} + {" · "} + rows: {data.length} +

+
    + {data.map((t) => ( +
  • + +
  • + ))} +
+
{ + e.preventDefault() + add() + }} + style={{ display: "flex", gap: 8 }} + > + setText(e.target.value)} + placeholder="new todo…" + style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }} + /> + +
+
+ ) +} diff --git a/examples/ssr/src/routes/live-suspense-query.tsx b/examples/ssr/src/routes/live-suspense-query.tsx new file mode 100644 index 0000000..1b108b4 --- /dev/null +++ b/examples/ssr/src/routes/live-suspense-query.tsx @@ -0,0 +1,80 @@ +// The Suspense-streaming SSR path: NO loader preload. The query is discovered +// mid-render by useLiveSuspenseQuery; on the server the component suspends +// while the snapshot transport reads the DO, and routerWithDbClient streams +// the pending query result into the document (the browser shows the fallback +// from the streamed shell, then the streamed rows, then the live WebSocket +// result once sync converges). Toggling the filter changes the structured +// query IR — a new query identity, a new derived collection — which re-suspends +// in the browser until it computes. + +import { eq } from "@tanstack/db" +import { useDbClient, useLiveSuspenseQuery } from "@tanstack/react-db" +import { createFileRoute } from "@tanstack/react-router" +import * as React from "react" +import { todosCollection } from "../lib/todos.ts" + +export const Route = createFileRoute("/live-suspense-query")({ + component: SuspensePage, +}) + +function SuspensePage() { + const [hydrated, setHydrated] = React.useState(false) + const [openOnly, setOpenOnly] = React.useState(false) + + React.useEffect(() => setHydrated(true), []) + + return ( +
+

useLiveSuspenseQuery todos

+

+ {hydrated ? "hydrated" : "ssr"} +

+ + loading todos…

}> + +
+
+ ) +} + +function TodoRows({ openOnly }: { openOnly: boolean }) { + const todos = useDbClient().collection(todosCollection) + // Config-object form: the derived query identity is the structured IR, so + // flipping `openOnly` swaps in a new live query rather than reusing rows. + const { data } = useLiveSuspenseQuery({ + query: (q) => { + const base = q.from({ t: todosCollection }) + const scoped = openOnly ? base.where(({ t }) => eq(t.done, 0)) : base + return scoped.orderBy(({ t }) => t.id, "asc") + }, + }) + + return ( + <> +

+ rows: {data.length} +

+
    + {data.map((t) => ( +
  • + +
  • + ))} +
+ + ) +} diff --git a/examples/ssr/src/server.ts b/examples/ssr/src/server.ts new file mode 100644 index 0000000..e753c74 --- /dev/null +++ b/examples/ssr/src/server.ts @@ -0,0 +1,22 @@ +// Custom worker entry (wrangler `main`): ONE worker serves both halves — +// WebSocket upgrades on /sync/* go straight to the DO, everything else is the +// TanStack Start app (SSR + assets). The Start handler never sees the upgrade, +// so hibernation stays intact. + +import handler from "@tanstack/react-start/server-entry" +import type { Env } from "./todos-do.ts" + +export { TodosDO } from "./todos-do.ts" + +export default { + fetch(req: Request, env: Env, ctx: ExecutionContext): Response | Promise { + const url = new URL(req.url) + if (url.pathname.startsWith("/sync/")) { + const room = url.pathname.slice("/sync/".length) || "main" + return env.TODOS_DO.get(env.TODOS_DO.idFromName(room)).fetch(req) + } + // Start's RequestHandler takes (request, opts?) — env/ctx reach server + // code through the `cloudflare:workers` module, not positional args. + return handler.fetch(req) + }, +} satisfies ExportedHandler diff --git a/examples/ssr/src/todos-do.ts b/examples/ssr/src/todos-do.ts new file mode 100644 index 0000000..1098f74 --- /dev/null +++ b/examples/ssr/src/todos-do.ts @@ -0,0 +1,72 @@ +// SSR example — the sync DO. One `todos` collection plus the three row +// mutations the browser client sends, authored with the object-schema API +// (defineSync; ADR-0014). Depends on the local package (`file:../..`), so run +// `npm run build` at the repo root before installing here. + +import { defineSync, SyncDurableObject } from "tanstack-durable-object-sync" +import type { Todo } from "./lib/todos.ts" + +export interface Env { + TODOS_DO: DurableObjectNamespace +} + +const UPDATABLE = new Set(["text", "done"]) + +const sync = defineSync() + +const todosSchema = sync.schema({ + collections: { + todos: sync.collection({ + pk: "id", + mutations: { + insert: { + execute: ({ op, sql }) => { + sql.exec("INSERT INTO todos(id, text, done) VALUES (?, ?, ?)", op.cols.id, op.cols.text, op.cols.done) + }, + }, + update: { + // A toggle/edit sends a getChanges() diff; build the SET from the + // present keys, allowing only the updatable columns. + execute: ({ op, sql }) => { + const cols = op.cols as Record + const keys = Object.keys(cols).filter((k) => UPDATABLE.has(k)) + if (keys.length === 0) return + const set = keys.map((k) => `"${k}" = ?`).join(", ") + sql.exec(`UPDATE todos SET ${set} WHERE id = ?`, ...keys.map((k) => cols[k]), op.key) + }, + }, + delete: { + execute: ({ op, sql }) => { + sql.exec("DELETE FROM todos WHERE id = ?", op.key) + }, + }, + }, + }), + }, +}) + +/** The schema's type — the client brands its transports with this so + * `doCollectionOptions` infers the `todos` row type (no runtime schema value). */ +export type TodosApi = typeof todosSchema + +export class TodosDO extends SyncDurableObject { + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env) + ctx.blockConcurrencyWhile(async () => { + // You own your schema (ADR-0007); the framework wires sync after. + this.sql.exec(`CREATE TABLE IF NOT EXISTS todos ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + done INTEGER NOT NULL DEFAULT 0 + )`) + this.registerSync(todosSchema) + // Seed AFTER registerSync so the rows flow through CDC and the first + // render gets a real (nonzero) resume cursor. Direct SQL is fine here: + // boot precedes any socket, so there is nothing to broadcast (ADR-0006). + this.sql.exec(`INSERT OR IGNORE INTO todos(id, text, done) VALUES + ('seed-1', 'Server-render this list', 1), + ('seed-2', 'Hydrate without a flash of empty', 0), + ('seed-3', 'Converge live over WebSocket', 0)`) + }) + } +} diff --git a/examples/ssr/tsconfig.json b/examples/ssr/tsconfig.json new file mode 100644 index 0000000..8f59103 --- /dev/null +++ b/examples/ssr/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "types": ["@cloudflare/workers-types", "vite/client"] + }, + "include": ["src/**/*", "vite.config.ts"] +} diff --git a/examples/ssr/vite.config.ts b/examples/ssr/vite.config.ts new file mode 100644 index 0000000..86ae58e --- /dev/null +++ b/examples/ssr/vite.config.ts @@ -0,0 +1,16 @@ +import { cloudflare } from "@cloudflare/vite-plugin" +import { tanstackStart } from "@tanstack/react-start/plugin/vite" +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +export default defineConfig({ + resolve: { + // `tanstack-durable-object-sync` is a file: link to the repo root, whose + // own `@tanstack/db` peer import would resolve from the ROOT node_modules + // — a second physical copy. Two copies break the Symbol-branded + // collectionOptions and every instanceof across the boundary. Dedupe + // forces one copy: this example's. + dedupe: ["@tanstack/db"], + }, + plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }), tanstackStart(), react()], +}) diff --git a/examples/ssr/wrangler.jsonc b/examples/ssr/wrangler.jsonc new file mode 100644 index 0000000..934d9f1 --- /dev/null +++ b/examples/ssr/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "name": "tanstack-do-db-ssr", + "main": "src/server.ts", + "compatibility_date": "2026-03-10", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [{ "name": "TODOS_DO", "class_name": "TodosDO" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["TodosDO"] }] +} diff --git a/package-lock.json b/package-lock.json index 6c96809..3703974 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "devDependencies": { "@cloudflare/vitest-pool-workers": "0.18.7", "@cloudflare/workers-types": "^4.20260518.1", - "@tanstack/db": "0.6.5", + "@tanstack/db": "^0.8.5", "typescript": "^5.7", "vitest": "4.1.10", "wrangler": "^4" @@ -2064,14 +2064,14 @@ "license": "MIT" }, "node_modules/@tanstack/db": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@tanstack/db/-/db-0.6.5.tgz", - "integrity": "sha512-gtCuAo4UtC9SR/kTMu5fVEff6qZ2R1FZi9X7MybtHKA6wve7RePifGG6qBI4OmMB+7juT5/+glNbnqZOrG0/pg==", + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@tanstack/db/-/db-0.8.5.tgz", + "integrity": "sha512-0bzsEWb9B0f6ADQjW/W1KCS/f8HBFLPz/Wqq52yqSjmvyKDAEvfOzaKdMXF38j3eR/oigwKj4qmw3Soni+BLNg==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", - "@tanstack/db-ivm": "0.1.18", + "@tanstack/db-ivm": "0.1.19", "@tanstack/pacer-lite": "^0.2.1" }, "peerDependencies": { @@ -2079,9 +2079,9 @@ } }, "node_modules/@tanstack/db-ivm": { - "version": "0.1.18", - "resolved": "https://registry.npmjs.org/@tanstack/db-ivm/-/db-ivm-0.1.18.tgz", - "integrity": "sha512-+pZJiRKdoKRM5Epq9T7otD9ZJl82pRFauo7LKuJGrarjVKQ7r+QQlPe3kGdN9LEKSnuNGIWjX9OOY4M8kH4eLw==", + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@tanstack/db-ivm/-/db-ivm-0.1.19.tgz", + "integrity": "sha512-3VGvgNXAPSVqny/pL8Y6n/txnaZzV49WmgSQ9N5eyCQNgMtuOEOJHKDatqqCy3gDxVu6pkhSHDKxcNFOtxMouw==", "dev": true, "license": "MIT", "dependencies": { @@ -2418,9 +2418,9 @@ } }, "node_modules/fractional-indexing": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", - "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.4.0.tgz", + "integrity": "sha512-8J3glhz2rrpKG6KmI7wmJo3zH1VjeOpN+vTJSw1fOyO+Viqq3zX6/5NGh6oaZB2qIAYdOYuu5Dz9xp4faOO0Pg==", "dev": true, "license": "CC0-1.0", "engines": { diff --git a/package.json b/package.json index efc1b48..5d0359c 100644 --- a/package.json +++ b/package.json @@ -56,12 +56,12 @@ "@msgpack/msgpack": "^3.0.0" }, "peerDependencies": { - "@tanstack/db": ">=0.6.0" + "@tanstack/db": ">=0.8.5" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "0.18.7", "@cloudflare/workers-types": "^4.20260518.1", - "@tanstack/db": "0.6.5", + "@tanstack/db": "^0.8.5", "typescript": "^5.7", "vitest": "4.1.10", "wrangler": "^4" diff --git a/src/client/do-collection.ts b/src/client/do-collection.ts index d6b8fd9..b964630 100644 --- a/src/client/do-collection.ts +++ b/src/client/do-collection.ts @@ -15,9 +15,15 @@ // loaded subset are confirmed and their optimistic overlay retired by a // post-mutation empty sync commit (ADR-0002 C2, verified). -import { compileSingleRowExpression, toBooleanPredicate, type CollectionConfig } from "@tanstack/db" +import { + compileSingleRowExpression, + toBooleanPredicate, + withCollectionConfigFactory, + type CollectionConfig, +} from "@tanstack/db" +import { encode as codecEncode } from "../wire/codec.ts" import type { MutOp, RowOp } from "../wire/frames.ts" -import { type SubHandler, WebSocketTransport } from "./transport.ts" +import type { SubHandler, Transport } from "./transport.ts" let subSeq = 0 @@ -49,15 +55,47 @@ interface PendingMutationLike { changes: unknown } +/** `true` = applied synchronously; a Promise settles when the rows are visible + * (@tanstack/db 0.8.5's SyncAppliedReceipt — loadSubset must await it). */ +type CommitReceipt = true | Promise + interface SyncParams { collection: { get: (key: string) => unknown } begin: (options?: { immediate?: boolean }) => void write: (message: { type: RowOp; value?: unknown; key?: string }) => void - commit: () => void + commit: (signal?: AbortSignal) => CommitReceipt markReady: () => void + /** Fails the collection's readiness promises with the cause (@tanstack/db + * 0.8.2); recovery is a retried preload() once sync later succeeds. + * Optional only for bare test harnesses — real @tanstack/db provides it. */ + markError?: (error?: unknown) => void truncate: () => void } +/** The opaque payload that rides TanStack's dehydrated state (ADR-0011 D3). + * Shape is ours; `v` gates forward evolution loudly. `where` fingerprints the + * eager filter the rows were dehydrated under: a cursor is only a sound + * resume point FOR THAT FILTER (catch-up emits changed keys only — an + * unchanged out-of-filter hydrated row would never be reconciled away). */ +export interface DoSyncMeta { + v: 1 + cursor: string + where?: string +} + +function parseSyncMeta(meta: unknown): DoSyncMeta { + const m = meta as Partial | null + if (m == null || m.v !== 1 || typeof m.cursor !== "string" || (m.where !== undefined && typeof m.where !== "string")) { + throw new Error(`unrecognized sync meta (expected {v:1, cursor, where?}): ${JSON.stringify(meta)}`) + } + // Malformed throws; NEGATIVE is rejected too (codex review): seedCursor + // ignores it but `since:"-1"` would reach the server, which answers a + // full snapshot the on-demand catch-up handler discards — no terminal, the + // transient sub never tears down, and stale hydrated rows survive forever. + if (BigInt(m.cursor) < 0n) throw new Error(`negative sync-meta cursor: ${m.cursor}`) + return { v: 1, cursor: m.cursor, ...(m.where === undefined ? {} : { where: m.where }) } +} + /** Subset of @tanstack/db's LoadSubsetOptions we consume. */ interface LoadSubsetOptions { where?: unknown @@ -78,8 +116,11 @@ function compilePredicate(where: unknown): (row: Record) => boo /** Api-typed options: Row is inferred from the schema `Api` + `table`, so the * client needs no runtime schema value. `getKey` and the row type follow. */ export interface DoApiCollectionOptions> { - /** One transport per DO, parameterized by the same schema `Api`. */ - transport: WebSocketTransport + /** One transport per DO, parameterized by the same schema `Api`. In the + * browser a `WebSocketTransport`; during SSR an + * `SsrSnapshotTransport` — created PER REQUEST (ADR-0011 D2). Both + * satisfy the structural `Transport`. */ + transport: Transport /** Collection (table) name on the DO — a key of the schema's collections. */ table: K /** Stable client-supplied key extractor (must match the server pk). */ @@ -99,11 +140,17 @@ export interface DoApiCollectionOptions> { // ) // // Explicit type args are optional (`doCollectionOptions(...)`). +// +// The returned config carries @tanstack/db's collection-config factory +// (`withCollectionConfigFactory`), so `collectionOptions("id", () => +// doCollectionOptions({...}))` descriptors materialize with FRESH adapter +// state per DbClient. The transport is the caller's: per-request SSR clients +// must construct a per-request transport inside their own descriptor factory. export function doCollectionOptions>( opts: DoApiCollectionOptions, ): CollectionConfig & object, string> export function doCollectionOptions(opts: { - transport: WebSocketTransport + transport: Transport table: string getKey: (row: any) => string id?: string @@ -118,8 +165,44 @@ export function doCollectionOptions(opts: { // Set by sync(); used by mutationFn to retire no-subset-match optimistic rows. let emptyCommit: (() => void) | null = null + // SSR hydration's resume point (ADR-0011 D3). Set by importSyncMeta — which + // upstream calls AFTER applying the dehydrated rows as synced upserts, and + // possibly BEFORE sync() ever runs (lazy collections). Consumed exactly once + // at sync start and cleared in cleanup: after a collection GC the rows are + // wiped, so a retained cursor would resume over an empty store and silently + // lose everything below it. + let hydratedCursor: string | null = null + + // Settlement gate for the syncMeta claim (codex review): the transport's + // cursor advances at commit BOUNDARIES, but a commit's SyncAppliedReceipt + // may settle later (application queued behind a persisting user + // transaction). Exporting the boundary cursor in that window would dehydrate + // pre-boundary rows under meta claiming the boundary — a resume that skips + // the gap forever. While any receipt is unsettled, exportSyncMeta claims the + // last fully-settled position instead (under-claiming is always safe: MIN + // semantics, idempotent replay). + let pendingReceipts = 0 + let settledCursor = "0" + const sync = (params: SyncParams): SyncConfigResult => { - const { collection, begin, write, commit, markReady, truncate } = params + const { collection, begin, write, commit, markReady, markError, truncate } = params + const consumeHydratedCursor = (): string | null => { + const hc = hydratedCursor + hydratedCursor = null + return hc + } + // Presence in SYNCED data — the combined view (collection.get) includes + // optimistic overlays, which sync writes must never be steered by: a key + // under an optimistic delete still exists synced (insert would throw), and + // an optimistic-only insert does not (update would not upsert the synced + // store the hydration correction targets). `_state.syncedData` is the same + // seam upstream's DbClient hydration itself writes through. + const syncedData = (): Map | null => + (collection as { _state?: { syncedData?: Map } })._state?.syncedData ?? null + const syncedHas = (key: string): boolean => { + const sd = syncedData() + return sd ? sd.has(key) : collection.get(key) !== undefined + } let open = false const ensureBegin = (): void => { if (!open) { @@ -127,57 +210,200 @@ export function doCollectionOptions(opts: { open = true } } - const flush = (): void => { - if (open) { - commit() - open = false + /** Book a commit's receipt against the settlement gate (see + * pendingReceipts). Returns the ORIGINAL receipt so callers still await + * and propagate rejection; the passive branch also keeps a fire-and- + * forget flush from surfacing as an unhandled rejection. */ + const track = (receipt: CommitReceipt): CommitReceipt => { + const r = receipt as { then?: (a: () => void, b: () => void) => unknown } + if (r != null && typeof r.then === "function") { + pendingReceipts++ + const settle = (): void => { + pendingReceipts-- + // Everything booked has applied: the transport's position is a + // sound claim again from here. + if (pendingReceipts === 0) settledCursor = transport.appliedCursor + } + void r.then(settle, settle) + } else if (pendingReceipts === 0) { + settledCursor = transport.appliedCursor } + return receipt + } + /** Commit the open transaction; the receipt settles when rows are visible + * (`true` = already are). The 0.8.5 loadSubset contract chains on it. */ + const flush = (): CommitReceipt => { + if (!open) return true + open = false + return track(commit()) + } + /** Run `onApplied` once `receipt` says the rows are visible; a REJECTED + * receipt (the application was aborted, 0.8.5) is a failure, not + * success — it must not resolve a subset load or readiness (codex + * review). Thenable-sniffed — anything non-promise (incl. `true`, and + * bare harness mocks returning void) means "already applied". */ + const afterApplied = (receipt: CommitReceipt, onApplied: () => void, onFail: (e: unknown) => void): void => { + const r = receipt as { then?: (a: () => void, b: (e: unknown) => void) => unknown } + if (r != null && typeof r.then === "function") void r.then(onApplied, onFail) + else onApplied() } emptyCommit = (): void => { flush() begin() - commit() // a standalone empty boundary; runs the direct-upsert clear path + track(commit()) // a standalone empty boundary; runs the direct-upsert clear path } - const makeHandler = (onReady: () => void): SubHandler => ({ - onSnap: (_key, row) => { - ensureBegin() - write({ type: "insert", value: row }) - }, - onSnapEnd: () => { - flush() - onReady() - }, - onDelta: (op, key, cols) => { - ensureBegin() - if (op === "delete") write({ type: "delete", key: key as string }) - // A catch-up emits the LATEST op per changed key, so a key deleted-and- - // reinserted while we were away arrives as "insert" for a key we still - // HOLD — TanStack's sync write throws DuplicateKeySyncError on that - // unless values deep-equal. Apply a held-key insert as the upsert it - // semantically is (update upserts; the move-in contract, ADR-0002 C4). - else if (op === "insert" && collection.get(key as string) !== undefined) write({ type: "update", value: cols }) - else write({ type: op, value: cols }) - }, - onUptodate: () => flush(), - onReset: () => { - flush() - begin() - truncate() - commit() - // A reset is also the only terminal signal for a REJECTED sub (the - // server sends `reset` with no `snap-end` for an unsupported predicate - // or unknown collection). Mark ready here too, or this subset's load - // promise — and the live query's preload() — would hang forever. For a - // compaction/rotation reset (a valid sub that re-snapshots) this is an - // idempotent no-op: onSnapEnd's onReady() has already fired. - onReady() - }, - }) + const makeHandler = ( + onReady: () => void, + opts?: { reconcileSnapshots?: boolean; onFail?: (e: unknown) => void }, + ): SubHandler => { + // Where a rejected receipt lands: a subset load rejects ITS promise; the + // collection-level default fails readiness loud (a later markReady — + // any successful snapshot — recovers, error → ready). + const onFail = opts?.onFail ?? ((e: unknown): void => markError?.(e)) + // `reconcileSnapshots` (armed for every EAGER sub, never for on-demand + // subset subs — a subset snapshot must not delete other subsets' rows): + // a snapshot is authoritative SET semantics over the synced rows — + // held keys absent from it were deleted server-side, and snapshots + // carry no tombstones (ADR-0011 D4). Track each snapshot's keys and + // delete the rest at ITS boundary; no truncate, so a hydrated first + // paint never flashes empty. The set is per-snapshot (reset at every + // snap-end), and an EMPTY snapshot (zero snap frames — the server + // wiped the table) still reconciles everything away at the boundary. + let snapKeys: Set | null = null + return { + onSnap: (_key, row) => { + ensureBegin() + const key = getKey(row as Record) + if (opts?.reconcileSnapshots) (snapKeys ??= new Set()).add(key) + // A held key's snapshot row is an upsert: hydrated rows may have + // changed since dehydration, and a differing insert would throw + // DuplicateKeySyncError. With the C1′ barrier a snapshot row is + // never staler than the held synced row, so the snapshot wins. + write(syncedHas(key) ? { type: "update", value: row } : { type: "insert", value: row }) + }, + onSnapEnd: () => { + if (opts?.reconcileSnapshots) { + const seen = snapKeys // null ⇒ empty snapshot ⇒ empty authoritative set + snapKeys = null + const sd = syncedData() + if (!sd) throw new Error("snapshot reconcile requires collection._state.syncedData (incompatible @tanstack/db)") + for (const key of sd.keys()) { + // ensureBegin only when a delete is actually due — the common + // converged/empty case stays boundary-free. + if (!seen?.has(key)) { + ensureBegin() + write({ type: "delete", key }) + } + } + } + afterApplied(flush(), onReady, onFail) + }, + onDelta: (op, key, cols) => { + ensureBegin() + if (op === "delete") write({ type: "delete", key: key as string }) + // A catch-up emits the LATEST op per changed key, so a key deleted- + // and-reinserted while we were away arrives as "insert" for a key we + // still HOLD — TanStack's sync write throws DuplicateKeySyncError on + // that unless values deep-equal. Apply a held-key insert as the + // upsert it semantically is (update upserts; move-in, ADR-0002 C4). + else if (op === "insert" && syncedHas(key as string)) write({ type: "update", value: cols }) + else write({ type: op, value: cols }) + }, + onUptodate: () => flush(), + onReset: () => { + flush() + begin() + truncate() + // A reset is also the only terminal signal for a REJECTED sub (the + // server sends `reset` with no `snap-end` for an unsupported predicate + // or unknown collection). Mark ready here too, or this subset's load + // promise — and the live query's preload() — would hang forever. For a + // compaction/rotation reset (a valid sub that re-snapshots) this is an + // idempotent no-op: onSnapEnd's onReady() has already fired. + afterApplied(track(commit()), onReady, onFail) + }, + } + } if (syncMode === "on-demand") { - // Ready as soon as connected; data arrives per loadSubset. - void transport.connect().then(() => markReady()) + // Hydration catch-up (ADR-0011 D3): the dehydrated rows are the union of + // whatever subsets the server render loaded — per-subset resume is + // unsound (a subset the render didn't cover has no since to resume + // from, and overlapping predicates leave stale-delete holes). ONE + // transient unfiltered sub from the dehydrated cursor covers every + // changed key (always-emit ⇒ synthetic deletes included) in the + // render→hydrate window, then unsubscribes at ITS terminal — never at a + // broadcast boundary, which can precede its own frames. Semantic cost + // (documented): rows outside any loaded subset that changed in the + // window land in the collection. + // + // With NO resume point ("0"), or when the server resets the catch-up + // (below the retention floor), the hydrated rows are honestly + // UNRESUMABLE: truncate. In on-demand a full snapshot would strand + // never-subscribed whole-table rows as permanently-stale state — worse + // than a one-roundtrip refetch of the live subsets. The reset path + // unsubscribes IMMEDIATELY so the server's trailing unfiltered + // resnapshot is dropped on the floor (no handler), and the subset subs + // repopulate right after. + // + // markReady gates on the catch-up sub FRAME being sent (not completed): + // loadSubset subs only fire after ready, so on the single ordered + // socket the catch-up's truncate/deltas always precede subset + // snapshots. Ready never waits for data — stale-while-revalidate. + const hc = consumeHydratedCursor() + let readyGate: Promise + if (hc !== null && hc !== "0") { + const catchupId = `${table}#hydrate#${++subSeq}` + const done = (): void => transport.unsubscribe(catchupId) + readyGate = transport.subscribe( + catchupId, + table, + { + onSnap: () => {}, // catch-ups never snapshot; reset's resnapshot is dropped (unsubbed) + onSnapEnd: () => {}, + onDelta: makeHandler(() => {}).onDelta, + onUptodate: (ownTerminal) => { + flush() + if (ownTerminal) { + done() + // Also heals an earlier failed gate (error → ready, 0.8.2): + // the readyGate rejected, the policy-driven reconnect + // resubscribed this catch-up, and its terminal is the first + // proof the collection is usable again (codex review — + // idempotent when the gate already marked ready). + markReady() + } + }, + onReset: () => { + flush() + begin() + truncate() + track(commit()) + done() // before the trailing resnapshot frames arrive + markReady() // same healing as the terminal path + }, + }, + undefined, + undefined, + undefined, + hc, + ) + } else if (hc === "0") { + // No resume point: drop the hydrated rows at sync start, honestly. + readyGate = transport.connect().then(() => { + begin() + truncate() + track(commit()) + }) + } else { + readyGate = transport.connect() + } + // A failed gate fails readiness loud (markError; preload() rejects with + // the cause) instead of hanging — a later retried preload() recovers + // once the transport's policy-driven reconnect succeeds (0.8.2). + void readyGate.then(markReady, (e) => markError?.(e)) + // Distinct `where` -> one refcounted server subscription. const loaded = new Map }>() const keyOf = (o: LoadSubsetOptions): string => JSON.stringify(o.where ?? null) @@ -214,7 +440,9 @@ export function doCollectionOptions(opts: { for (const r of page) { if (collection.get(getKey(r)) === undefined) write({ type: "insert", value: r }) } - flush() + // 0.8.5 contract: a subset load settles only once its rows are visible. + const receipt = flush() + if (receipt !== true) await receipt } const loadSubset = (o: LoadSubsetOptions): true | Promise => { @@ -226,15 +454,28 @@ export function doCollectionOptions(opts: { return existing.ready } let resolve!: () => void - const ready = new Promise((r) => { - resolve = r + let reject!: (e: unknown) => void + const ready = new Promise((res, rej) => { + resolve = res + reject = rej }) const subId = `${table}#${key}` loaded.set(key, { subId, refs: 1, ready }) // Forward orderBy/limit so the INITIAL snapshot is the bounded window // (recent N), not the whole where-subset. The live sub's predicate is // still `where`, so entering rows (e.g. new messages) are delivered. - void transport.subscribe(subId, table, makeHandler(resolve), o.where, o.orderBy, o.limit) + // A send failure or rejected receipt rejects THIS load (0.8.4 surfaces + // it per-subscription as loadSubset:error), not the whole collection. + // A completed subset also (re)marks ready — the recovery path out of a + // failed ready-gate's error state (idempotent otherwise). + const handler = makeHandler( + () => { + resolve() + markReady() + }, + { onFail: reject }, + ) + void transport.subscribe(subId, table, handler, o.where, o.orderBy, o.limit).catch(reject) return ready } @@ -252,12 +493,49 @@ export function doCollectionOptions(opts: { } } - return { loadSubset, unloadSubset, cleanup: () => transport.close() } + return { + loadSubset, + unloadSubset, + cleanup: () => { + hydratedCursor = null // GC wiped the rows; a retained cursor would lie + transport.close() + }, + } } - // eager - void transport.subscribe(eagerSubId, table, makeHandler(markReady), where) - return () => transport.unsubscribe(eagerSubId) + // eager — reconcile is ALWAYS armed: an eager snapshot is authoritative + // set semantics over synced rows, period (ADR-0011 D4). For the normal + // empty-at-first-snapshot flow it is a no-op; for ANY path where synced + // rows precede a snapshot — hydration with no resume point, hydration + // whose meta failed validation (rows land before importSyncMeta; no + // veto), futures we haven't imagined — it is what prevents a + // server-deleted held row from being stale forever. C1′ makes it sound + // mid-session too: a held synced key absent from a snapshot is deleted. + { + const hc = consumeHydratedCursor() + const handler = makeHandler(markReady, { reconcileSnapshots: true }) + if (hc !== null) { + // Hydrated (ADR-0011 D3): the rows were applied upstream as synced + // upserts before we ran. Resume from the dehydrated cursor (server + // catch-up; below the floor an honest reset + resnapshot) — or, with + // no resume point ("0"), take a fresh snapshot and reconcile it. + // Ready NOW: stale-while-revalidate is the explicit SSR contract — + // first paint renders the hydrated rows, the boundary converges them. + void transport + .subscribe(eagerSubId, table, handler, where, undefined, undefined, hc === "0" ? undefined : hc) + .catch((e) => markError?.(e)) + markReady() + } else { + // A first-connect failure fails readiness loud (preload() rejects); + // the policy-driven reconnect keeps retrying and the eventual + // snapshot's markReady recovers the collection (error → ready, 0.8.2). + void transport.subscribe(eagerSubId, table, handler, where).catch((e) => markError?.(e)) + } + } + return () => { + hydratedCursor = null // GC wiped the rows; a retained cursor would lie + transport.unsubscribe(eagerSubId) + } } const mutationFn = async (params: { @@ -294,15 +572,108 @@ export function doCollectionOptions(opts: { } } - return { + // SSR syncMeta hooks (ADR-0011 D3) — called by TanStack's DbClient + // dehydrate/hydrate (@tanstack/db ≥0.8.0, PR #1564 as merged). The eager + // `where` fingerprint is the codec envelope — stable for the same + // constructor code; a cross-deploy false mismatch merely downgrades to the + // (always-sound) snapshot-reconcile path. + const whereFingerprint = where == null ? undefined : codecEncode(where) + // Merged-upstream contract (client.ts applyRows): on every hydrated chunk, + // upstream asks exportSyncMeta() for the CURRENT meta and — when it exists — + // routes the incoming meta through mergeSyncMeta. A fresh browser-side + // adapter must therefore export UNDEFINED, not {cursor:"0"}: it holds no + // claim, and a "0" claim would win the MIN-merge against every real + // dehydrated cursor, silently downgrading all hydration to the + // snapshot-reconcile path. "0" stays a REAL claim ("no resume point" — the + // honest-truncate route); no-claim is the absence of meta. On the server the + // SSR transport's reads establish the position this exports. + const exportSyncMeta = (): DoSyncMeta | undefined => { + // While a commit's receipt is unsettled the boundary cursor is not yet a + // sound claim — export the last fully-settled position instead (see + // pendingReceipts). Under-claiming is always safe. + const live = pendingReceipts === 0 ? transport.appliedCursor : settledCursor + const cursor = transport.hasPosition ? live : hydratedCursor + if (cursor === null) return undefined + return { + v: 1, + cursor, + ...(whereFingerprint === undefined ? {} : { where: whereFingerprint }), + } + } + const importSyncMeta = (meta: unknown): void => { + // Upstream applies the dehydrated rows BEFORE this runs — there is no + // veto. So a validation failure must fail loud AND fail safe: the rows + // are in syncedData regardless, and silently skipping our bookkeeping + // would start sync down the no-resume path with no reconcile intent — + // a server-deleted hydrated row would then be stale forever. Set the + // safe state ("0" → snapshot + reconcile) FIRST, then throw so the + // version/corruption skew still surfaces to the app. + let m: DoSyncMeta + try { + m = parseSyncMeta(meta) + } catch (e) { + hydratedCursor = "0" + throw e + } + if (m.where === whereFingerprint) { + hydratedCursor = m.cursor + transport.seedCursor(m.cursor) + } else { + // The rows were dehydrated under a DIFFERENT eager filter: the cursor + // is not a sound resume point for ours (see DoSyncMeta). "0" routes the + // sync start to snapshot + reconcile; the transport cursor stays + // unseeded so a bootstrap-window reconnect resnapshots too. + hydratedCursor = "0" + } + } + const mergeSyncMeta = (current: unknown, incoming: unknown): DoSyncMeta => { + // Same fail-loud-but-SAFE contract as importSyncMeta: upstream calls + // merge (then import) AFTER applying the chunk's rows, so a parse throw + // here also can't veto anything — and upstream never reaches + // importSyncMeta when merge throws, which would skip the safety net. + let a: DoSyncMeta + let b: DoSyncMeta + try { + a = parseSyncMeta(current) + b = parseSyncMeta(incoming) + } catch (e) { + hydratedCursor = "0" + throw e + } + // Fingerprint skew between the two sides means SOME chunk's rows were + // dehydrated under a filter that is not ours — and they were APPLIED (no + // veto). MIN would let the matching side's cursor survive the merge and + // sail through import's fingerprint check, leaving the foreign rows with + // no catch-up that covers them (codex review). No sound joint resume + // point exists: return the honest "0" (snapshot-reconcile / on-demand + // truncate route) under OUR fingerprint so import routes it there. + if (a.where !== b.where) { + return { v: 1, cursor: "0", ...(whereFingerprint === undefined ? {} : { where: whereFingerprint }) } + } + // MIN is self-healing: a late chunk's rows were already applied over + // newer state (no veto); resuming from the EARLIER position replays the + // window idempotently and re-freshens whatever the chunk clobbered. + return BigInt(a.cursor) <= BigInt(b.cursor) ? a : b + } + + const options = { id: opts.id ?? table, getKey, syncMode, - sync: { sync, rowUpdateMode: "partial" }, + sync: { sync, rowUpdateMode: "partial", exportSyncMeta, importSyncMeta, mergeSyncMeta }, onInsert: mutationFn, onUpdate: mutationFn, onDelete: mutationFn, - } as unknown as CollectionConfig + } + // Descriptor opt-in (@tanstack/db ≥0.8.0): a `collectionOptions("id", …)` + // descriptor over this config materializes per DbClient through this + // factory, giving each client FRESH adapter state (hydratedCursor, subIds). + // The transport is deliberately not recreated — it is the caller's; SSR + // callers construct a per-request transport in their own factory closure. + return withCollectionConfigFactory( + options as never, + () => doCollectionOptions(opts as Parameters[0]) as never, + ) as unknown as CollectionConfig } /** What our sync() returns: a cleanup fn (eager) or the on-demand handlers. */ diff --git a/src/client/index.ts b/src/client/index.ts index c23ac67..ea0af12 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -13,6 +13,11 @@ export { MutationRejectedError, WebSocketTransport, } from "./transport.ts" -export type { ReconnectDelayFn, SubHandler, TransportOptions, WebSocketLike } from "./transport.ts" +export type { ReconnectDelayFn, SubHandler, Transport, TransportOptions, WebSocketLike } from "./transport.ts" export { doCollectionOptions, WriteOutsideSubError } from "./do-collection.ts" -export type { CollectionName, DoApiCollectionOptions, RowOf } from "./do-collection.ts" +export type { CollectionName, DoApiCollectionOptions, DoSyncMeta, RowOf } from "./do-collection.ts" +// SSR (@tanstack/db ≥0.8.0 DbClient dehydrate/hydrate; ADR-0011). Create one +// SsrSnapshotTransport PER REQUEST and pass `(req) => stub.readSyncSnapshot(req, request)` +// — the same claims-bearing Request the WS upgrade gets (one auth gate, both paths). +export { SsrReadOnlyError, SsrSnapshotTransport } from "./ssr-transport.ts" +export type { SnapshotRead } from "./ssr-transport.ts" diff --git a/src/client/ssr-transport.ts b/src/client/ssr-transport.ts new file mode 100644 index 0000000..f4deb7e --- /dev/null +++ b/src/client/ssr-transport.ts @@ -0,0 +1,113 @@ +// SsrSnapshotTransport — the server-rendering half of ADR-0011 D2. +// +// Implements the same structural `Transport` the WebSocket transport does, so +// `doCollectionOptions` runs unchanged inside a per-request DbClient on the +// worker: each subscribe is ONE snapshot read (rows + durable cursor), synthesized +// as onSnap*/onSnapEnd. No socket, no timers, no live deltas — render, dehydrate, +// throw away. +// +// The reader is injected as a plain function so this file carries no Cloudflare +// types; the author passes `(req) => stub.readSyncSnapshot(req, request)` (the +// SyncDurableObject RPC), closing over the same claims-bearing Request the WS +// upgrade gets — parseAttachment is the ONE auth gate for both paths. +// +// SSR is read-only: mutations during render are a design error, not a queue — +// they throw. Create one transport (and one options object) PER REQUEST; a +// module-scope instance would leak cursor state across requests (the upstream +// hooks offer no per-instance identity — see ADR-0011 "Context"). + +import { decode, encode } from "../wire/codec.ts" +import type { ClientFrame } from "../wire/frames.ts" +import type { SubHandler, Transport } from "./transport.ts" + +/** TanStack's expression IR arrives as class instances (Func/Ref/Value), which + * structured clone — and therefore DO RPC — rejects. The wire tagged-value + * codec already flattens them to plain data preserving bigint/Date/±Inf, so a + * round-trip gives the reader a clone-safe request (same shape the WS frames + * carry). */ +function plain(v: unknown): unknown { + return v === undefined ? undefined : decode(encode(v)) +} + +export class SsrReadOnlyError extends Error { + constructor(operation: string) { + super( + `${operation} during SSR: the snapshot transport is read-only. ` + + `Mutations belong on the live client after hydration.`, + ) + this.name = "SsrReadOnlyError" + } +} + +/** One snapshot read — rows plus the durable high-water cursor at one position. */ +export type SnapshotRead = (req: { + collection: string + where?: unknown + orderBy?: unknown + limit?: number +}) => Promise<{ rows: Array>; cursor: string }> + +export class SsrSnapshotTransport implements Transport { + /** phantom — carries `Api` so `doCollectionOptions` infers the collection set + * from the SSR transport exactly as it does from `WebSocketTransport`. */ + declare readonly __api?: Api + /** MIN across reads — multiple subsets read at different positions can only + * safely resume from the EARLIEST one (replay is idempotent; skipping is + * not). Null until the first read. */ + private cursor: bigint | null = null + + constructor(private readonly opts: { read: SnapshotRead }) {} + + /** Lowest position across this render's reads (stringified bigint). */ + get appliedCursor(): string { + return String(this.cursor ?? 0n) + } + + /** True after ANY read — a read that returned cursor 0 (a DO with no + * history) is a REAL "no resume point" claim and must dehydrate as such; + * only a transport that never read holds no claim. */ + get hasPosition(): boolean { + return this.cursor !== null + } + + async connect(): Promise { + // Nothing to open — but resolving lets on-demand mode markReady as usual. + } + + async subscribe( + _subId: string, + collection: string, + handler: SubHandler, + where?: unknown, + orderBy?: unknown, + limit?: number, + _since?: string, + ): Promise { + const { rows, cursor } = await this.opts.read({ collection, where: plain(where), orderBy: plain(orderBy), limit }) + const c = BigInt(cursor) + this.cursor = this.cursor === null || c < this.cursor ? c : this.cursor + // Key is derived by the adapter via getKey(row); snap keys are advisory. + for (const row of rows) handler.onSnap(undefined, row) + handler.onSnapEnd() + } + + unsubscribe(): void { + // One-shot reads hold nothing to release. + } + + sendMut(_frame: Extract): Promise<{ result?: unknown }> { + return Promise.reject(new SsrReadOnlyError("mutation")) + } + + fetch(_frame: Extract): Promise> { + return Promise.reject(new SsrReadOnlyError("cursor fetch")) + } + + seedCursor(): void { + // Hydrating INTO a render is meaningless — reads define this cursor. + } + + close(): void { + // Nothing held. + } +} diff --git a/src/client/transport.ts b/src/client/transport.ts index 6ae64e2..efe70a1 100644 --- a/src/client/transport.ts +++ b/src/client/transport.ts @@ -31,10 +31,48 @@ export interface SubHandler { onSnap(key: unknown, row: unknown): void onSnapEnd(): void onDelta(op: RowOp, key: unknown, cols: Record | undefined): void - onUptodate(): void + /** `ownTerminal` is true only for a sub-scoped boundary addressed to THIS + * subscription (a catch-up's terminal, ADR-0011 D3) — a transient + * subscription may tear itself down on it, but never on a broadcast + * boundary, which can precede its own catch-up frames. */ + onUptodate(ownTerminal?: boolean): void onReset(): void } +/** The transport surface `doCollectionOptions` consumes — structural, so the + * WebSocket transport and the SSR snapshot transport are interchangeable + * (ADR-0011 D2). Generic + branded on `Api` so row/table typing survives the + * seam: `WebSocketTransport` and `SsrSnapshotTransport` both satisfy + * it, and `doCollectionOptions` still infers the collection set from `Api`. */ +export interface Transport { + /** phantom — carries `Api` through the structural interface so inference at + * `doCollectionOptions` recovers it (never `unknown`). */ + readonly __api?: Api + connect(): Promise + subscribe( + subId: string, + collection: string, + handler: SubHandler, + where?: unknown, + orderBy?: unknown, + limit?: number, + since?: string, + ): Promise + unsubscribe(subId: string): void + sendMut(frame: Extract): Promise<{ result?: unknown }> + fetch(frame: Extract): Promise> + close(): void + readonly appliedCursor: string + /** True once this transport has established a stream position of its own — + * even position 0 (an SSR read against a DO with no history is a REAL + * claim: "no resume point", the honest-truncate route). False means no + * claim at all (a fresh browser transport) — exportSyncMeta then exports + * nothing rather than a spurious "0" that would win a MIN-merge against + * every real dehydrated cursor (ADR-0011 D3, merged-upstream semantics). */ + readonly hasPosition: boolean + seedCursor(cursor: string): void +} + /** Cloudflare's inbound WebSocket edge cap, ~1 MiB (ADR-0018). An * infrastructure FACT, not an application preference: both wire endpoints * ship in this package and the only supported infra fixes the number, so it @@ -192,6 +230,67 @@ export class WebSocketTransport { return String(this.appliedSeq) } + /** A live transport's position exists once anything has advanced (or seeded) + * the cursor — it can never claim position 0 (seedCursor("0") is a no-op), + * so 0 here honestly means "no claim yet". */ + get hasPosition(): boolean { + return this.appliedSeq !== 0n + } + + /** + * Claim a cursor position on behalf of externally-applied state — SSR + * hydration (ADR-0011 D3). The hydrated rows ARE the stream's prefix up to + * the dehydrated cursor, so claiming it keeps a bootstrap-window reconnect + * from re-snapshotting over them (a fresh snapshot carries no tombstones, so + * a row deleted server-side meanwhile would never be removed). + * + * The claim only ever SHRINKS relative to live progress: claiming a shorter + * applied prefix is always safe; claiming a longer one without data never + * is. A seed below the current position (a late streamed chunk — upstream + * has already applied its possibly-stale rows; there is no veto) regresses + * the cursor and resubscribes, so the catch-up replay re-freshens exactly + * the clobbered window. Replay is idempotent: latest-op-per-key, applied as + * upserts/deletes. + */ + seedCursor(cursor: string): void { + const c = BigInt(cursor) // malformed cursor throws — fail loud, never guess + if (c <= 0n) return // "0" honestly means: no resume point to claim + if (c >= this.appliedSeq && this.appliedSeq !== 0n) return // never grow the claim + const wasLive = this.appliedSeq !== 0n && this.ws !== null + this.appliedSeq = c + if (wasLive && this.handlers.size > 0) { + // A live regress cannot replay on the SAME socket: boundary frames the + // server already sent (full duplex) would dispatch after the regress + // and re-advance the cursor past the repair window — then a drop + // resumes beyond it and the late chunk's clobbered rows stay stale + // forever. Force a reconnect instead: the old socket stops speaking for + // the stream (message dispatch is identity-guarded), and the FRESH + // socket resubscribes from the seed — clean ordering, replay guaranteed. + this.forceReconnect() + } + } + + /** Abandon the current socket and reconnect NOW. A cursor-regress reconnect + * is voluntary — not a network failure — so it bypasses the backoff policy + * (no attempt consumed, no delay, and a custom policy cannot declare it + * terminal) but still runs the resubscribe path. A failed open falls back + * into the normal policy-driven retry via connect()'s failure path. */ + private forceReconnect(): void { + const old = this.ws + this.ws = null // the identity guards now ignore the old socket entirely + this.connectPromise = null + try { + old?.close() + } catch { + /* already dead; the reconnect proceeds regardless */ + } + this.reconnecting = true + this.clearReconnectTimer() + void this.connect().catch(() => { + /* retries route through connect()'s failure path (policy-driven) */ + }) + } + async connect(): Promise { if (this.ws) return if (this.connectPromise) return this.connectPromise @@ -216,7 +315,20 @@ export class WebSocketTransport { } catch { /* some socket impls don't expose binaryType; codec handles AB/Uint8Array */ } - ws.addEventListener("message", (ev) => this.onMessage(ev.data)) + ws.addEventListener("message", (ev) => { + // Only the CURRENT socket speaks for the STREAM. An abandoned socket + // (forceReconnect regress, a superseded reconnect) can still deliver + // queued frames — applying its stream frames, or worse advancing the + // cursor on them, would claim positions the fresh socket's replay is + // about to own (ADR-0011 D3). Dropped stream frames are re-covered by + // the resubscribe catch-up from the applied cursor, idempotently. + // ID-scoped receipts (`committed`/`rejected`/`page`) are NOT + // re-covered by any replay, so a stale socket may still settle those + // waiters — it just never advances the cursor (codex review: a + // committed mutation must not be reported as timed out because a late + // hydration chunk forced a reconnect first). + this.onMessage(ev.data, this.ws !== ws) + }) ws.addEventListener("close", (ev) => { // Only the CURRENT socket's close may detach/reconnect. A stale // socket's late close (delivered after close()+connect() installed a @@ -343,10 +455,18 @@ export class WebSocketTransport { where?: unknown, orderBy?: unknown, limit?: number, + /** Resume point for the FIRST sub — SSR hydration's dehydrated cursor + * (ADR-0011 D3). One-shot: reconnects resume from `appliedCursor`. */ + since?: string, ): Promise { this.handlers.set(subId, { handler, collection, where, orderBy, limit }) await this.connect() - this.sendFrame({ t: "sub", subId, collection, where, orderBy, limit }) + // Unsubscribed while the connect was in flight (its `unsub` had no socket + // to ride): sending now would register a ghost subscription the server + // persists (ADR-0019) with no local handler — dead weight against the + // sub cap until the socket drops (codex review). + if (!this.handlers.has(subId)) return + this.sendFrame({ t: "sub", subId, collection, where, orderBy, limit, since }) } unsubscribe(subId: string): void { @@ -459,7 +579,7 @@ export class WebSocketTransport { this.ws.send(data) } - private onMessage(data: unknown): void { + private onMessage(data: unknown, stale = false): void { let frame: ServerFrame try { frame = this.codec.decode(data as ArrayBuffer | string) as ServerFrame @@ -468,17 +588,24 @@ export class WebSocketTransport { } switch (frame.t) { case "snap": + if (stale) return this.handlers.get(frame.sub)?.handler.onSnap(frame.key, frame.row) return case "snap-end": + if (stale) return this.handlers.get(frame.sub)?.handler.onSnapEnd() this.advance(frame.seq) return case "d": + if (stale) return this.handlers.get(frame.sub)?.handler.onDelta(frame.op, frame.key, frame.cols) return case "uptodate": - for (const { handler } of this.handlers.values()) handler.onUptodate() + if (stale) return + // A sub-scoped terminal (a catch-up's) goes to its handler alone; a + // broadcast boundary (coalescer tick / barrier flush) goes to all. + if (frame.sub) this.handlers.get(frame.sub)?.handler.onUptodate(true) + else for (const { handler } of this.handlers.values()) handler.onUptodate(false) this.advance(frame.seq) return case "committed": { @@ -488,7 +615,7 @@ export class WebSocketTransport { this.pendingTx.delete(frame.txId) w.resolve({ result: frame.result }) } - this.advance(frame.seq) + if (!stale) this.advance(frame.seq) return } case "rejected": { @@ -510,6 +637,7 @@ export class WebSocketTransport { return } case "reset": + if (stale) return if (frame.sub) this.handlers.get(frame.sub)?.handler.onReset() else for (const { handler } of this.handlers.values()) handler.onReset() return diff --git a/src/server/changes.ts b/src/server/changes.ts index 74dc8f4..4b2c8a5 100644 --- a/src/server/changes.ts +++ b/src/server/changes.ts @@ -226,6 +226,17 @@ export function currentSeq(sql: SqlStorage): number { return Number(rows[0]?.s ?? 0) } +/** + * Durable high-water mark — the latest position the stream has reached, robust + * to retention pruning the changelog empty (`currentSeq` alone reads 0 then, + * which would hand SSR a bogus "no history" cursor for live rows; ADR-0011 D1). + * The drain cursor lives in `_sync_meta` and survives pruning; an undrained + * tail is covered by the MAX over the log itself. + */ +export function highWaterSeq(sql: SqlStorage): number { + return Math.max(currentSeq(sql), getDrainCursor(sql)) +} + /** Lowest `seq` still in the log — the retention floor for reconnect catch-up. */ export function minChangeSeq(sql: SqlStorage): number { const rows = Array.from( diff --git a/src/server/mixin.ts b/src/server/mixin.ts index 6aaaed7..3c9a463 100644 --- a/src/server/mixin.ts +++ b/src/server/mixin.ts @@ -28,6 +28,7 @@ import { currentSeq, ensureTriggers, getDrainCursor, + highWaterSeq, hydrateRows, initSchema, minChangeSeq, @@ -109,10 +110,27 @@ export interface SyncApi { drainAndBroadcast(): void } +/** One consistent snapshot read for SSR (ADR-0011 D1): the request shape and + * result of `readSyncSnapshot`, callable over the DO binding as plain RPC. */ +export interface SyncSnapshotReq { + collection: string + where?: unknown + orderBy?: unknown + limit?: number +} +export interface SyncSnapshotRes { + rows: Array> + cursor: string +} + /** The surface the mixin adds to `Base`. The four methods are real, runtime- - * dispatched overrides so workerd finds them on the prototype. */ + * dispatched overrides so workerd finds them on the prototype; + * `readSyncSnapshot` is public (not behind the `sync` facade) because DO RPC + * dispatches only on public instance members — one deliberate addition to the + * host-collision surface (ADR-0011 D1, amending ADR-0015's four-method rule). */ export interface SyncMixin { readonly sync: SyncApi + readSyncSnapshot(req: SyncSnapshotReq, request: Request): Promise fetch(request: Request): Promise webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise @@ -335,6 +353,52 @@ export function Syncable() { // ---- runtime-dispatched overrides -------------------------------------- + /** + * One consistent snapshot of a collection plus a durable resume cursor, + * WITHOUT a WebSocket — the SSR read path (ADR-0011 D1). Throws on an + * unknown collection or an un-lowerable predicate (fail loud; RPC + * propagates the error to the caller). + * + * `request` is REQUIRED and runs through the SAME gate as the WS upgrade: + * the configured `parseAttachment` — pass the claims-bearing Request the + * worker already forges (or forwards) for the socket path. One hook guards + * both paths, so an author's tenant check cannot be silently bypassed by + * the read path (and the minted claims are the seam where uniform + * read-scoping would land, on subs and snapshots alike). A rejecting + * parseAttachment rejects the RPC. The await happens BEFORE the reads: + * rows and cursor are still taken at one position (synchronous SQLite, + * no await between them). + * + * The cursor is the durable high-water mark, not bare `currentSeq` — see + * `highWaterSeq`. A cursor of "0" honestly means "no resume point" and the + * client must reconcile a fresh snapshot instead of catching up. + * + * BLOB parity (ADR-0017): the WS path's codec normalizes bare ArrayBuffer + * to Uint8Array; RPC structured clone would leak ArrayBuffer through, so + * rows are normalized here — one blob shape on both paths. + */ + async readSyncSnapshot(req: SyncSnapshotReq, request: Request): Promise { + await this.#parseAttachmentHook(request) // the one gate (claims unused until read-scoping exists) + const coll = this.#registry.collections.get(req.collection) + if (!coll) throw new Error(`readSyncSnapshot: unknown collection '${req.collection}'`) + const query = compileSubsetQuery(req.collection, { + where: req.where, + orderBy: req.orderBy, + limit: req.limit, + }) + const rows = Array.from(this.#sql.exec(query.sql, ...query.params)).map((row) => { + let out: Record | undefined + for (const [k, v] of Object.entries(row)) { + if (v instanceof ArrayBuffer) { + out ??= { ...row } + out[k] = new Uint8Array(v) as unknown as SqlStorageValue + } + } + return out ?? row + }) as Array> + return { rows, cursor: String(highWaterSeq(this.#sql)) } + } + override async fetch(req: Request): Promise { if (req.headers.get("Upgrade") === "websocket" && this.#claimsUpgrade(req)) { return this.#acceptSyncSocket(req) @@ -1006,7 +1070,9 @@ export function Syncable() { this.#send(ws, { t: "d", sub: sub.subId, key, op: change.op, cols: row, seq }) } } - this.#send(ws, { t: "uptodate", seq }) + // Sub-scoped terminal: this catch-up is one subscription's bootstrap, not + // a socket-wide boundary (ADR-0011 D3). Still advances the client cursor. + this.#send(ws, { t: "uptodate", seq, sub: sub.subId }) } /** Encode and send a server frame on one socket. Warns — and still sends diff --git a/src/wire/frames.ts b/src/wire/frames.ts index b2cd21b..bae1890 100644 --- a/src/wire/frames.ts +++ b/src/wire/frames.ts @@ -70,7 +70,11 @@ export type ServerFrame = // Live delta; `cols` is a partial (top-level) patch, absent for delete. | { t: "d"; sub: string; key: unknown; op: RowOp; cols?: Record; seq: Cursor } // Batch boundary — client commits the buffered sync transaction here. - | { t: "uptodate"; seq: Cursor } + // `sub` scopes a CATCH-UP's terminal to its subscription (ADR-0011 D3): a + // transient hydration catch-up must distinguish its own terminal from a + // coalescer/barrier boundary, or it unsubscribes early and drops its own + // deltas. Absent on broadcast boundaries (additive, backwards-compatible). + | { t: "uptodate"; seq: Cursor; sub?: string } // Mutation receipt (the no-subscription-match path lives here; ADR-0002 C1/C2). | { t: "committed"; txId: TxId; seq: Cursor; result?: unknown } | { t: "rejected"; txId: TxId; error: { code?: string; message: string } } diff --git a/tests/do-collection.test.ts b/tests/do-collection.test.ts index cf332c5..c814edb 100644 --- a/tests/do-collection.test.ts +++ b/tests/do-collection.test.ts @@ -49,7 +49,7 @@ function startSync(transport: WebSocketTransport): { calls: Array // sync lives on opts.sync.sync; invoke with spy controls (cast: type-only dep). const syncConfig = (opts as unknown as { sync: { sync: (p: unknown) => void } }).sync syncConfig.sync({ - collection: { get: () => undefined }, // adapter consults held keys (held-insert upsert) + collection: { get: () => undefined, _state: { syncedData: new Map() } }, // adapter consults synced rows begin: () => calls.push(["begin"]), write: (m: unknown) => calls.push(["write", m]), commit: () => calls.push(["commit"]), @@ -94,7 +94,7 @@ describe("doCollectionOptions (M3 adapter)", () => { const adapter = doCollectionOptions({ transport: t, table: "messages", getKey: (r) => r.id }) const calls: Array = [] ;(adapter as unknown as { sync: { sync: (p: unknown) => void } }).sync.sync({ - collection: { get: () => undefined }, + collection: { get: () => undefined, _state: { syncedData: new Map() } }, begin: () => calls.push(["begin"]), write: (m: unknown) => calls.push(["write", m]), commit: () => calls.push(["commit"]), diff --git a/tests/filtered-client.test.ts b/tests/filtered-client.test.ts index 3454b53..57e62f8 100644 --- a/tests/filtered-client.test.ts +++ b/tests/filtered-client.test.ts @@ -52,7 +52,7 @@ function startFiltered(transport: WebSocketTransport, where: unknown): const calls: Array = [] const adapter = doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id, where }) ;(adapter as unknown as { sync: { sync: (p: unknown) => void } }).sync.sync({ - collection: { get: () => undefined }, // adapter consults held keys (held-insert upsert) + collection: { get: () => undefined, _state: { syncedData: new Map() } }, // adapter consults synced rows begin: () => calls.push(["begin"]), write: (m: unknown) => calls.push(["write", m]), commit: () => calls.push(["commit"]), diff --git a/tests/read-sync-snapshot.test.ts b/tests/read-sync-snapshot.test.ts new file mode 100644 index 0000000..a63bb1c --- /dev/null +++ b/tests/read-sync-snapshot.test.ts @@ -0,0 +1,115 @@ +import { env, runInDurableObject } from "cloudflare:test" +import { describe, expect, it } from "vitest" + +// WHY (ADR-0011 D1): SSR needs a snapshot + resume cursor out of the DO +// WITHOUT a WebSocket. The cursor must be a DURABLE high-water mark — not +// MAX(_sync_changes.seq), which retention can prune to 0 while the table still +// has rows. A bogus cursor 0 against live rows means a delete landing between +// render and hydration strands a stale row forever (the client can't resume, +// and a fresh snapshot doesn't carry tombstones). These pin: rows+cursor read +// at one position, predicate pushdown, fail-loud on unknown collections, and +// the high-water surviving a pruned-empty changelog. + +type SnapshotReq = { collection: string; where?: unknown; orderBy?: unknown; limit?: number } +type SnapshotRes = { rows: Array>; cursor: string } + +function stubFor(room: string): DurableObjectStub { + return env.SYNC_DO.get(env.SYNC_DO.idFromName(room)) +} + +/** Call over the binding like an SSR worker would (real RPC, not instance + * poking), passing the same claims-bearing Request the WS upgrade gets. */ +async function readSyncSnapshot(room: string, req: SnapshotReq, user = "anon"): Promise { + const stub = stubFor(room) as unknown as { + readSyncSnapshot: (r: SnapshotReq, request: Request) => Promise + } + return stub.readSyncSnapshot(req, new Request("https://example.com/ssr", { headers: { "x-user": user } })) +} + +describe("readSyncSnapshot RPC (SSR read path, ADR-0011 D1)", () => { + it("runs the SAME gate as the WS upgrade: a rejecting parseAttachment rejects the read", async () => { + const room = `snap-gate-${crypto.randomUUID()}` + await runInDurableObject(stubFor(room), (_i, s) => { + s.storage.sql.exec("INSERT INTO messages(id,body) VALUES('a','secret')") + }) + // The author's one auth hook guards both paths — a tenant check cannot be + // silently bypassed by the snapshot read. + await expect(readSyncSnapshot(room, { collection: "messages" }, "forbidden")).rejects.toThrow() + // ...and a passing identity reads normally. + const { rows } = await readSyncSnapshot(room, { collection: "messages" }) + expect(rows).toHaveLength(1) + }) + + it("returns current rows and a cursor that resumes past them", async () => { + const room = `snap-${crypto.randomUUID()}` + await runInDurableObject(stubFor(room), (_i, s) => { + s.storage.sql.exec("INSERT INTO messages(id,body) VALUES('a','hi'),('b','yo')") + }) + + const { rows, cursor } = await readSyncSnapshot(room, { collection: "messages" }) + expect(rows.map((r) => r.id).sort()).toEqual(["a", "b"]) + // The cursor covers the snapshot: every change that produced these rows is + // at or below it, so a client resuming from it re-receives nothing. + expect(BigInt(cursor)).toBeGreaterThanOrEqual(2n) + + // A later write is ABOVE the cursor — exactly what catch-up will deliver. + await runInDurableObject(stubFor(room), (_i, s) => { + s.storage.sql.exec("INSERT INTO messages(id,body) VALUES('c','new')") + }) + const after = await readSyncSnapshot(room, { collection: "messages" }) + expect(BigInt(after.cursor)).toBeGreaterThan(BigInt(cursor)) + }) + + it("pushes the where predicate into the read", async () => { + const room = `snap-where-${crypto.randomUUID()}` + await runInDurableObject(stubFor(room), (_i, s) => { + s.storage.sql.exec("INSERT INTO messages(id,body) VALUES('a','keep'),('b','drop')") + }) + // The serialized @tanstack/db IR shape a collection's `where` carries. + const where = { type: "func", name: "gt", args: [{ type: "ref", path: ["id"] }, { type: "val", value: "a" }] } + const { rows } = await readSyncSnapshot(room, { collection: "messages", where }) + expect(rows.map((r) => r.id)).toEqual(["b"]) + }) + + it("throws on an unknown collection (fail loud, not empty-success)", async () => { + const room = `snap-unknown-${crypto.randomUUID()}` + await runInDurableObject(stubFor(room), () => {}) // materialize schema + await expect(readSyncSnapshot(room, { collection: "nope" })).rejects.toThrow(/unknown collection/) + }) + + it("normalizes BLOB values to Uint8Array — parity with the wire codec (ADR-0017)", async () => { + const room = `snap-blob-${crypto.randomUUID()}` + await runInDurableObject(stubFor(room), (_i, s) => { + // SQLite affinity doesn't coerce blobs: a TEXT column stores this as BLOB + // and reads it back as ArrayBuffer — exactly what RPC structured clone + // would leak through where the WS codec normalizes to Uint8Array. + s.storage.sql.exec("INSERT INTO messages(id,body) VALUES('a', ?)", new Uint8Array([1, 2, 3])) + }) + const { rows } = await readSyncSnapshot(room, { collection: "messages" }) + expect(rows[0]!.body).toBeInstanceOf(Uint8Array) + expect(Array.from(rows[0]!.body as Uint8Array)).toEqual([1, 2, 3]) + }) + + it("keeps a durable high-water cursor when retention has pruned the changelog empty", async () => { + const room = `snap-prune-${crypto.randomUUID()}` + await runInDurableObject(stubFor(room), (_i, s) => { + s.storage.sql.exec("INSERT INTO messages(id,body) VALUES('a','hi')") + }) + const before = await readSyncSnapshot(room, { collection: "messages" }) + expect(BigInt(before.cursor)).toBeGreaterThan(0n) + + // Simulate retention pruning the whole log away (time passing). The drain + // cursor in _sync_meta is the durable survivor the high-water must use. + await runInDurableObject(stubFor(room), (_i, s) => { + s.storage.sql.exec( + "INSERT INTO _sync_meta(k,v) VALUES('drain_cursor', ?) ON CONFLICT(k) DO UPDATE SET v=excluded.v", + String(before.cursor), + ) + s.storage.sql.exec("DELETE FROM _sync_changes") + }) + + const after = await readSyncSnapshot(room, { collection: "messages" }) + expect(after.rows).toHaveLength(1) // table rows are untouched by retention + expect(BigInt(after.cursor)).toBeGreaterThanOrEqual(BigInt(before.cursor)) // never regresses to 0 + }) +}) diff --git a/tests/ssr-adapter.test.ts b/tests/ssr-adapter.test.ts new file mode 100644 index 0000000..715b33b --- /dev/null +++ b/tests/ssr-adapter.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "vitest" +import { doCollectionOptions, type DoSyncMeta } from "../src/client/do-collection.ts" +import type { Transport } from "../src/client/transport.ts" + +// WHY (ADR-0011 D3, adapter-level ordering): on-demand readiness must GATE on +// the transient hydration catch-up sub being SENT — loadSubset subs only fire +// after ready, so on the single ordered socket the catch-up's truncate/deltas +// always precede subset snapshots. A markReady racing ahead (the bug: its +// connect().then() was registered first) lets a subset snapshot land at a seq +// the catch-up then stomps over — or, below the floor, lets the catch-up's +// truncate WIPE an already-loaded subset. Also pins the syncMeta hook +// contract: export shape, import validation, where-fingerprint downgrade, +// min-merge. + +interface Msg { + id: string + body: string +} + +/** Structural schema Api the branded transport carries, so `doCollectionOptions` + * infers Row = Msg for `table: "messages"` (matches `RowOf`/`CollectionName`). */ +type Api = { collections: { messages: { __row?: Msg } } } + +type Hooked = { + sync: { + sync: (p: unknown) => unknown + exportSyncMeta: () => DoSyncMeta + importSyncMeta: (m: unknown) => void + mergeSyncMeta: (a: unknown, b: unknown) => DoSyncMeta + } +} + +function spyTransport(calls: Array): Transport { + return { + connect: async () => { + calls.push("connect") + }, + subscribe: async (subId, _collection, _handler, _where, _orderBy, _limit, since) => { + calls.push(`sub:${subId}:since=${since ?? "none"}`) + }, + unsubscribe: (subId: string) => { + calls.push(`unsub:${subId}`) + }, + sendMut: () => Promise.reject(new Error("unused")), + fetch: () => Promise.reject(new Error("unused")), + close: () => {}, + appliedCursor: "7", + hasPosition: true, + seedCursor: () => { + calls.push("seed") + }, + } +} + +const controls = { + collection: { get: () => undefined }, + begin: () => {}, + write: () => {}, + commit: () => {}, + truncate: () => {}, +} + +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)) + +describe("hydrated on-demand start ordering", () => { + it("ready waits for the catch-up sub to be SENT; the catch-up precedes any subset sub", async () => { + const calls: Array = [] + const opts = doCollectionOptions({ + transport: spyTransport(calls), + table: "messages", + getKey: (r) => r.id, + syncMode: "on-demand", + }) as unknown as Hooked + opts.sync.importSyncMeta({ v: 1, cursor: "5" }) + opts.sync.sync({ ...controls, markReady: () => calls.push("ready") }) + await flush() + + const catchup = calls.findIndex((c) => c.startsWith("sub:messages#hydrate#") && c.endsWith("since=5")) + const ready = calls.indexOf("ready") + expect(catchup).toBeGreaterThanOrEqual(0) + expect(ready).toBeGreaterThan(catchup) + }) + + it("without hydration there is no catch-up sub and ready follows connect", async () => { + const calls: Array = [] + const opts = doCollectionOptions({ + transport: spyTransport(calls), + table: "messages", + getKey: (r) => r.id, + syncMode: "on-demand", + }) as unknown as Hooked + opts.sync.sync({ ...controls, markReady: () => calls.push("ready") }) + await flush() + expect(calls.filter((c) => c.startsWith("sub:"))).toEqual([]) + expect(calls).toContain("ready") + }) + + it("no resume point ('0'): hydrated rows are truncated, not left to go stale", async () => { + const calls: Array = [] + const truncated: Array = [] + const opts = doCollectionOptions({ + transport: spyTransport(calls), + table: "messages", + getKey: (r) => r.id, + syncMode: "on-demand", + }) as unknown as Hooked + opts.sync.importSyncMeta({ v: 1, cursor: "0" }) + opts.sync.sync({ + ...controls, + truncate: () => truncated.push("truncate"), + markReady: () => calls.push("ready"), + }) + await flush() + expect(truncated).toEqual(["truncate"]) + expect(calls.filter((c) => c.startsWith("sub:"))).toEqual([]) // no unfiltered full snapshot + expect(calls).toContain("ready") + }) +}) + +describe("syncMeta hooks", () => { + const eq = (field: string, value: unknown): unknown => ({ + type: "func", + name: "eq", + args: [ + { type: "ref", path: [field] }, + { type: "val", value }, + ], + }) + + function makeOpts(where?: unknown): Hooked { + return doCollectionOptions({ + transport: spyTransport([]), + table: "messages", + getKey: (r) => r.id, + where, + }) as unknown as Hooked + } + + it("export round-trips through import; the eager where is fingerprinted", () => { + const a = makeOpts(eq("body", "keep")) + const meta = a.sync.exportSyncMeta() + expect(meta).toMatchObject({ v: 1, cursor: "7" }) + expect(typeof meta.where).toBe("string") + a.sync.importSyncMeta(meta) // same fingerprint: accepted (no throw) + }) + + it("a DIFFERENT where downgrades the cursor to the snapshot-reconcile path", async () => { + const calls: Array = [] + const renderSide = makeOpts(eq("body", "keep")) + const meta = renderSide.sync.exportSyncMeta() + + const clientSide = doCollectionOptions({ + transport: spyTransport(calls), + table: "messages", + getKey: (r) => r.id, + where: eq("body", "other"), + }) as unknown as Hooked + clientSide.sync.importSyncMeta(meta) + expect(calls).not.toContain("seed") // an unsound cursor is never claimed + clientSide.sync.sync({ ...controls, markReady: () => {} }) + await flush() + // The eager sub must NOT resume from the foreign cursor. + expect(calls.some((c) => c.startsWith("sub:") && c.endsWith("since=none"))).toBe(true) + }) + + it("rejects meta it does not understand — never resumes from garbage", () => { + const o = makeOpts() + expect(() => o.sync.importSyncMeta({ v: 2, cursor: "5" })).toThrow(/unrecognized sync meta/) + expect(() => o.sync.importSyncMeta({ v: 1, cursor: "not-a-seq" })).toThrow() + expect(() => o.sync.importSyncMeta(null)).toThrow(/unrecognized sync meta/) + }) + + it("unrecognized meta fails loud BUT safe: the rows already landed, so sync still reconciles", async () => { + // Upstream applies the chunk's rows BEFORE importSyncMeta — a throw can't + // veto them. If the throw also skipped our bookkeeping, sync would start + // down the non-hydrated path and a server-deleted hydrated row would be + // stale forever. The throw must leave the safe state behind: no resume + // point ("0") → snapshot + reconcile. + const calls: Array = [] + const o = doCollectionOptions({ + transport: spyTransport(calls), + table: "messages", + getKey: (r) => r.id, + }) as unknown as Hooked + expect(() => o.sync.importSyncMeta({ v: 99, cursor: "5" })).toThrow(/unrecognized sync meta/) + expect(calls).not.toContain("seed") // a cursor we can't read is never claimed + o.sync.sync({ ...controls, markReady: () => {} }) + await flush() + // Snapshot path (no since) — where the always-armed eager reconcile lives. + expect(calls.some((c) => c.startsWith("sub:") && c.endsWith("since=none"))).toBe(true) + }) + + it("merge takes the EARLIER cursor — replay is idempotent, skipping is not", () => { + const o = makeOpts() + const merged = o.sync.mergeSyncMeta({ v: 1, cursor: "90" }, { v: 1, cursor: "100" }) + expect(merged.cursor).toBe("90") + expect(o.sync.mergeSyncMeta({ v: 1, cursor: "100" }, { v: 1, cursor: "90" }).cursor).toBe("90") + }) + + it("a NEGATIVE cursor is rejected — and on-demand still lands on the safe truncate path", async () => { + // codex finding: BigInt("-1") parses, seedCursor ignores it, but + // `since:"-1"` on the wire makes the server answer a full snapshot the + // catch-up handler discards — no terminal, the transient sub never tears + // down, stale hydrated rows survive forever. Parse must refuse it; the + // fail-loud-but-safe contract then routes sync start to "0" (truncate). + const calls: Array = [] + const truncated: Array = [] + const o = doCollectionOptions({ + transport: spyTransport(calls), + table: "messages", + getKey: (r) => r.id, + syncMode: "on-demand", + }) as unknown as Hooked + expect(() => o.sync.importSyncMeta({ v: 1, cursor: "-1" })).toThrow(/negative/) + expect(calls).not.toContain("seed") + o.sync.sync({ ...controls, truncate: () => truncated.push("truncate"), markReady: () => {} }) + await flush() + expect(truncated).toEqual(["truncate"]) // safe "0" route, not a since:"-1" catch-up + expect(calls.filter((c) => c.startsWith("sub:"))).toEqual([]) + }) + + it("merge with MISMATCHED fingerprints yields cursor '0' — no sound joint resume point", () => { + // codex finding: MIN alone can return the side whose fingerprint matches + // ours while the OTHER side's foreign-filter rows were already applied — + // import would then accept a cursor whose catch-up never covers them. + const o = makeOpts() // our fingerprint: undefined + const merged = o.sync.mergeSyncMeta({ v: 1, cursor: "50", where: "AAA" }, { v: 1, cursor: "100", where: "BBB" }) + expect(merged.cursor).toBe("0") + expect(merged.where).toBeUndefined() // OUR fingerprint, so import routes it to "0" + // Same fingerprints (even a foreign one) still MIN — import does the + // ours-vs-theirs check. + expect(o.sync.mergeSyncMeta({ v: 1, cursor: "50", where: "AAA" }, { v: 1, cursor: "100", where: "AAA" }).cursor).toBe("50") + }) +}) + +describe("commit receipts (0.8.5 SyncAppliedReceipt)", () => { + /** Spy transport that CAPTURES subscribe handlers so a test can drive them. */ + function capturingTransport(calls: Array, handlers: Map): Transport { + const t = spyTransport(calls) + return { + ...t, + subscribe: async (subId, _c, handler) => { + calls.push(`sub:${subId}`) + handlers.set(subId, handler) + }, + } + } + + it("a REJECTED receipt fails the subset load — an aborted application is not success", async () => { + const calls: Array = [] + const handlers = new Map() + const opts = doCollectionOptions({ + transport: capturingTransport(calls, handlers), + table: "messages", + getKey: (r) => r.id, + syncMode: "on-demand", + }) as unknown as Hooked + const res = opts.sync.sync({ + ...controls, + commit: () => Promise.reject(new Error("aborted application")), + markReady: () => {}, + markError: () => {}, + } as never) as { loadSubset: (o: unknown) => true | Promise } + await flush() + const load = res.loadSubset({}) as Promise + const settled = load.then( + () => "resolved", + (e: Error) => `rejected:${e.message}`, + ) + await flush() + const h = handlers.get("messages#null")! + h.onSnap(undefined, { id: "a", body: "x" }) + h.onSnapEnd() // flush → rejected receipt + await expect(settled).resolves.toBe("rejected:aborted application") + }) + + it("exportSyncMeta never claims a boundary whose receipt is unsettled", async () => { + // codex finding: the transport cursor advances at the boundary, but the + // commit's application can settle later — a dehydrate in that window would + // serialize pre-boundary rows under meta claiming the boundary. + const calls: Array = [] + const handlers = new Map() + let settle!: () => void + const receipt = new Promise((r) => { + settle = r + }) + const opts = doCollectionOptions({ + transport: capturingTransport(calls, handlers), + table: "messages", + getKey: (r) => r.id, + }) as unknown as Hooked + expect(opts.sync.exportSyncMeta().cursor).toBe("7") // nothing pending: the live claim + opts.sync.sync({ + ...controls, + collection: { get: () => undefined, _state: { syncedData: new Map() } }, + commit: () => receipt, + markReady: () => {}, + markError: () => {}, + } as never) + await flush() + const h = handlers.get([...handlers.keys()][0]!)! + h.onSnap(undefined, { id: "a", body: "x" }) + h.onSnapEnd() // flush → pending receipt + expect(opts.sync.exportSyncMeta().cursor).toBe("0") // under-claim, never the unproven "7" + settle() + await flush() + expect(opts.sync.exportSyncMeta().cursor).toBe("7") // settled: live claim again + }) +}) diff --git a/tests/ssr-cursor.test.ts b/tests/ssr-cursor.test.ts new file mode 100644 index 0000000..3f73d08 --- /dev/null +++ b/tests/ssr-cursor.test.ts @@ -0,0 +1,309 @@ +import { env, runInDurableObject, SELF } from "cloudflare:test" +import { describe, expect, it } from "vitest" +import { type SubHandler, WebSocketTransport, type WebSocketLike } from "../src/client/transport.ts" +import { createFrameCodec } from "../src/wire/frame-codec.ts" +import type { ClientFrame, ServerFrame } from "../src/wire/frames.ts" + +// WHY (ADR-0011 D3): SSR hydration hands a client rows it did not stream — so +// the FIRST sub must be able to resume from the dehydrated cursor (server +// catch-up, not a redundant snapshot), and the transport must be able to claim +// that position before/around live traffic: +// - seedCursor before any advance: a drop in the bootstrap window otherwise +// resubscribes from 0 → fresh snapshot over hydrated rows → a row deleted +// server-side meanwhile is never removed (snapshots carry no tombstones). +// - seedCursor AFTER live advance (a late streamed SSR chunk): upstream has +// already applied the chunk's possibly-stale rows — we cannot veto. The +// transport claims the SHORTER prefix (always safe) and resubscribes, so +// the catch-up replay re-freshens exactly the clobbered window. + +interface Rec { + events: Array<[string, ...Array]> + handler: SubHandler +} +function recorder(): Rec { + const events: Array<[string, ...Array]> = [] + return { + events, + handler: { + onSnap: (k, r) => events.push(["snap", k, r]), + onSnapEnd: () => events.push(["snap-end"]), + onDelta: (op, k, c) => events.push(["d", op, k, c]), + onUptodate: () => events.push(["uptodate"]), + onReset: () => events.push(["reset"]), + }, + } +} + +function makeTransport(room: string): WebSocketTransport { + return new WebSocketTransport({ + url: `https://example.com/sync/${room}`, + reconnectDelay: 20, + open: async () => { + const res = await SELF.fetch(`https://example.com/sync/${room}`, { headers: { Upgrade: "websocket" } }) + const ws = res.webSocket + if (!ws) throw new Error("no webSocket") + ws.accept() + return ws as unknown as WebSocketLike + }, + }) +} + +async function waitFor(pred: () => boolean, timeoutMs = 3000): Promise { + const start = Date.now() + while (!pred()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout") + await new Promise((r) => setTimeout(r, 5)) + } +} + +function stubFor(room: string): DurableObjectStub { + return env.SYNC_DO.get(env.SYNC_DO.idFromName(room)) +} + +async function snapshotCursor(room: string): Promise { + const stub = stubFor(room) as unknown as { + readSyncSnapshot: (r: { collection: string }, request: Request) => Promise<{ rows: Array; cursor: string }> + } + return (await stub.readSyncSnapshot({ collection: "messages" }, new Request("https://example.com/ssr", { headers: { "x-user": "anon" } }))).cursor +} + +describe("transport cursor bootstrap (SSR hydration, ADR-0011 D3)", () => { + it("a FIRST sub carrying `since` gets a catch-up, not a snapshot", async () => { + const room = `ssr-since-${crypto.randomUUID()}` + await runInDurableObject(stubFor(room), (_i, s) => { + s.storage.sql.exec("INSERT INTO messages(id,body) VALUES('a','hydrated')") + }) + const cursor = await snapshotCursor(room) // what dehydration exported + await runInDurableObject(stubFor(room), (_i, s) => { + s.storage.sql.exec("INSERT INTO messages(id,body) VALUES('b','missed')") + }) + + const t = makeTransport(room) + const { events, handler } = recorder() + await t.subscribe("s1", "messages", handler, undefined, undefined, undefined, cursor) + await waitFor(() => events.some((e) => e[0] === "uptodate")) + + // The hydrated row is NOT re-streamed; only the post-cursor change is. + expect(events.some((e) => e[0] === "snap")).toBe(false) + expect(events.some((e) => e[0] === "snap-end")).toBe(false) + expect(events.some((e) => e[0] === "d" && e[2] === "b")).toBe(true) + expect(events.some((e) => e[0] === "d" && e[2] === "a")).toBe(false) + t.close() + }) + + it("seedCursor claims the dehydrated position before any advance", async () => { + const room = `ssr-seed-${crypto.randomUUID()}` + await runInDurableObject(stubFor(room), (_i, s) => { + s.storage.sql.exec("INSERT INTO messages(id,body) VALUES('a','hydrated')") + }) + const cursor = await snapshotCursor(room) + + const t = makeTransport(room) + expect(t.appliedCursor).toBe("0") + t.seedCursor(cursor) + expect(t.appliedCursor).toBe(cursor) + // A seed can never grow the claim without data. + t.seedCursor(String(BigInt(cursor) + 100n)) + expect(t.appliedCursor).toBe(cursor) + t.close() + }) + + it("a late seed (streamed chunk after live advance) regresses the claim and replays the window", async () => { + const room = `ssr-late-${crypto.randomUUID()}` + await runInDurableObject(stubFor(room), (_i, s) => { + s.storage.sql.exec("INSERT INTO messages(id,body) VALUES('a','v1')") + }) + const chunkCursor = await snapshotCursor(room) // a chunk dehydrated NOW... + + const t = makeTransport(room) + const { events, handler } = recorder() + await t.subscribe("s1", "messages", handler, undefined, undefined, undefined, chunkCursor) + await waitFor(() => events.some((e) => e[0] === "uptodate")) + + // ...but it arrives LATE: live sync has moved on past another write + // (driven through a real mut — raw SQL never broadcasts, ADR-0006). + const t2 = makeTransport(room) + await t2.sendMut({ + t: "mut", + txId: `tx-${crypto.randomUUID()}`, + collection: "messages", + ops: [{ type: "update", key: "a", cols: { body: "v2" } }], + }) + await waitFor(() => events.some((e) => e[0] === "d" && e[2] === "a")) + t2.close() + const advanced = t.appliedCursor + expect(BigInt(advanced)).toBeGreaterThan(BigInt(chunkCursor)) + const before = events.length + + // Upstream already applied the chunk's stale rows; the transport claims + // the shorter prefix and resubscribes — the replayed catch-up delivers the + // post-chunk window again (idempotent) and re-freshens clobbered rows. + t.seedCursor(chunkCursor) + expect(t.appliedCursor).toBe(chunkCursor) + await waitFor(() => events.slice(before).some((e) => e[0] === "d" && e[2] === "a")) + await waitFor(() => BigInt(t.appliedCursor) >= BigInt(advanced)) + expect(events.slice(before).some((e) => e[0] === "snap")).toBe(false) // replay, not re-snapshot + t.close() + }) + + it("a stale pre-regress boundary cannot re-advance the claim; the fresh socket replays from the seed", async () => { + // Fully fake sockets: a live regress rides a RECONNECT because the old + // socket's already-queued boundary frames (full duplex) would otherwise + // re-advance the cursor past the repair window — then a drop would resume + // beyond it and the late chunk's clobbered rows would stay stale forever. + const codec = createFrameCodec() + interface Fake { + ws: WebSocketLike + sent: Array + emit: (type: string, ev: { data?: unknown }) => void + closeCalled: boolean + } + const makeFake = (): Fake => { + const listeners = new Map void>>() + const fake: Fake = { + sent: [], + closeCalled: false, + emit: (type, ev) => { + for (const l of listeners.get(type) ?? []) l(ev) + }, + ws: { + send: (data) => fake.sent.push(codec.decode(data as ArrayBuffer | string) as ClientFrame), + close: () => { + fake.closeCalled = true // close event delivery is the TEST's choice + }, + addEventListener: (type, l) => { + const arr = listeners.get(type) ?? [] + arr.push(l) + listeners.set(type, arr) + }, + removeEventListener: () => {}, + }, + } + return fake + } + const fakes: Array = [] + const t = new WebSocketTransport({ + url: "wss://fake", + reconnectDelay: 1, + open: () => { + const f = makeFake() + fakes.push(f) + return f.ws + }, + }) + const { handler } = recorder() + await t.subscribe("s1", "messages", handler) + const server = (frame: ServerFrame, fake = fakes.at(-1)!): void => + fake.emit("message", { data: codec.encode(frame) }) + + server({ t: "snap-end", sub: "s1", seq: "100" }) + expect(t.appliedCursor).toBe("100") + + // Late chunk → regress. The transport must abandon this socket. + t.seedCursor("50") + expect(t.appliedCursor).toBe("50") + expect(fakes[0]!.closeCalled).toBe(true) + + // A boundary the server sent BEFORE the close (still queued client-side) + // must not count: the claim holds at the seed. + server({ t: "uptodate", seq: "101" }, fakes[0]!) + expect(t.appliedCursor).toBe("50") + + // Now the close lands; the fresh socket resubscribes FROM the seed... + fakes[0]!.emit("close", {}) + await waitFor(() => fakes.length === 2 && fakes[1]!.sent.some((f) => f.t === "sub")) + const resub = fakes[1]!.sent.find((f) => f.t === "sub") as Extract + expect(resub.since).toBe("50") + + // ...and its frames own the cursor again. + server({ t: "uptodate", seq: "102" }) + expect(t.appliedCursor).toBe("102") + t.close() + }) + + it("an abandoned socket still settles ID-scoped receipts — but never advances the cursor", async () => { + // codex finding: a regress-reconnect must not convert a COMMITTED mutation + // into a timeout — receipts are not re-covered by any replay. Stream + // frames from the stale socket stay dropped (the catch-up re-covers them). + const codec = createFrameCodec() + const fakes: Array<{ + ws: WebSocketLike + sent: Array + emit: (type: string, ev: { data?: unknown }) => void + }> = [] + const makeFake = () => { + const listeners = new Map void>>() + const fake = { + sent: [] as Array, + emit: (type: string, ev: { data?: unknown }) => { + for (const l of listeners.get(type) ?? []) l(ev) + }, + ws: { + send: (data: string | ArrayBuffer | ArrayBufferView) => + fake.sent.push(codec.decode(data as ArrayBuffer | string) as ClientFrame), + close: () => {}, + addEventListener: (type: string, l: (ev: { data?: unknown }) => void) => { + const arr = listeners.get(type) ?? [] + arr.push(l) + listeners.set(type, arr) + }, + removeEventListener: () => {}, + } as WebSocketLike, + } + fakes.push(fake) + return fake + } + const t = new WebSocketTransport({ url: "wss://fake", reconnectDelay: 1, open: () => makeFake().ws }) + const { handler } = recorder() + await t.subscribe("s1", "messages", handler) + const emit = (frame: ServerFrame, i = fakes.length - 1): void => + fakes[i]!.emit("message", { data: codec.encode(frame) }) + + emit({ t: "snap-end", sub: "s1", seq: "100" }) + const mut = t.sendMut({ t: "mut", txId: "tx-1", collection: "messages", ops: [] }) + const page = t.fetch({ t: "fetch", fetchId: "f-1", collection: "messages" }) + // Both frames must be ON the old socket before the regress abandons it. + await waitFor(() => fakes[0]!.sent.some((f) => f.t === "mut") && fakes[0]!.sent.some((f) => f.t === "fetch")) + + t.seedCursor("50") // regress → the socket that carries tx-1/f-1 is abandoned + expect(t.appliedCursor).toBe("50") + + // The stale socket's receipts settle their waiters... + emit({ t: "committed", txId: "tx-1", seq: "101" }, 0) + emit({ t: "page", fetchId: "f-1", rows: [{ id: "x" }], seq: "101" }, 0) + await expect(mut).resolves.toEqual({ result: undefined }) + await expect(page).resolves.toEqual([{ id: "x" }]) + // ...but never the cursor; and its stream frames are dropped outright. + expect(t.appliedCursor).toBe("50") + emit({ t: "uptodate", seq: "102" }, 0) + expect(t.appliedCursor).toBe("50") + t.close() + }) + + it("unsubscribing while a subscribe's connect is in flight sends no ghost sub", async () => { + // codex finding: the suspended subscribe() body would send its `sub` after + // the open completes even though the handler is gone — the server persists + // a subscription nothing consumes (ADR-0019) until the socket drops. + const codec = createFrameCodec() + let openNow!: (ws: WebSocketLike) => void + const sent: Array = [] + const ws: WebSocketLike = { + send: (data) => sent.push(codec.decode(data as ArrayBuffer | string) as ClientFrame), + close: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + } + const t = new WebSocketTransport({ + url: "wss://fake", + reconnectDelay: 1, + open: () => new Promise((r) => (openNow = r)), + }) + const { handler } = recorder() + const sub = t.subscribe("s1", "messages", handler) // parks on the pending open + t.unsubscribe("s1") // no socket yet: nothing to send the unsub on + openNow(ws) + await sub + expect(sent.filter((f) => f.t === "sub")).toEqual([]) + t.close() + }) +}) diff --git a/tests/ssr-hydration.test.ts b/tests/ssr-hydration.test.ts new file mode 100644 index 0000000..6766188 --- /dev/null +++ b/tests/ssr-hydration.test.ts @@ -0,0 +1,287 @@ +import { createLiveQueryCollection, DbClient, collectionOptions, eq } from "@tanstack/db" +import { env, runInDurableObject, SELF } from "cloudflare:test" +import { describe, expect, it } from "vitest" +import { doCollectionOptions } from "../src/client/do-collection.ts" +import { SsrSnapshotTransport, type SnapshotRead } from "../src/client/ssr-transport.ts" +import { WebSocketTransport, type WebSocketLike } from "../src/client/transport.ts" +import type { ClientFrame } from "../src/wire/frames.ts" + +// WHY (ADR-0011, end to end): the whole point of SSR support is one specific +// promise — the browser renders the worker's dehydrated rows IMMEDIATELY, then +// CONVERGES to the DO's current truth without wedging, flashing empty, or +// stranding a deleted row. Rows ride TanStack's DehydratedDbState; our cursor +// rides the opaque syncMeta. These tests run the REAL upstream DbClient +// (released ≥0.8.5 — PR #1564 as merged) on both sides: a server-side DbClient + snapshot +// transport renders and dehydrates; a client-side DbClient hydrates over a +// WebSocket transport; writes land on the DO between the two. Convergence — +// updates applied, deletes applied, no DuplicateKeySyncError, even with no +// resume point — is the contract. + +interface Msg { + id: string + body: string +} + +/** Structural schema Api the branded transports carry, so `doCollectionOptions` + * infers Row = Msg for `table: "messages"`. */ +type Api = { collections: { messages: { __row?: Msg } } } + +function makeRead(room: string): SnapshotRead { + const stub = env.SYNC_DO.get(env.SYNC_DO.idFromName(room)) as unknown as { + readSyncSnapshot: (r: Parameters[0], request: Request) => ReturnType + } + return (req) => stub.readSyncSnapshot(req, new Request("https://example.com/ssr", { headers: { "x-user": "anon" } })) +} + +function makeWsTransport(room: string): WebSocketTransport { + return new WebSocketTransport({ + url: `https://example.com/sync/${room}`, + reconnectDelay: 20, + open: async () => { + const res = await SELF.fetch(`https://example.com/sync/${room}`, { headers: { Upgrade: "websocket" } }) + const ws = res.webSocket + if (!ws) throw new Error("no webSocket") + ws.accept() + return ws as unknown as WebSocketLike + }, + }) +} + +async function sql(room: string, ...statements: Array): Promise { + await runInDurableObject(env.SYNC_DO.get(env.SYNC_DO.idFromName(room)), (_i, s) => { + for (const stmt of statements) s.storage.sql.exec(stmt) + }) +} + +async function waitFor(pred: () => boolean, timeoutMs = 3000): Promise { + const start = Date.now() + while (!pred()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout") + await new Promise((r) => setTimeout(r, 5)) + } +} + +/** The branded options DbClient wants, around our adapter. One per "process". */ +function makeOptions( + transport: WebSocketTransport | SsrSnapshotTransport, + syncMode?: "eager" | "on-demand", + where?: unknown, +) { + return collectionOptions( + doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id, syncMode, where }) as never, + ) as never +} + +/** Server render: per-request DbClient + snapshot transport → dehydrated state. */ +async function serverRender(room: string, syncMode?: "eager" | "on-demand", where?: unknown) { + const transport = new SsrSnapshotTransport({ read: makeRead(room) }) + const db = new DbClient() + const col = db.collection(makeOptions(transport, syncMode, where)) as unknown as { + preload: () => Promise + get: (k: string) => Msg | undefined + } + if (syncMode === "on-demand") { + const kept = createLiveQueryCollection((q) => + q.from({ m: col as never }).where(({ m }: { m: Msg }) => eq(m.body, "keep")), + ) + await kept.preload() + } else { + await col.preload() + } + return db.dehydrate() +} + +const whereEq = (field: string, value: unknown): unknown => ({ + type: "func", + name: "eq", + args: [ + { type: "ref", path: [field] }, + { type: "val", value }, + ], +}) + +describe("SSR round trip: dehydrate on the worker, hydrate + converge in the browser", () => { + it("eager: hydrated rows render immediately, then converge (update applied, delete applied)", async () => { + const room = `rt-eager-${crypto.randomUUID()}` + await sql(room, "INSERT INTO messages(id,body) VALUES('a','v1'),('b','doomed'),('c','calm')") + + const state = await serverRender(room) + const chunk = state.collections[0]! + expect(chunk.collectionId).toBe("messages") + expect(chunk.rows).toHaveLength(3) + expect(chunk.syncMeta).toMatchObject({ v: 1 }) + const dehydratedCursor = (chunk.syncMeta as { cursor: string }).cursor + expect(BigInt(dehydratedCursor)).toBeGreaterThan(0n) + + // While the HTML is in flight, the DO moves on: a changes, b dies. + await sql(room, "UPDATE messages SET body='v2' WHERE id='a'", "DELETE FROM messages WHERE id='b'") + + // Browser: hydrate, then go live. + const ws = makeWsTransport(room) + const db = new DbClient() + db.hydrate(state as never) + const col = db.collection(makeOptions(ws)) as unknown as { + preload: () => Promise + get: (k: string) => Msg | undefined + size: number + } + await col.preload() + + // First paint: the dehydrated rows, stale and ALL present — ready never + // waited for the socket (stale-while-revalidate, ADR-0011 D3). + expect(col.get("a")).toMatchObject({ body: "v1" }) + expect(col.get("b")).toBeDefined() + + // Convergence: catch-up applies the update AND the tombstone. + await waitFor(() => col.get("a")?.body === "v2" && col.get("b") === undefined) + expect(col.get("c")).toMatchObject({ body: "calm" }) + expect(BigInt(ws.appliedCursor)).toBeGreaterThan(BigInt(dehydratedCursor)) + ws.close() + }) + + it("eager with NO resume point (pruned log → cursor 0): snapshot reconcile removes the dead row", async () => { + const room = `rt-zero-${crypto.randomUUID()}` + await sql(room, "INSERT INTO messages(id,body) VALUES('a','hi'),('b','doomed')") + // Retention pruned everything; nothing was ever drained. High-water is + // honestly 0 — there is no resume point. + await sql(room, "DELETE FROM _sync_changes") + + const state = await serverRender(room) + expect((state.collections[0]!.syncMeta as { cursor: string }).cursor).toBe("0") + + await sql(room, "DELETE FROM messages WHERE id='b'") // dies while HTML is in flight + + const ws = makeWsTransport(room) + const db = new DbClient() + db.hydrate(state as never) + const col = db.collection(makeOptions(ws)) as unknown as { + preload: () => Promise + get: (k: string) => Msg | undefined + } + await col.preload() + expect(col.get("b")).toBeDefined() // stale first paint, not a flash-to-empty + + // The fresh snapshot is authoritative SET semantics: b is reconciled away. + await waitFor(() => col.get("b") === undefined) + expect(col.get("a")).toMatchObject({ body: "hi" }) + ws.close() + }) + + it("eager with no resume point and a WIPED table: the empty snapshot still reconciles everything away", async () => { + const room = `rt-wipe-${crypto.randomUUID()}` + await sql(room, "INSERT INTO messages(id,body) VALUES('a','hi'),('b','yo')") + await sql(room, "DELETE FROM _sync_changes") // no resume point + + const state = await serverRender(room) + expect(state.collections[0]!.rows).toHaveLength(2) + + // Everything dies while the HTML is in flight: the catch-up snapshot has + // ZERO rows — which must still count as the authoritative (empty) set. + await sql(room, "DELETE FROM messages") + + const ws = makeWsTransport(room) + const db = new DbClient() + db.hydrate(state as never) + const col = db.collection(makeOptions(ws)) as unknown as { + preload: () => Promise + size: number + } + await col.preload() + expect(col.size).toBe(2) // stale first paint + await waitFor(() => col.size === 0) // honest convergence, not stale-forever + ws.close() + }) + + it("a FUTURE-VERSIONED syncMeta throws from hydrate, yet the collection still converges", async () => { + const room = `rt-vskew-${crypto.randomUUID()}` + await sql(room, "INSERT INTO messages(id,body) VALUES('a','hi'),('b','doomed')") + + const state = await serverRender(room) + // A newer serializer wrote meta this client can't read. + ;(state.collections[0]! as { syncMeta: unknown }).syncMeta = { v: 99, cursor: "999" } + await sql(room, "DELETE FROM messages WHERE id='b'") // dies while in flight + + const ws = makeWsTransport(room) + const db = new DbClient() + const col = db.collection(makeOptions(ws)) as unknown as { + preload: () => Promise + get: (k: string) => Msg | undefined + } + expect(() => db.hydrate(state as never)).toThrow(/unrecognized sync meta/) // loud... + await col.preload() + expect(col.get("b")).toBeDefined() // rows landed regardless (no upstream veto) + await waitFor(() => col.get("b") === undefined) // ...but SAFE: reconcile converges + expect(col.get("a")).toMatchObject({ body: "hi" }) + ws.close() + }) + + it("a CHANGED eager where between render and hydrate downgrades to snapshot reconcile", async () => { + const room = `rt-where-${crypto.randomUUID()}` + await sql(room, "INSERT INTO messages(id,body) VALUES('a','keep'),('b','other')") + + // Rendered under where body='keep' — only 'a' is dehydrated, and the + // cursor is fingerprinted to THAT filter. + const state = await serverRender(room, undefined, whereEq("body", "keep")) + expect(state.collections[0]!.rows.map((r) => r.key)).toEqual(["a"]) + + // The browser ships a different filter (deploy skew). 'a' never changes + // after the render, so a since-catch-up would NEVER remove it — the + // foreign cursor must be refused and the snapshot reconciled instead. + const ws = makeWsTransport(room) + const db = new DbClient() + db.hydrate(state as never) + const col = db.collection(makeOptions(ws, undefined, whereEq("body", "other"))) as unknown as { + preload: () => Promise + get: (k: string) => Msg | undefined + } + await col.preload() + await waitFor(() => col.get("b") !== undefined && col.get("a") === undefined) + ws.close() + }) + + it("on-demand: transient catch-up converges hydrated subsets, then leaves (no eager leak)", async () => { + const room = `rt-od-${crypto.randomUUID()}` + await sql(room, "INSERT INTO messages(id,body) VALUES('a','keep'),('b','keep'),('c','drop')") + + const state = await serverRender(room, "on-demand") + // Dehydrated = the loaded subset only. + expect(state.collections[0]!.rows.map((r) => (r.value as unknown as Msg).id).sort()).toEqual(["a", "b"]) + + // While in flight: b dies, d joins the subset. + await sql(room, "DELETE FROM messages WHERE id='b'", "INSERT INTO messages(id,body) VALUES('d','keep')") + + const ws = makeWsTransport(room) + const db = new DbClient() + db.hydrate(state as never) + const colRaw = db.collection(makeOptions(ws, "on-demand")) + const col = colRaw as unknown as { get: (k: string) => Msg | undefined } + const kept = createLiveQueryCollection((q) => + q.from({ m: colRaw as never }).where(({ m }: { m: Msg }) => eq(m.body, "keep")), + ) + await kept.preload() + + // Convergence across hydrated rows: the tombstone for b (only the + // transient catch-up can deliver it — b is gone from every fresh subset + // snapshot) and the new subset member d. + await waitFor(() => col.get("b") === undefined && col.get("d") !== undefined) + expect(col.get("a")).toMatchObject({ body: "keep" }) + + // The catch-up sub must be GONE: an unfiltered leftover would stream + // out-of-subset rows. Write e (outside) then f (inside) through a real + // mut; f's arrival is the ordering sentinel proving e had its chance. + const writer = makeWsTransport(room) + const mut = (id: string, body: string): Extract => ({ + t: "mut", + txId: `tx-${id}-${crypto.randomUUID()}`, + collection: "messages", + ops: [{ type: "insert", key: id, cols: { id, body } }], + }) + await writer.sendMut(mut("e", "drop")) + await writer.sendMut(mut("f", "keep")) + await waitFor(() => col.get("f") !== undefined) + expect(col.get("e")).toBeUndefined() + expect(col.get("c")).toBeUndefined() // never loaded; on-demand stayed on-demand + writer.close() + ws.close() + }) +}) diff --git a/tests/ssr-transport.test.ts b/tests/ssr-transport.test.ts new file mode 100644 index 0000000..32222c7 --- /dev/null +++ b/tests/ssr-transport.test.ts @@ -0,0 +1,91 @@ +import { createCollection, createLiveQueryCollection, eq } from "@tanstack/db" +import { env, runInDurableObject } from "cloudflare:test" +import { describe, expect, it } from "vitest" +import { doCollectionOptions } from "../src/client/do-collection.ts" +import { SsrReadOnlyError, SsrSnapshotTransport, type SnapshotRead } from "../src/client/ssr-transport.ts" + +// WHY (ADR-0011 D2): server rendering must run the SAME collection adapter the +// browser runs — one code path, swapped at the transport seam — with one +// snapshot read per subscription and no socket. These pin: eager preload +// materializes the DO's rows; on-demand loadSubset works under a server-side +// live query preload (the upstream SSR fixture's flagship pattern); the +// render's cursor is the durable high-water mark; and any write during SSR +// fails loud as the design error it is. + +interface Msg { + id: string + body: string +} + +/** Structural schema Api the branded transport carries, so `doCollectionOptions` + * infers Row = Msg for `table: "messages"`. */ +type Api = { collections: { messages: { __row?: Msg } } } + +/** Exactly what an SSR worker passes: the DO stub's RPC, as a function. */ +function makeRead(room: string): SnapshotRead { + const stub = env.SYNC_DO.get(env.SYNC_DO.idFromName(room)) as unknown as { + readSyncSnapshot: (r: Parameters[0], request: Request) => ReturnType + } + // The author closes over the claims-bearing Request; the transport's read + // contract stays {collection, where, ...} only. + return (req) => stub.readSyncSnapshot(req, new Request("https://example.com/ssr", { headers: { "x-user": "anon" } })) +} + +async function seed(room: string, rows: Array<[string, string]>): Promise { + await runInDurableObject(env.SYNC_DO.get(env.SYNC_DO.idFromName(room)), (_i, s) => { + for (const [id, body] of rows) s.storage.sql.exec("INSERT INTO messages(id,body) VALUES(?,?)", id, body) + }) +} + +describe("SsrSnapshotTransport (server-side render path, ADR-0011 D2)", () => { + it("eager: preload materializes the DO's rows and a resumable cursor, no socket", async () => { + const room = `ssrt-eager-${crypto.randomUUID()}` + await seed(room, [ + ["a", "hi"], + ["b", "yo"], + ]) + + const transport = new SsrSnapshotTransport({ read: makeRead(room) }) + const messages = createCollection( + doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id }), + ) + await messages.preload() + + expect(messages.size).toBe(2) + expect(messages.get("a")).toMatchObject({ id: "a", body: "hi" }) + expect(BigInt(transport.appliedCursor)).toBeGreaterThan(0n) // dehydration exports this + }) + + it("on-demand: a server-side live query preload drives loadSubset through one read", async () => { + const room = `ssrt-od-${crypto.randomUUID()}` + await seed(room, [ + ["a", "keep"], + ["b", "drop"], + ]) + + const transport = new SsrSnapshotTransport({ read: makeRead(room) }) + const messages = createCollection( + doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id, syncMode: "on-demand" }), + ) + const kept = createLiveQueryCollection((q) => q.from({ m: messages }).where(({ m }) => eq(m.body, "keep"))) + await kept.preload() + + expect(kept.get("a")).toMatchObject({ id: "a", body: "keep" }) + expect(kept.get("b")).toBeUndefined() + expect(BigInt(transport.appliedCursor)).toBeGreaterThan(0n) + }) + + it("rejects writes during SSR — read-only, fail loud", async () => { + const room = `ssrt-ro-${crypto.randomUUID()}` + await seed(room, [["a", "hi"]]) + + const transport = new SsrSnapshotTransport({ read: makeRead(room) }) + const messages = createCollection( + doCollectionOptions({ transport, table: "messages", getKey: (r) => r.id }), + ) + await messages.preload() + + const tx = messages.insert({ id: "x", body: "nope" }) + await expect(tx.isPersisted.promise).rejects.toThrow(SsrReadOnlyError) + }) +}) diff --git a/tests/test-worker.ts b/tests/test-worker.ts index a1b5da4..07a7da5 100644 --- a/tests/test-worker.ts +++ b/tests/test-worker.ts @@ -213,7 +213,12 @@ export class SyncTestDO extends SyncDurableObject { } protected override parseAttachment(req: Request): Claims { - return { userId: req.headers.get("x-user") ?? "anon" } + const userId = req.headers.get("x-user") ?? "anon" + // Sentinel for the auth-gate tests: parseAttachment is the ONE gate for both + // the WS upgrade and the SSR readSyncSnapshot read (ADR-0011 D1) — a rejecting + // identity must reject both paths, so a tenant check can't be bypassed by SSR. + if (userId === "forbidden") throw new Response("forbidden", { status: 403 }) + return { userId } } }