From f94e3e504a07f74d22427a2b18513a98f107289d Mon Sep 17 00:00:00 2001 From: mdhruvil Date: Thu, 22 Jan 2026 09:59:13 +0530 Subject: [PATCH 01/12] feat: docs --- .context/dofs | 1 - .context/isomorphic-git | 1 - .gitmodules | 6 - docs/custom-git-implementation.md | 680 ++++++++++++++++++++++++++++++ docs/data-storage.md | 365 ++++++++++++++++ docs/git-operations.md | 492 +++++++++++++++++++++ docs/isomorphic-git-analysis.md | 357 ++++++++++++++++ docs/maintenance.md | 420 ++++++++++++++++++ 8 files changed, 2314 insertions(+), 8 deletions(-) delete mode 160000 .context/dofs delete mode 160000 .context/isomorphic-git delete mode 100644 .gitmodules create mode 100644 docs/custom-git-implementation.md create mode 100644 docs/data-storage.md create mode 100644 docs/git-operations.md create mode 100644 docs/isomorphic-git-analysis.md create mode 100644 docs/maintenance.md diff --git a/.context/dofs b/.context/dofs deleted file mode 160000 index a4b1d19..0000000 --- a/.context/dofs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a4b1d19c2c1fa7cafa1e1d96be0df2798628f5ac diff --git a/.context/isomorphic-git b/.context/isomorphic-git deleted file mode 160000 index 93d3ef9..0000000 --- a/.context/isomorphic-git +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 93d3ef99fac2c50290dbc9c09816ceaf49ebb58b diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index f8393c9..0000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule ".context/dofs"] - path = .context/dofs - url = https://github.com/benallfree/dofs -[submodule ".context/isomorphic-git"] - path = .context/isomorphic-git - url = https://github.com/isomorphic-git/isomorphic-git diff --git a/docs/custom-git-implementation.md b/docs/custom-git-implementation.md new file mode 100644 index 0000000..c0be929 --- /dev/null +++ b/docs/custom-git-implementation.md @@ -0,0 +1,680 @@ +# Custom Git Implementation Scope + +Complete list of everything needed for a GitHub-compatible git hosting platform. + +## Legend + +- **P0**: Must have for MVP (clone, push, basic web UI) +- **P1**: Required for usable product (PRs, diff, blame) +- **P2**: GitHub parity (advanced features) +- **P3**: Nice to have (optimization, edge cases) +- [x] Already implemented in Gitflare +- [ ] Not implemented + +--- + +## 1. Object Model + +### 1.1 Object Types + +| Item | Priority | Status | Notes | +| ------------------- | -------- | ------ | ------------------ | +| Blob (file content) | P0 | [x] | via isomorphic-git | +| Tree (directory) | P0 | [x] | via isomorphic-git | +| Commit | P0 | [x] | via isomorphic-git | +| Tag (annotated) | P0 | [x] | via isomorphic-git | + +### 1.2 Object Operations + +| Item | Priority | Status | Notes | +| ---------------------- | -------- | ------ | --------------------------- | +| SHA-1 hashing | P0 | [ ] | Object ID generation | +| SHA-256 hashing | P3 | [ ] | Future git version | +| Object serialization | P0 | [ ] | `type size\0content` format | +| Object deserialization | P0 | [ ] | Parse git object format | +| Zlib compression | P0 | [ ] | For loose objects | +| Zlib decompression | P0 | [ ] | For reading objects | +| Object validation | P0 | [ ] | Verify format, hash | + +--- + +## 2. Pack Files + +### 2.1 Pack Format Reading + +| Item | Priority | Status | Notes | +| ---------------------------- | -------- | ------ | -------------------- | +| Pack header parsing | P0 | [ ] | PACK, version, count | +| OBJ_COMMIT (type 1) | P0 | [ ] | Commit objects | +| OBJ_TREE (type 2) | P0 | [ ] | Tree objects | +| OBJ_BLOB (type 3) | P0 | [ ] | Blob objects | +| OBJ_TAG (type 4) | P0 | [ ] | Annotated tags | +| OBJ_OFS_DELTA (type 6) | P0 | [ ] | Offset delta | +| OBJ_REF_DELTA (type 7) | P0 | [ ] | Reference delta | +| Variable-length int decoding | P0 | [ ] | Size encoding | +| Pack checksum verification | P1 | [ ] | SHA-1 trailer | + +### 2.2 Delta Resolution + +| Item | Priority | Status | Notes | +| --------------------------- | -------- | ------ | ----------------------- | +| Delta instruction parsing | P0 | [ ] | Copy + insert ops | +| Copy instruction | P0 | [ ] | Copy from base | +| Insert instruction | P0 | [ ] | Insert literal data | +| Delta chain resolution | P0 | [ ] | Recursive base lookup | +| Delta chain depth limit | P1 | [ ] | Prevent DoS | +| Cross-pack delta resolution | P1 | [ ] | REF_DELTA to other pack | + +### 2.3 Pack Index (.idx) + +| Item | Priority | Status | Notes | +| ----------------- | -------- | ------ | --------------------- | +| IDX v2 parsing | P3 | [ ] | We use SQLite instead | +| IDX v2 generation | P3 | [ ] | For R2 backup only | +| Fanout table | P3 | [ ] | Part of IDX format | + +### 2.4 Pack Generation (for clone/fetch) + +| Item | Priority | Status | Notes | +| --------------------------- | -------- | ------ | ---------------------- | +| Pack header writing | P0 | [ ] | PACK + version + count | +| Non-delta object writing | P0 | [ ] | Simple case first | +| OFS_DELTA writing | P2 | [ ] | Space optimization | +| REF_DELTA writing | P2 | [ ] | For thin packs | +| Delta generation algorithm | P2 | [ ] | Find similar objects | +| Object ordering (for delta) | P2 | [ ] | Group similar objects | +| Thin pack generation | P2 | [ ] | For fetch efficiency | +| Pack checksum writing | P0 | [ ] | SHA-1 trailer | +| Streaming pack generation | P0 | [ ] | 128MB memory limit | + +### 2.5 Multi-Pack (Optional) + +| Item | Priority | Status | Notes | +| ---------------- | -------- | ------ | -------------------------- | +| Multi-pack index | P3 | [ ] | Single index for all packs | +| Pack selection | P3 | [ ] | Which pack has object | + +--- + +## 3. References + +### 3.1 Ref Types + +| Item | Priority | Status | Notes | +| ------------------------ | -------- | ------ | ----------------------- | +| HEAD | P0 | [x] | Symbolic or direct | +| Branches (refs/heads/\*) | P0 | [x] | Branch refs | +| Tags (refs/tags/\*) | P0 | [x] | Tag refs | +| Symbolic refs | P0 | [x] | HEAD -> refs/heads/main | + +### 3.2 Ref Operations + +| Item | Priority | Status | Notes | +| ------------------- | -------- | ------ | ---------------------- | +| Ref resolution | P0 | [x] | Follow symbolic refs | +| Ref listing | P0 | [x] | List all refs | +| Ref creation | P0 | [x] | Create branch/tag | +| Ref update | P0 | [x] | Fast-forward | +| Ref deletion | P0 | [x] | Delete branch/tag | +| Atomic ref updates | P0 | [x] | Multiple refs at once | +| Ref name validation | P0 | [ ] | Disallow invalid names | +| Reflog | P2 | [ ] | Ref history | + +### 3.3 Ref Constraints + +| Item | Priority | Status | Notes | +| ------------------- | -------- | ------ | ------------------------ | +| Fast-forward check | P0 | [x] | Reject non-ff by default | +| Force push handling | P1 | [ ] | Allow with flag | +| Protected branches | P2 | [ ] | Reject certain updates | +| Default branch | P1 | [ ] | Configurable HEAD | + +--- + +## 4. Git Protocol + +### 4.1 Packet Line Format + +| Item | Priority | Status | Notes | +| ------------------- | -------- | ------ | ----------------------- | +| Pkt-line encoding | P0 | [x] | 4-hex length prefix | +| Pkt-line decoding | P0 | [x] | Parse length + data | +| Flush packet (0000) | P0 | [x] | End of section | +| Delim packet (0001) | P0 | [x] | Protocol v2 | +| Sideband demux | P1 | [x] | Progress/error channels | +| Sideband mux | P1 | [x] | Send progress/errors | + +### 4.2 Protocol Discovery + +| Item | Priority | Status | Notes | +| --------------------------------------- | -------- | ------ | --------------------- | +| GET /info/refs?service=git-upload-pack | P0 | [x] | Clone/fetch discovery | +| GET /info/refs?service=git-receive-pack | P0 | [x] | Push discovery | +| Capability advertisement | P0 | [x] | List capabilities | +| Protocol v1 support | P0 | [x] | Legacy clients | +| Protocol v2 support | P0 | [x] | Modern clients | + +### 4.3 Upload-Pack (Clone/Fetch) + +| Item | Priority | Status | Notes | +| ----------------------- | -------- | ------ | ------------------------ | +| Ref advertisement | P0 | [x] | List refs + capabilities | +| ls-refs command (v2) | P0 | [x] | List refs | +| Want parsing | P0 | [x] | What client wants | +| Have parsing | P0 | [x] | What client has | +| Common ancestor finding | P0 | [x] | Negotiation | +| Pack generation | P0 | [x] | Send objects | +| Shallow clone (depth) | P2 | [ ] | Partial history | +| Shallow clone (since) | P2 | [ ] | History after date | +| Shallow clone (exclude) | P2 | [ ] | Exclude refs | +| Filter (partial clone) | P2 | [ ] | blob:none, tree:depth | +| Include-tag | P1 | [ ] | Send annotated tags | +| Multi-ack | P1 | [ ] | Better negotiation | +| Thin pack | P2 | [ ] | Omit bases client has | + +### 4.4 Receive-Pack (Push) + +| Item | Priority | Status | Notes | +| ---------------------- | -------- | ------ | ------------------------ | +| Ref advertisement | P0 | [x] | List refs + capabilities | +| Command parsing | P0 | [x] | old new refname | +| Pack receiving | P0 | [x] | Stream from client | +| Pack validation | P0 | [x] | Verify checksum | +| Ref update application | P0 | [x] | Update refs | +| Atomic push | P0 | [x] | All or nothing | +| Report status | P0 | [x] | Success/failure per ref | +| Delete refs | P0 | [x] | old 0000 refname | +| Push options | P2 | [ ] | Custom metadata | +| Pre-receive hook | P2 | [ ] | Validation | +| Post-receive hook | P2 | [ ] | Trigger actions | +| Quarantine objects | P2 | [ ] | Validate before merge | + +--- + +## 5. Tree Operations + +### 5.1 Tree Parsing + +| Item | Priority | Status | Notes | +| ----------------------- | -------- | ------ | ----------------------- | +| Tree entry parsing | P0 | [x] | mode name\0sha | +| Mode parsing | P0 | [x] | File type + permissions | +| Tree sorting validation | P1 | [ ] | Git-specific sort order | + +### 5.2 Tree Traversal + +| Item | Priority | Status | Notes | +| -------------------- | -------- | ------ | ------------------ | +| Single-level listing | P0 | [x] | Directory contents | +| Recursive listing | P1 | [ ] | Full tree | +| Path lookup | P0 | [x] | Get entry at path | +| Tree comparison | P1 | [ ] | Find differences | + +### 5.3 Tree Building + +| Item | Priority | Status | Notes | +| -------------------- | -------- | ------ | --------------- | +| Tree entry creation | P1 | [ ] | For commits | +| Tree serialization | P1 | [ ] | To git format | +| Nested tree building | P1 | [ ] | From flat paths | + +--- + +## 6. Commit Operations + +### 6.1 Commit Parsing + +| Item | Priority | Status | Notes | +| --------------------- | -------- | ------ | ---------------------- | +| Tree reference | P0 | [x] | Root tree SHA | +| Parent references | P0 | [x] | Parent commits | +| Author parsing | P0 | [x] | Name, email, timestamp | +| Committer parsing | P0 | [x] | Name, email, timestamp | +| Message extraction | P0 | [x] | Subject + body | +| GPG signature parsing | P2 | [ ] | Extract signature | + +### 6.2 Commit Traversal + +| Item | Priority | Status | Notes | +| ---------------------- | -------- | ------ | ---------------------- | +| Log (linear) | P0 | [x] | First-parent traversal | +| Log (all parents) | P1 | [ ] | Full DAG traversal | +| Log with path filter | P1 | [x] | Commits touching path | +| Log with author filter | P1 | [ ] | By author | +| Log with date filter | P1 | [ ] | Date range | +| Log pagination | P0 | [x] | Offset/limit | +| Topological sort | P1 | [ ] | For display | +| Date sort | P1 | [ ] | By commit date | +| Merge base | P1 | [ ] | Common ancestor | +| Reachability | P1 | [ ] | Is A ancestor of B | + +### 6.3 Commit Creation + +| Item | Priority | Status | Notes | +| ---------------------- | -------- | ------ | ---------------------------- | +| Commit object creation | P1 | [ ] | For merge commits, web edits | +| Commit serialization | P1 | [ ] | To git format | +| GPG signing | P3 | [ ] | Sign commits | + +--- + +## 7. Diff + +### 7.1 Blob Diff + +| Item | Priority | Status | Notes | +| -------------------- | -------- | ------ | --------------------- | +| Myers diff algorithm | P1 | [ ] | Standard diff | +| Patience diff | P2 | [ ] | Better for some cases | +| Histogram diff | P2 | [ ] | Git default | +| Unified diff output | P1 | [x] | Standard format | +| Context lines | P1 | [x] | Surrounding lines | +| Binary detection | P1 | [ ] | Skip binary files | +| Diff stat | P1 | [x] | +/- line counts | +| Word diff | P2 | [ ] | Inline word changes | + +### 7.2 Tree Diff + +| Item | Priority | Status | Notes | +| -------------- | -------- | ------ | -------------------- | +| Added files | P1 | [x] | In new, not in old | +| Deleted files | P1 | [x] | In old, not in new | +| Modified files | P1 | [x] | Changed content | +| Renamed files | P2 | [ ] | Similarity detection | +| Copied files | P2 | [ ] | Similarity detection | +| Mode changes | P1 | [ ] | Permission changes | +| Type changes | P2 | [ ] | File -> dir, etc | + +### 7.3 Commit Diff + +| Item | Priority | Status | Notes | +| ------------------ | -------- | ------ | --------------------- | +| Single parent diff | P1 | [x] | Commit vs parent | +| Merge commit diff | P2 | [ ] | Combined diff | +| Range diff | P2 | [ ] | Compare commit ranges | + +--- + +## 8. Blame + +| Item | Priority | Status | Notes | +| ------------------ | -------- | ------ | --------------------------- | +| Line attribution | P1 | [ ] | Which commit, which line | +| Blame algorithm | P1 | [ ] | Track lines through history | +| Blame with renames | P2 | [ ] | Follow file renames | +| Blame range | P1 | [ ] | Specific line range | +| Incremental blame | P2 | [ ] | Streaming results | +| Ignore revs | P2 | [ ] | Skip formatting commits | + +--- + +## 9. Merge + +### 9.1 Merge Base + +| Item | Priority | Status | Notes | +| --------------------- | -------- | ------ | ---------------- | +| Two-commit merge base | P1 | [ ] | Common ancestor | +| Octopus merge base | P2 | [ ] | Multiple commits | +| Virtual merge base | P2 | [ ] | For criss-cross | + +### 9.2 Three-Way Merge + +| Item | Priority | Status | Notes | +| ---------------------- | -------- | ------ | ------------------------- | +| Tree merge | P1 | [ ] | Directory-level merge | +| Blob merge | P1 | [ ] | File-level merge | +| Conflict detection | P1 | [ ] | Overlapping changes | +| Conflict markers | P1 | [ ] | <<<<<<< ======= >>>>>>> | +| Add/add conflict | P1 | [ ] | Both added same path | +| Modify/delete conflict | P1 | [ ] | One modified, one deleted | +| Rename/rename conflict | P2 | [ ] | Both renamed same file | +| Rename/modify conflict | P2 | [ ] | Rename + modify same file | + +### 9.3 Merge Strategies + +| Item | Priority | Status | Notes | +| ------------ | -------- | ------ | -------------- | +| Merge commit | P1 | [ ] | Standard merge | +| Fast-forward | P1 | [ ] | Linear history | +| Squash merge | P1 | [ ] | Single commit | +| Rebase merge | P2 | [ ] | Replay commits | + +--- + +## 10. Web UI Operations + +### 10.1 Repository Browser + +| Item | Priority | Status | Notes | +| ------------------- | -------- | ------ | ------------------- | +| Tree view | P0 | [x] | Directory listing | +| Blob view | P0 | [x] | File content | +| Syntax highlighting | P0 | [x] | Shiki | +| Raw file download | P0 | [x] | Direct file content | +| Markdown rendering | P0 | [x] | README, etc | +| Image rendering | P1 | [ ] | Inline images | +| PDF rendering | P2 | [ ] | PDF preview | +| Notebook rendering | P2 | [ ] | Jupyter notebooks | + +### 10.2 Commit Browser + +| Item | Priority | Status | Notes | +| ------------- | -------- | ------ | ----------------- | +| Commit list | P0 | [x] | History view | +| Commit detail | P0 | [x] | Single commit | +| Commit diff | P0 | [x] | Changes in commit | +| Commit graph | P2 | [ ] | Visual DAG | + +### 10.3 Branch/Tag UI + +| Item | Priority | Status | Notes | +| ------------------- | -------- | ------ | ------------- | +| Branch list | P0 | [x] | All branches | +| Branch selector | P0 | [x] | Switch branch | +| Branch comparison | P1 | [ ] | Ahead/behind | +| Create branch (web) | P1 | [ ] | From web UI | +| Delete branch (web) | P1 | [ ] | From web UI | +| Tag list | P1 | [ ] | All tags | +| Create tag (web) | P2 | [ ] | From web UI | + +### 10.4 File Operations (Web) + +| Item | Priority | Status | Notes | +| ---------------- | -------- | ------ | ------------- | +| Edit file | P2 | [ ] | Web editor | +| Create file | P2 | [ ] | New file | +| Delete file | P2 | [ ] | Remove file | +| Upload file | P2 | [ ] | Binary upload | +| Rename/move file | P2 | [ ] | Rename path | + +--- + +## 11. Pull Requests + +### 11.1 PR Core + +| Item | Priority | Status | Notes | +| -------------- | -------- | ------ | -------------------- | +| Create PR | P1 | [ ] | From branch | +| PR list | P1 | [ ] | Open/closed/all | +| PR detail view | P1 | [ ] | Description, commits | +| PR diff view | P1 | [ ] | All changes | +| Close PR | P1 | [ ] | Without merge | +| Reopen PR | P1 | [ ] | Closed -> open | + +### 11.2 PR Merge + +| Item | Priority | Status | Notes | +| ------------------------ | -------- | ------ | ---------------- | +| Merge commit | P1 | [ ] | Standard merge | +| Squash merge | P1 | [ ] | Single commit | +| Rebase merge | P2 | [ ] | Replay commits | +| Fast-forward merge | P1 | [ ] | Linear history | +| Merge conflict detection | P1 | [ ] | Pre-merge check | +| Auto-merge | P2 | [ ] | When checks pass | + +### 11.3 PR Review + +| Item | Priority | Status | Notes | +| --------------- | -------- | ------ | ----------------------- | +| PR comments | P1 | [ ] | Discussion | +| Line comments | P1 | [ ] | Comment on diff line | +| Review request | P2 | [ ] | Request review | +| Review approval | P2 | [ ] | Approve/request changes | +| Review dismiss | P2 | [ ] | Dismiss stale review | + +### 11.4 PR Checks + +| Item | Priority | Status | Notes | +| --------------- | -------- | ------ | -------------------- | +| Status checks | P2 | [ ] | CI integration | +| Required checks | P2 | [ ] | Block merge | +| Check runs | P2 | [ ] | GitHub Actions style | + +--- + +## 12. Repository Management + +### 12.1 Repository CRUD + +| Item | Priority | Status | Notes | +| ------------------- | -------- | ------ | ----------------- | +| Create repository | P0 | [x] | New empty repo | +| Repository settings | P0 | [x] | Name, visibility | +| Delete repository | P1 | [ ] | With confirmation | +| Rename repository | P1 | [ ] | Change name | +| Transfer repository | P2 | [ ] | Change owner | +| Archive repository | P2 | [ ] | Read-only mode | + +### 12.2 Repository Features + +| Item | Priority | Status | Notes | +| ----------- | -------- | ------ | ----------------- | +| Fork | P1 | [ ] | Copy repository | +| Star | P2 | [ ] | Bookmark repo | +| Watch | P2 | [ ] | Notifications | +| Topics/tags | P2 | [ ] | Categorization | +| Description | P0 | [x] | Short description | +| Website URL | P2 | [ ] | Project homepage | + +### 12.3 Repository Access + +| Item | Priority | Status | Notes | +| ----------------- | -------- | ------ | ---------------- | +| Public/private | P0 | [x] | Visibility | +| Collaborators | P1 | [ ] | Add users | +| Teams | P2 | [ ] | Group access | +| Permission levels | P1 | [ ] | Read/write/admin | +| Deploy keys | P2 | [ ] | CI access | + +### 12.4 Repository Statistics + +| Item | Priority | Status | Notes | +| ------------------ | -------- | ------ | ------------------ | +| Commit count | P1 | [ ] | Total commits | +| Contributor count | P1 | [ ] | Unique authors | +| Language breakdown | P1 | [ ] | By file extension | +| Repository size | P1 | [ ] | Total size | +| Code frequency | P2 | [ ] | Commits over time | +| Contributor stats | P2 | [ ] | Commits per author | + +--- + +## 13. Issues + +| Item | Priority | Status | Notes | +| ------------------ | -------- | ------ | ------------------- | +| Create issue | P0 | [x] | New issue | +| Issue list | P0 | [x] | All issues | +| Issue detail | P0 | [x] | Single issue | +| Close/reopen issue | P0 | [x] | Status change | +| Issue comments | P0 | [x] | Discussion | +| Labels | P1 | [ ] | Categorization | +| Assignees | P1 | [ ] | Assign to users | +| Milestones | P2 | [ ] | Group issues | +| Issue templates | P2 | [ ] | Predefined forms | +| Issue search | P1 | [ ] | Filter/search | +| Issue references | P2 | [ ] | Link to commits/PRs | + +--- + +## 14. User & Auth + +| Item | Priority | Status | Notes | +| ---------------------- | -------- | ------ | ----------------- | +| User registration | P0 | [x] | Sign up | +| User login | P0 | [x] | Sign in | +| User profile | P0 | [x] | Public page | +| User settings | P0 | [x] | Edit profile | +| Personal access tokens | P0 | [x] | API/git auth | +| SSH keys | P2 | [ ] | SSH git access | +| Two-factor auth | P2 | [ ] | 2FA | +| OAuth login | P2 | [ ] | GitHub/Google/etc | +| Email verification | P1 | [ ] | Verify email | +| Password reset | P1 | [ ] | Forgot password | + +--- + +## 15. Organizations + +| Item | Priority | Status | Notes | +| ---------------- | -------- | ------ | ---------------- | +| Create org | P2 | [ ] | New organization | +| Org profile | P2 | [ ] | Public page | +| Org members | P2 | [ ] | Member list | +| Org teams | P2 | [ ] | Group members | +| Org repositories | P2 | [ ] | Org-owned repos | +| Member roles | P2 | [ ] | Owner/member | +| Team permissions | P2 | [ ] | Per-repo access | + +--- + +## 16. Search + +| Item | Priority | Status | Notes | +| ----------------- | -------- | ------ | ---------------------- | +| Repository search | P1 | [ ] | Find repos | +| Code search | P2 | [ ] | Search file contents | +| Commit search | P2 | [ ] | Search commit messages | +| Issue search | P1 | [ ] | Search issues | +| User search | P1 | [ ] | Find users | +| File finder | P1 | [ ] | Fuzzy file search | + +--- + +## 17. Notifications & Activity + +| Item | Priority | Status | Notes | +| ------------------- | -------- | ------ | ------------------ | +| Activity feed | P1 | [ ] | Recent activity | +| Notifications | P2 | [ ] | User notifications | +| Email notifications | P2 | [ ] | Email alerts | +| Watching | P2 | [ ] | Subscribe to repos | +| @mentions | P2 | [ ] | Notify users | + +--- + +## 18. API + +| Item | Priority | Status | Notes | +| ----------------- | -------- | ------ | ------------------- | +| REST API | P1 | [ ] | Programmatic access | +| GraphQL API | P2 | [ ] | Flexible queries | +| Webhooks | P2 | [ ] | Push notifications | +| API rate limiting | P1 | [ ] | Prevent abuse | +| API documentation | P1 | [ ] | OpenAPI/Swagger | + +--- + +## 19. Storage Layer (Cloudflare-Specific) + +### 19.1 SQLite Index + +| Item | Priority | Status | Notes | +| ------------------- | -------- | ------ | -------------------- | +| Object index table | P0 | [ ] | OID -> pack location | +| Pack registry table | P0 | [ ] | Pack metadata | +| Refs table | P0 | [ ] | Branch/tag storage | +| Index queries | P0 | [ ] | Fast lookups | +| Batch inserts | P0 | [ ] | Efficient indexing | + +### 19.2 R2 Storage + +| Item | Priority | Status | Notes | +| ---------------- | -------- | ------ | ------------------ | +| Pack upload | P0 | [ ] | Store packfiles | +| Pack download | P0 | [ ] | Retrieve packfiles | +| Range reads | P0 | [ ] | Partial pack reads | +| Multipart upload | P1 | [ ] | Large packs | +| Pack deletion | P1 | [ ] | GC cleanup | + +### 19.3 Cache API + +| Item | Priority | Status | Notes | +| ------------------ | -------- | ------ | --------------- | +| Object caching | P0 | [ ] | Parsed objects | +| Cache key design | P0 | [ ] | repo:oid format | +| Cache invalidation | P1 | [ ] | On force push | +| TTL configuration | P1 | [ ] | Expiration | + +--- + +## 20. Validation & Security + +### 20.1 Input Validation + +| Item | Priority | Status | Notes | +| -------------------------- | -------- | ------ | ------------------ | +| Object format validation | P0 | [ ] | Valid git objects | +| Pack checksum verification | P0 | [ ] | SHA-1 check | +| Ref name validation | P0 | [ ] | Valid ref names | +| Path traversal prevention | P0 | [ ] | No ../ escapes | +| Size limits | P0 | [ ] | Max file/pack size | + +### 20.2 Access Control + +| Item | Priority | Status | Notes | +| --------------------- | -------- | ------ | ----------------------- | +| Repository visibility | P0 | [x] | Public/private | +| Push authorization | P0 | [x] | Who can push | +| Branch protection | P2 | [ ] | Restrict changes | +| Force push protection | P1 | [ ] | Prevent history rewrite | + +--- + +## 21. Maintenance & Operations + +### 21.1 Garbage Collection + +| Item | Priority | Status | Notes | +| ---------------------------- | -------- | ------ | --------------------- | +| Unreachable object detection | P1 | [ ] | Find orphaned objects | +| Object deletion | P1 | [ ] | Clean up orphans | +| Pack repacking | P2 | [ ] | Merge small packs | +| Prune old packs | P2 | [ ] | Remove replaced packs | + +### 21.2 Monitoring + +| Item | Priority | Status | Notes | +| ------------------- | -------- | ------ | ------------------- | +| Request logging | P1 | [ ] | Access logs | +| Error tracking | P1 | [ ] | Error reporting | +| Performance metrics | P2 | [ ] | Latency, throughput | +| Storage metrics | P2 | [ ] | Size per repo | + +--- + +## Summary + +### By Priority + +| Priority | Count | Description | +| -------- | ----- | ----------------------------------------- | +| **P0** | ~60 | MVP - clone, push, basic web UI | +| **P1** | ~70 | Usable product - PRs, diff, blame, search | +| **P2** | ~80 | GitHub parity - advanced features | +| **P3** | ~10 | Nice to have | + +### Already Implemented + +- Git protocol (clone/push/fetch) via isomorphic-git +- Basic web UI (tree, blob, commits, issues) +- User auth with PATs +- Repository CRUD + +### Critical Path for Custom Git + +1. **Object model** - types, hashing, serialization +2. **Pack reading** - parse packs, resolve deltas +3. **Pack writing** - generate packs for clone/fetch +4. **Storage layer** - SQLite index, R2 packs, Cache API +5. **Protocol** - replace isomorphic-git in protocol handlers + +### Hardest Parts + +1. **Delta resolution** - recursive base lookup, instruction parsing +2. **Delta generation** - finding similar objects, computing deltas +3. **Blame** - tracking lines through history with renames +4. **Three-way merge** - conflict detection and resolution +5. **Streaming** - all operations within 128MB memory limit diff --git a/docs/data-storage.md b/docs/data-storage.md new file mode 100644 index 0000000..8f884d5 --- /dev/null +++ b/docs/data-storage.md @@ -0,0 +1,365 @@ +# Hybrid Storage Architecture: DO SQLite + R2 + +## Overview + +Gitflare uses a **cost-optimized** hybrid storage strategy: + +- **DO SQLite**: Packfile indices + refs only (minimal, expensive) +- **R2**: Raw packfiles + blobs (bulk, cheap) +- **Cache API**: Parsed commits/trees (free, warm cache) + +## Pricing Reality + +| Storage | Cost/GB/month | Latency | Subrequests | +| --------- | ------------- | --------- | ----------- | +| DO SQLite | **$0.20** | ~0ms | 0 | +| R2 | **$0.015** | 180-250ms | 1 | +| Cache API | **Free** | 9-15ms | 1 | + +**DO SQLite is 13x more expensive than R2!** + +## Cost Comparison + +| Strategy | 1000 Repos Cost/month | Notes | +| ----------------------- | --------------------- | ------------- | +| Everything in DO SQLite | **$100+** | Too expensive | +| Index-only in DO SQLite | **$8.50** | Recommended | + +**92% cost savings** with the optimized strategy. + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Request Flow │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ HIT ┌──────────────┐ │ +│ │ Cache API │───────────▶│ Response │ 9-15ms, FREE │ +│ │(parsed data) │ └──────────────┘ 1 subrequest │ +│ └──────┬───────┘ │ +│ │ MISS │ +│ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ DO SQLite │───────────▶│ Get offset │ ~0ms, $0.20/GB │ +│ │ (index only) │ │ from index │ 0 subrequests │ +│ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ +│ │ ▼ │ +│ │ ┌──────────────┐ │ +│ │ │ R2 Range │ 180-250ms, $0.015/GB │ +│ │ │ Request │ 1 subrequest │ +│ │ └──────┬───────┘ │ +│ │ │ │ +│ │ ▼ │ +│ │ ┌──────────────┐ │ +│ │ │ Parse + Store│ Store in Cache API │ +│ │ │ in Cache API │ for next time │ +│ │ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## What to Store Where + +### DO SQLite (Expensive - Minimize!) + +| Data | Size | Cost | Why Here | +| --------------------------- | ---------------- | ---------- | ------------------------- | +| Packfile index (oid→offset) | ~28 bytes/object | $0.20/GB | Required for O(1) lookups | +| Refs (HEAD, branches, tags) | ~1KB total | Negligible | Tiny, always needed | +| Pack registry | ~100B/pack | Negligible | Track packfiles | + +**Total per repo**: ~15-20MB for 500K objects = **$0.003-0.004/repo/month** + +### R2 (Cheap - Bulk Storage) + +| Data | Size | Cost | Why Here | +| --------------------- | ---------- | --------- | ------------------ | +| Raw packfiles (.pack) | Variable | $0.015/GB | Cheap bulk storage | +| Backup index (.idx) | ~14MB/500K | Cheap | Disaster recovery | + +### Cache API (Free - Parsed Objects) + +| Data | TTL | Max Size | Why Here | +| ------------------- | -------- | -------- | ------------------------------ | +| Parsed commits | 1 year | 1KB | Immutable, frequently accessed | +| Parsed trees | 1 year | 50KB | Immutable, frequently accessed | +| Small blobs (<10KB) | 1 week | 10KB | Moderate frequency | +| Ref resolution | 1 minute | 100B | Mutable, short-lived | + +## SQLite Schema (Minimal - Index Only) + +```sql +-- Packfile index: The ONLY large data in SQLite +-- Size: ~28 bytes per object +CREATE TABLE git_object_index ( + oid TEXT PRIMARY KEY, -- 40-char SHA + pack_id TEXT NOT NULL, -- which packfile + pack_offset INTEGER NOT NULL, -- byte offset in .pack + pack_size INTEGER NOT NULL, -- compressed size in pack + type INTEGER NOT NULL, -- 1=commit, 2=tree, 3=blob, 4=tag + is_delta INTEGER DEFAULT 0, + base_oid TEXT +); + +CREATE INDEX idx_object_pack ON git_object_index(pack_id); + +-- Pack registry: tiny +CREATE TABLE git_packs ( + pack_id TEXT PRIMARY KEY, + r2_key TEXT NOT NULL, -- R2 location + object_count INTEGER, + size_bytes INTEGER, + created_at INTEGER +); + +-- Refs: tiny +CREATE TABLE git_refs ( + name TEXT PRIMARY KEY, -- 'refs/heads/main', 'HEAD' + value TEXT NOT NULL, + updated_at INTEGER +); +``` + +**No object data stored in SQLite! Only the index.** + +## Size & Cost Estimates + +### Per-Repo Storage Costs + +| Repo Size | Objects | DO SQLite | R2 | DO Cost/mo | R2 Cost/mo | Total | +| ------------ | ------- | --------- | ----- | ---------- | ---------- | -------- | +| Small (1K) | 1,000 | 30KB | 8MB | $0.000006 | $0.00012 | ~$0.0001 | +| Medium (50K) | 50,000 | 1.4MB | 700MB | $0.00028 | $0.0105 | ~$0.011 | +| Large (500K) | 500,000 | 14MB | 1.8GB | $0.0028 | $0.027 | ~$0.030 | + +### Platform at Scale (1000 repos) + +| Component | Size | Cost/month | +| ------------------------ | ------ | ---------- | +| DO SQLite (indices only) | ~5GB | $1.00 | +| R2 (packfiles) | ~500GB | $7.50 | +| Cache API | N/A | Free | +| **Total** | | **$8.50** | + +vs $100+/month if storing everything in DO SQLite. + +## Read Path + +```typescript +async function readObject(oid: string): Promise { + // 1. Check Cache API FIRST (FREE, 9-15ms, 1 subrequest) + const cacheKey = `obj:${repoId}:${oid}`; + const cached = await caches.default.match( + new Request(`https://cache/${cacheKey}`), + ); + if (cached) { + return cached.json(); + } + + // 2. Lookup offset in DO SQLite (0ms, 0 subrequests) + const index = await db.query( + ` + SELECT pack_id, pack_offset, pack_size, type, is_delta, base_oid + FROM git_object_index WHERE oid = ? + `, + [oid], + ); + + if (!index) throw new Error(`Object not found: ${oid}`); + + // 3. R2 range request (180-250ms, 1 subrequest) + const packKey = `${repoId}/packs/${index.pack_id}.pack`; + const raw = await env.R2.get(packKey, { + range: { offset: index.pack_offset, length: index.pack_size }, + }); + + // 4. Decompress and parse + const data = await inflate(await raw.arrayBuffer()); + const object = parseObject(data, index.type); + + // 5. Resolve delta if needed (base likely in Cache API) + if (index.is_delta) { + const base = await readObject(index.base_oid); + object.data = applyDelta(object.data, base.data); + } + + // 6. Store in Cache API (FREE) for future reads + if (shouldCache(object)) { + await caches.default.put( + new Request(`https://cache/${cacheKey}`), + new Response(JSON.stringify(object), { + headers: { "Cache-Control": `max-age=${getCacheTTL(object.type)}` }, + }), + ); + } + + return object; +} +``` + +## Write Path (Push) + +```typescript +async function receivePack(packStream: ReadableStream) { + const packId = `pack-${Date.now()}`; + + // 1. Stream directly to R2 (1 subrequest, cheap storage) + await env.R2.put(`${repoId}/packs/${packId}.pack`, packStream); + + // 2. Parse pack to build index (fetch from R2 for parsing) + const packData = await env.R2.get(`${repoId}/packs/${packId}.pack`); + const index = await parsePackIndex(packData.body); + + // 3. Store index in DO SQLite (small, fast lookups) + await db.transaction(async () => { + for (const obj of index.objects) { + await db.exec( + ` + INSERT INTO git_object_index + (oid, pack_id, pack_offset, pack_size, type, is_delta, base_oid) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, + [ + obj.oid, + packId, + obj.offset, + obj.size, + obj.type, + obj.isDelta, + obj.baseOid, + ], + ); + } + + await db.exec( + ` + INSERT INTO git_packs (pack_id, r2_key, object_count, size_bytes, created_at) + VALUES (?, ?, ?, ?, ?) + `, + [ + packId, + `${repoId}/packs/${packId}.pack`, + index.objects.length, + packData.size, + Date.now(), + ], + ); + }); + + // 4. Warm Cache API with commits/trees (FREE) + ctx.waitUntil(warmCache(index.objects)); + + // Total: 2-3 R2 subrequests (PUT + GET for parsing) +} + +async function warmCache(objects: PackObject[]) { + for (const obj of objects) { + if (obj.type === "commit" || obj.type === "tree") { + // Parse and cache in Cache API + const parsed = await readObject(obj.oid); + // Already cached by readObject + } + } +} +``` + +## Caching Configuration + +```typescript +const CACHE_CONFIG = { + // What to cache in Cache API (FREE) + cacheTypes: ["commit", "tree"], // Always cache these + maxBlobSize: 10 * 1024, // Only cache blobs <10KB + + // TTLs (objects are immutable by SHA) + ttl: { + commit: 365 * 24 * 60 * 60, // 1 year + tree: 365 * 24 * 60 * 60, // 1 year + blob: 7 * 24 * 60 * 60, // 1 week + ref: 60, // 1 minute (mutable) + }, +}; + +function shouldCache(object: GitObject): boolean { + if (object.type === "commit" || object.type === "tree") { + return true; // Always cache - small and frequently accessed + } + if (object.type === "blob" && object.data.length < CACHE_CONFIG.maxBlobSize) { + return true; // Cache small blobs + } + return false; // Don't cache large blobs +} +``` + +## Latency Analysis + +### Common Operations + +| Operation | Cache Hit | Cache Miss | Subrequests | +| ------------------------ | --------- | ---------- | ----------- | +| Browse repo (4 objects) | 36-60ms | 720-1000ms | 4-8 | +| View commit history (20) | 180-300ms | 3.6-5s | 20-40 | +| View file | 9-30ms | 200-300ms | 1-3 | +| Clone (full) | N/A | 200-500ms | 1 | + +### Expected Cache Hit Rates + +| Object Type | Hit Rate | Reasoning | +| -------------- | -------- | ---------------------------- | +| Recent commits | 80-95% | Users browse recent history | +| Root tree | 90%+ | README, root always accessed | +| Deep paths | 30-50% | Less frequent | +| Large blobs | N/A | Never cached | + +**After warmup**: Most operations hit Cache API (free, fast). + +## Subrequest Budget + +| Operation | Max Subrequests | +| --------------- | --------------- | +| Browse repo | 4-8 | +| Log 100 commits | 100-200 | +| View file | 1-5 | +| Clone (stream) | 1-2 | +| Push | 2-3 | + +All well under the 1000 limit. + +## Cost Optimization Summary + +1. **DO SQLite**: Index only (~28 bytes/object) - no object data! +2. **R2**: All packfiles and raw object data +3. **Cache API**: Parsed commits/trees/small blobs (FREE) + +| What | Where | Why | +| -------------- | --------- | -------------------------- | +| Object index | DO SQLite | Required for lookups, tiny | +| Refs | DO SQLite | Tiny, always needed | +| Packfiles | R2 | Cheap bulk storage | +| Parsed objects | Cache API | Free, fast, immutable | + +## Comparison: Old vs New + +| Metric | Old (All SQLite) | New (Hybrid) | Improvement | +| --------------- | ---------------- | --------------- | --------------- | +| DO SQLite/repo | ~500MB | ~15MB | **97% smaller** | +| Cost (1K repos) | ~$100/mo | ~$8.50/mo | **92% cheaper** | +| Browse latency | ~0ms | ~15ms cache hit | Acceptable | +| Clone latency | N/A | ~200ms R2 | Same | + +## Memory Considerations + +Workers/DO have 128MB memory limit: + +| Component | Allocation | +| -------------------- | ---------- | +| V8 overhead | ~10MB | +| SQLite (index only) | ~5MB | +| Request handling | ~20MB | +| Object decompression | ~30MB | +| Delta resolution | ~30MB | +| Safety margin | ~33MB | + +**Key**: SQLite only holds index (~15MB), not object data. Safe within limits. diff --git a/docs/git-operations.md b/docs/git-operations.md new file mode 100644 index 0000000..efad36f --- /dev/null +++ b/docs/git-operations.md @@ -0,0 +1,492 @@ +# Git Operations in Hybrid Architecture + +## Overview + +This document details how core git operations work with the **cost-optimized** hybrid architecture: + +- **DO SQLite**: Index only (cheap lookups, ~$0.003/repo/month) +- **Cache API**: Parsed commits/trees (free, warm cache) +- **R2**: Raw packfiles (cheap bulk storage) + +## Platform Constraints Recap + +| Constraint | Limit | Critical For | +| ----------- | ------------ | ----------------------- | +| Subrequests | 1000/request | Clone, fetch operations | +| Memory | 128MB | Packfile generation | +| CPU (DO) | 30s-300s | Index building, GC | + +### Latency & Cost Reference + +| Operation | Latency | Subrequests | Cost | +| --------- | --------- | ----------- | --------- | +| DO SQLite | ~0ms | 0 | $0.20/GB | +| Cache API | 9-15ms | 1 | **Free** | +| R2 read | 180-250ms | 1 | $0.015/GB | + +## Operation Flows + +### 1. Clone (Full) + +**Strategy**: Stream existing packfile from R2 (1 subrequest) instead of individual object reads (impossible at scale). + +``` +┌─────────┐ ┌─────────────────┐ ┌─────────────┐ +│ Client │───▶│ upload-pack │───▶│ R2 Stream │ +│ Request │ │ (check refs) │ │ (1 subreq) │ +└─────────┘ └────────┬────────┘ └─────────────┘ + │ + SQLite: refs, pack info + (0 subrequests) +``` + +**Subrequest Analysis**: + +| Step | Subrequests | +| ---------------------- | ----------- | +| Read refs (SQLite) | 0 | +| Get pack info (SQLite) | 0 | +| Stream packfile (R2) | 1 | +| **Total** | **1** | + +**Why this works for 500K objects**: + +- Individual reads: 500K subrequests ❌ +- Stream pack: 1 subrequest ✅ + +### 2. Fetch (Incremental) + +Client has some commits, wants only new ones. + +``` +┌─────────┐ ┌─────────────────┐ ┌─────────────┐ +│ Client │───▶│ Negotiation │───▶│ SQLite walk │ +│ want/have │ (find common) │ │ (0 subreqs) │ +└─────────┘ └────────┬────────┘ └─────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Generate pack │ + │ from cache/R2 │ + └─────────────────┘ +``` + +**Subrequest Analysis** (fetch 100 new commits): + +| Step | Subrequests | +| ---------------------------------- | --------------- | +| Walk commit graph (SQLite cache) | 0 | +| Collect object list (SQLite index) | 0 | +| Read objects from cache | 0 | +| R2 reads for cache misses | 10-50 (batched) | +| **Total** | **10-50** | + +**Optimization**: Batch R2 reads by pack, use range requests. + +### 3. Push (Receive Pack) + +Client sends new objects to server. + +``` +┌─────────┐ ┌─────────────────┐ ┌─────────────┐ +│ Client │───▶│ Stream to R2 │───▶│ R2 Storage │ +│ Packfile│ │ (1 subrequest) │ │ │ +└─────────┘ └────────┬────────┘ └─────────────┘ + │ + ▼ Parse & Index (0 subreqs) + ┌─────────────────┐ + │ SQLite Updates │ + │ - Object index │ + │ - Hot cache │ + │ - Refs │ + └─────────────────┘ +``` + +**Subrequest Analysis** (push 5MB, 1000 objects): + +| Step | Subrequests | +| --------------------------- | ----------- | +| Stream pack to R2 | 1 | +| Parse pack (in-memory) | 0 | +| Build object index (SQLite) | 0 | +| Populate hot cache (SQLite) | 0 | +| Upload idx to R2 | 1 | +| Update refs (SQLite) | 0 | +| **Total** | **2** | + +### 4. git.log (Commit History) + +**Best case**: All commits in Cache API (free). + +``` +┌─────────┐ ┌─────────────────┐ ┌─────────────┐ +│ Request │───▶│ Cache API │───▶│ Return │ +│ log(20) │ │ (9-15ms, FREE) │ │ fast │ +└─────────┘ └─────────────────┘ └─────────────┘ +``` + +**Subrequest Analysis**: + +| Scenario | Subrequests | Latency | +| ------------------ | ----------- | ---------- | +| All in Cache API | 20 | ~180-300ms | +| 50% cache miss | 30 | ~2-3s | +| Cold (none cached) | 40 | ~4-5s | + +**Why Cache API**: Commits are cached after first access. Free storage, acceptable latency. + +### 5. git.readBlob (View File) + +Path: commit → tree → subtree(s) → blob + +**Subrequest Analysis** (read src/index.ts): + +| Object | Cached? | Subrequests | +| ------------- | ------------------- | ----------- | +| Commit | Yes (always cached) | 0 | +| Root tree | Yes (always cached) | 0 | +| src/ tree | Yes (likely cached) | 0 | +| index.ts blob | Maybe | 0-1 | +| **Total** | | **0-1** | + +**Latency**: + +- Cached: ~1ms (all SQLite) +- Uncached blob: ~200ms (1 R2 read) + +### 6. git.readTree (Browse Directory) + +``` +┌─────────┐ ┌─────────────────┐ +│ Request │───▶│ SQLite cache │ +│ /src │ │ (commits/trees) │ +└─────────┘ └─────────────────┘ + │ + All trees cached + │ + ▼ + ┌─────────────────┐ + │ Return tree │ + │ entries │ + └─────────────────┘ +``` + +**Subrequest Analysis**: Typically **0** (trees always cached). + +### 7. Pull Requests + +PRs are application-level. Git operations involved: + +**Create PR**: + +| Step | Subrequests | +| --------------------- | ----------- | +| Push branch | 2 | +| Create PR in D1 | 0 (not DO) | +| Compute diff (cached) | 0 | +| **Total** | **2** | + +**Merge PR**: + +| Step | Subrequests | +| ------------------------------ | ----------- | +| Check merge conflicts (SQLite) | 0 | +| Create merge commit | 0-2 | +| Update ref (SQLite) | 0 | +| Close PR (D1) | 0 | +| **Total** | **0-2** | + +## Subrequest Budget Summary + +| Operation | Typical | Worst Case | Limit | +| ------------------- | ------- | ---------- | ------- | +| Clone (full) | 1 | 3 | 1000 ✅ | +| Fetch (100 commits) | 10-50 | 200 | 1000 ✅ | +| Push (5MB) | 2 | 4 | 1000 ✅ | +| Log (20 commits) | 0 | 20 | 1000 ✅ | +| Read file | 0-1 | 4 | 1000 ✅ | +| Browse tree | 0 | 10 | 1000 ✅ | + +## Streaming Packfile Generation + +For large repos, we can't buffer entire packfile in memory. + +### Memory-Efficient Approach + +```typescript +async function* streamingPack(oids: string[]): AsyncGenerator { + const hash = new SHA1(); + + // 1. Header (12 bytes) + const header = encodePackHeader(oids.length); + hash.update(header); + yield header; + + // 2. Objects one at a time + for (const oid of oids) { + // Check Cache API first (FREE, 1 subrequest) + const cacheKey = `obj:${repoId}:${oid}`; + let object = await caches.default.match( + new Request(`https://cache/${cacheKey}`), + ); + + if (!object) { + // Cache miss - lookup index (0 subrequests) then R2 read (1 subrequest) + const index = await db.query( + `SELECT * FROM git_object_index WHERE oid = ?`, + [oid], + ); + object = await fetchFromR2( + index.pack_id, + index.pack_offset, + index.pack_size, + ); + + // Cache for next time (FREE) + ctx.waitUntil( + caches.default.put( + new Request(`https://cache/${cacheKey}`), + new Response(object), + ), + ); + } + + // Encode and yield + const encoded = encodeObject(object.type, object.data); + hash.update(encoded); + yield encoded; + + // Object can be GC'd now + } + + // 3. Checksum + yield hash.digest(); +} +``` + +**Memory**: O(largest_single_object) ≈ 10-50MB + +### Pack Reuse (Optimal for Clone) + +If most objects are in one packfile, stream it directly: + +```typescript +async function uploadPack(wants: string[], haves: string[]) { + // Check if full clone (no haves) + if (haves.length === 0) { + // Find pack containing all/most objects + const mainPack = await db.query(` + SELECT pack_id, COUNT(*) as coverage + FROM git_objects + GROUP BY pack_id + ORDER BY coverage DESC + LIMIT 1 + `); + + if (mainPack.coverage > totalObjects * 0.9) { + // Stream existing pack directly (1 subrequest!) + return r2.get(mainPack.r2_pack_key).body; + } + } + + // Partial fetch - generate new pack + const objectsNeeded = await collectObjects(wants, haves); + return generatorToStream(streamingPack(objectsNeeded)); +} +``` + +## Batching R2 Reads + +When Cache API misses occur, batch R2 reads to minimize subrequests: + +```typescript +async function batchReadObjects( + oids: string[], +): Promise> { + const results = new Map(); + const toFetch: Array<{ + oid: string; + packId: string; + offset: number; + size: number; + }> = []; + + // 1. Check Cache API first (FREE, 1 subrequest per check) + for (const oid of oids) { + const cacheKey = `obj:${repoId}:${oid}`; + const cached = await caches.default.match( + new Request(`https://cache/${cacheKey}`), + ); + if (cached) { + results.set(oid, await cached.json()); + } else { + // Get index from SQLite (0 subrequests) + const index = await db.query( + `SELECT * FROM git_object_index WHERE oid = ?`, + [oid], + ); + toFetch.push({ oid, ...index }); + } + } + + if (toFetch.length === 0) return results; + + // 2. Group by pack + const byPack = groupBy(toFetch, "packId"); + + // 3. Batch read each pack (1 subrequest per pack) + for (const [packId, objects] of byPack) { + // Sort by offset for sequential access + objects.sort((a, b) => a.offset - b.offset); + + // Calculate range covering all objects + const start = objects[0].offset; + const end = + objects[objects.length - 1].offset + objects[objects.length - 1].size; + + // Single range read (1 subrequest) + const data = await r2.get(packId, { + range: { offset: start, length: end - start }, + }); + + // Extract individual objects from response + for (const obj of objects) { + const slice = data.slice( + obj.offset - start, + obj.offset - start + obj.size, + ); + results.set(obj.oid, parseObject(slice)); + + // Populate cache for future reads + await cacheObject(obj.oid, slice); + } + } + + return results; +} +``` + +**Example**: Reading 100 objects from 2 packs = 2 subrequests (not 100). + +## Delta Resolution + +When reading delta-compressed objects: + +```typescript +async function resolveObject(oid: string): Promise { + // 1. Check Cache API (FREE) + const cacheKey = `obj:${repoId}:${oid}`; + const cached = await caches.default.match( + new Request(`https://cache/${cacheKey}`), + ); + if (cached) return cached.json(); + + // 2. Get index from SQLite (0 subrequests) + const index = await db.query(`SELECT * FROM git_object_index WHERE oid = ?`, [ + oid, + ]); + + // 3. Fetch from R2 (1 subrequest) + const raw = await fetchFromR2( + index.pack_id, + index.pack_offset, + index.pack_size, + ); + + let object: GitObject; + if (!index.is_delta) { + object = parseObject(raw); + } else { + // Delta - resolve base (likely in Cache API) + const base = await resolveObject(index.base_oid); // Recursive + object = applyDelta(raw, base); + } + + // 4. Cache resolved object in Cache API (FREE) + await caches.default.put( + new Request(`https://cache/${cacheKey}`), + new Response(JSON.stringify(object)), + ); + + return object; +} +``` + +**Typical delta chain**: 1-3 levels, bases usually cached. + +## Error Handling + +### Subrequest Limit Exceeded + +```typescript +async function safeFetch(objectCount: number) { + if (objectCount > 500) { + // Risk of hitting limit - use pack streaming + return streamEntirePack(); + } + + // Safe to do individual reads + return batchReadObjects(oids); +} +``` + +### Push Conflicts + +```typescript +async function receivePack(packfile: ReadableStream, refs: RefUpdate[]) { + // 1. Validate refs BEFORE accepting packfile (0 subrequests) + for (const ref of refs) { + const current = await db.query( + `SELECT value FROM git_refs WHERE name = ?`, + [ref.name], + ); + if (current?.value !== ref.oldOid) { + throw new Error(`ref ${ref.name} conflict`); + } + } + + // 2. Stream packfile to R2 (1 subrequest) + const packId = await streamToR2(packfile); + + // 3. Atomic ref update (0 subrequests) + await db.transaction(async () => { + for (const ref of refs) { + await db.exec(`UPDATE git_refs SET value = ? WHERE name = ?`, [ + ref.newOid, + ref.name, + ]); + } + }); +} +``` + +## Latency Optimization Summary + +| Optimization | Impact | +| ---------------------------- | --------------------------------- | +| Cache API for parsed objects | FREE storage, eliminates R2 reads | +| SQLite for index only | Fast lookups, minimal cost | +| Batch R2 reads | N objects → 1-2 subrequests | +| Stream pack for clone | 500K objects → 1 subrequest | +| Cache on push | New objects immediately fast | + +## Cost vs Latency Tradeoff + +| Approach | Cost | Latency | Subrequests | +| --------------------------- | ------------------ | ------- | ----------- | +| All in DO SQLite | **$100/1K repos** | ~0ms | 0 | +| Index in SQLite + Cache API | **$8.50/1K repos** | ~15ms | 1/object | +| All in R2 | **$7.50/1K repos** | ~200ms | 1/object | + +**Chosen**: Index in SQLite + Cache API (best balance) + +## Comparison: Cost-Optimized Architecture + +| Operation | Cache Hit | Cache Miss | +| ----------------------- | --------------------- | -------------- | +| Browse repo (4 objects) | ~60ms, 4 subs, FREE | ~800ms, 8 subs | +| Log 20 commits | ~300ms, 20 subs, FREE | ~4s, 40 subs | +| View file | ~15ms, 1 sub, FREE | ~250ms, 2 subs | +| Clone | N/A | ~200ms, 1 sub | + +After warmup, most operations hit Cache API → fast and free. diff --git a/docs/isomorphic-git-analysis.md b/docs/isomorphic-git-analysis.md new file mode 100644 index 0000000..91550e7 --- /dev/null +++ b/docs/isomorphic-git-analysis.md @@ -0,0 +1,357 @@ +# isomorphic-git Viability Analysis + +## Executive Summary + +isomorphic-git is **highly adaptable** for the **cost-optimized** hybrid architecture: + +| Constraint | Impact | Mitigation | +| -------------------- | ------------------------------ | -------------------------------- | +| 1000 subrequests | Can't do 1000+ R2 calls | Cache API (free), pack streaming | +| 128MB memory | Can't buffer large packs | Streaming generators | +| $0.20/GB DO SQLite | Too expensive for object cache | Index only in SQLite | +| 180-250ms R2 latency | Slow cold reads | Cache API (free, 9-15ms) | + +### Cost-Optimized Storage + +| Storage | Cost | Use For | +| --------- | --------- | -------------------------------- | +| DO SQLite | $0.20/GB | Index only (~15MB/500K objects) | +| Cache API | **Free** | Parsed commits/trees/small blobs | +| R2 | $0.015/GB | Raw packfiles | + +## FS Adapter Architecture + +### Constraint-Aware Design + +The hybrid FS adapter must minimize subrequests: + +```typescript +class HybridFS { + async readFile(path: string): Promise { + // 1. Packfiles → Check SQLite cache FIRST (0 subrequests) + if (path.match(/objects\/pack\/.*\.pack$/)) { + return this.readPackfile(path); // Cache or R2 + } + + // 2. IDX files → SQLite has object index (no idx needed) + if (path.match(/objects\/pack\/.*\.idx$/)) { + // We don't need idx files - SQLite has the index + throw new Error("IDX files replaced by SQLite index"); + } + + // 3. Loose objects → SQLite cache (0 subrequests) + if (path.match(/objects\/[0-9a-f]{2}\/[0-9a-f]{38}$/)) { + return this.readFromSQLiteCache(path); + } + + // 4. Refs, HEAD → SQLite (0 subrequests) + return this.sqlite.read(path); + } +} +``` + +### Subrequest Analysis per Method + +| Method | Original | Hybrid | +| --------------------- | ---------- | -------------------------- | +| `readFile` (ref) | 1 FS read | 0 (SQLite) | +| `readFile` (object) | 1 FS read | 0 (SQLite cache) or 1 (R2) | +| `readFile` (packfile) | 1 FS read | 0-1 (SQLite/R2) | +| `readdir` (pack/) | 1 FS read | 0 (SQLite query) | +| `writeFile` | 1 FS write | 0-1 (SQLite/R2) | + +## Object Reading: Bypass .idx Files + +### Original isomorphic-git Flow + +``` +readObjectPacked(oid): + 1. readdir("objects/pack") → List .idx files + 2. For each .idx: + - fs.read(idx) → Load entire idx file + - GitPackIndex.fromIdx() → Parse to Map + - if offsets.has(oid): + - fs.read(pack) → Load ENTIRE packfile + - readSlice(offset) → Extract object +``` + +**Problems**: + +- Scans all .idx files (slow) +- Loads entire packfile into memory +- Each read = 1+ subrequests + +### Cost-Optimized Approach + +```typescript +async function readObject(oid: string): Promise { + // 1. Check Cache API FIRST (FREE, 9-15ms, 1 subrequest) + const cacheKey = `obj:${repoId}:${oid}`; + const cached = await caches.default.match( + new Request(`https://cache/${cacheKey}`), + ); + if (cached) { + return cached.json(); + } + + // 2. Lookup in SQLite index (0 subrequests, 0ms) + const index = await db.query( + ` + SELECT pack_id, pack_offset, pack_size, is_delta, base_oid + FROM git_object_index WHERE oid = ? + `, + [oid], + ); + + if (!index) throw new Error(`Object not found: ${oid}`); + + // 3. R2 range read (1 subrequest, 180-250ms) + const raw = await r2.get(`packs/${index.pack_id}.pack`, { + range: { offset: index.pack_offset, length: index.pack_size }, + }); + + // 4. Resolve delta if needed (base likely in Cache API) + let object: GitObject; + if (index.is_delta) { + const base = await readObject(index.base_oid); // Recursive, likely cached + object = applyDelta(raw, base); + } else { + object = inflate(raw); + } + + // 5. Cache in Cache API (FREE!) + await caches.default.put( + new Request(`https://cache/${cacheKey}`), + new Response(JSON.stringify(object), { + headers: { "Cache-Control": "max-age=31536000" }, // 1 year (immutable) + }), + ); + + return object; +} +``` + +**Cost-optimized**: + +- Cache API is FREE (not SQLite at $0.20/GB) +- 1 subrequest per Cache API check (acceptable) +- SQLite only for index lookups (~15MB total) + +## Packfile Generation + +### Original isomorphic-git + +```javascript +// _pack() in isomorphic-git +const outputStream = []; // Accumulates ALL objects in memory +for (const oid of oids) { + const obj = await readObject(oid); // Full object in memory + outputStream.push(deflate(obj)); +} +return outputStream; // Entire pack in memory +``` + +**Memory**: O(all_objects) = 100MB+ for large repos + +### Streaming Approach + +```typescript +async function* streamingPack(oids: string[]): AsyncGenerator { + const hash = new SHA1(); + + // Header + yield* hashAndYield(encodePackHeader(oids.length), hash); + + for (const oid of oids) { + // Read one object (from SQLite cache or R2) + const object = await readObject(oid); + + // Encode and yield immediately + const encoded = encodePackObject(object); + yield* hashAndYield(encoded, hash); + + // Object can be GC'd now - memory stays constant + } + + // Trailer + yield hash.digest(); +} +``` + +**Memory**: O(largest_single_object) = 10-50MB + +### Subrequest Budget for Pack Generation + +| Scenario | Objects | Cache API Hits | R2 Reads | Total Subs | +| ------------------ | ------- | -------------- | -------- | ----------------- | +| Clone (all cached) | 500K | 500K | 0 | 500K (free) | +| Clone (90% cached) | 500K | 450K | 50K | 500K (but 50K R2) | +| Clone (pack reuse) | 500K | 0 | 1 | **1 ✅** | + +**Solution**: For large operations, stream existing packfile instead of generating. +**Note**: Cache API subrequests are acceptable (free), but R2 reads should be minimized. + +## Delta Compression + +### Reading Deltas ✅ + +isomorphic-git fully supports reading deltas: + +```javascript +// GitPackIndex.readSlice() handles: +// - OFS_DELTA: base at offset in same pack +// - REF_DELTA: base by OID +``` + +**Our optimization**: Base objects usually in Cache API (free). + +### Writing Deltas ❌ + +isomorphic-git does NOT write deltas. Generated packs are 2-3x larger. + +**Mitigation**: + +1. **Pack reuse**: Stream client's original pack (has deltas) +2. **Accept larger packs** for generated content +3. **Future**: Implement windowed delta compression + +## Component Reusability Matrix + +### Can Use Directly + +| Component | Use As-Is | Notes | +| ------------------------- | --------- | ------------------------ | +| `GitCommit/Tree/Tag/Blob` | ✅ | Object parsing | +| `applyDelta()` | ✅ | Delta resolution | +| `inflate()/deflate()` | ✅ | Compression | +| `GitObject.wrap/unwrap` | ✅ | Object framing | +| `listpack()` | ✅ | Pack parsing for receive | + +### Need Adaptation + +| Component | Changes | Cost Impact | +| --------------- | ---------------------------- | --------------------------- | +| `readObject` | Use SQLite index + Cache API | Index in SQLite, cache FREE | +| `readPackIndex` | Skip (use SQLite index) | Eliminates idx reads | +| Object caching | Cache API instead of SQLite | **FREE** (vs $0.20/GB) | +| `GitRefManager` | SQLite instead of FS | Tiny, negligible cost | + +### Need Rewrite + +| Component | Why | Approach | +| ------------------- | -------------------- | ---------------- | +| `_pack()` | Memory, no streaming | Async generator | +| `indexPack()` | Memory | Streaming parser | +| `fetch()` client | Memory | Stream to R2 | +| receive-pack server | Not implemented | Custom | + +## Memory Analysis (128MB Budget) + +| Component | Allocation | +| ---------------------------------- | -------------- | +| V8 overhead | ~10MB | +| SQLite (index only, ~15MB on disk) | ~5MB in memory | +| Request handling | ~20MB | +| Single object | ~30MB | +| Delta chain (3 deep) | ~30MB | +| **Available** | ~33MB | + +**Note**: Object cache is in Cache API (Cloudflare edge), not in DO memory. + +### Safe Operations + +| Operation | Peak Memory | Safe? | +| ------------------------- | ----------- | ----- | +| Read single object | ~30MB | ✅ | +| Resolve delta chain | ~50MB | ✅ | +| Generate pack (streaming) | ~50MB | ✅ | +| Parse incoming pack | ~50MB | ✅ | + +### Unsafe Operations + +| Operation | Peak Memory | Mitigation | +| ---------------------- | ----------- | ----------------- | +| Original `_pack()` | Unbounded | Rewrite streaming | +| Original `indexPack()` | Pack size | Rewrite streaming | +| Original `fetch()` | Pack size | Stream to R2 | + +## Implementation Phases + +### Phase 1: Read Path (Cost-Optimized) + +```typescript +// Custom readObject: Cache API (free) + SQLite index +async function readObject(oid: string) { + // 1. Cache API → 1 subrequest, FREE + // 2. SQLite index → 0 subrequests, ~$0.003/repo + // 3. R2 range read → 1 subrequest, cache miss only +} + +// Use isomorphic-git's parsing +import { GitCommit, GitTree, GitBlob } from "isomorphic-git"; +``` + +**Cost**: ~$0.003/repo/month for index, cache is FREE + +### Phase 2: Write Path (Custom) + +```typescript +// Stream packfile to R2 +async function receivePack(stream: ReadableStream) { + // 1 subrequest to R2 + await r2.put(packKey, stream); + + // Build SQLite index (0 subrequests, small cost) + await buildObjectIndex(packKey); + + // Warm Cache API (FREE) + ctx.waitUntil(warmCacheAPI(newObjects)); +} +``` + +**Subrequests**: 2-3 (R2 writes) +**Cost**: R2 storage only ($0.015/GB) + +### Phase 3: Clone/Fetch (Optimized) + +```typescript +async function uploadPack(wants: string[], haves: string[]) { + if (haves.length === 0) { + // Full clone - stream existing pack (1 subrequest) + return r2.get(mainPackKey).body; + } + + // Partial fetch - generate streaming pack + // Subrequests = R2 cache misses (minimized by SQLite cache) + return generatorToStream(streamingPack(objectsNeeded)); +} +``` + +## Recommendations + +1. **Cache API for object cache**: FREE storage, eliminates most R2 reads +2. **SQLite for index only**: ~$0.003/repo/month, fast lookups +3. **Pack reuse for clones**: 1 subrequest vs potentially thousands +4. **Batch R2 reads**: Group by pack, use range requests +5. **Skip .idx files**: SQLite index is faster +6. **Accept no delta writing**: 2-3x larger but simpler +7. **Stream everything**: Stay within 128MB memory + +## Constraint Compliance Summary + +| Constraint | How We Handle It | +| ------------------ | -------------------------------------- | +| 1000 subrequests | Cache API + pack streaming | +| 128MB memory | Streaming generators, no buffering | +| $0.20/GB DO SQLite | Index only (~15MB), no object cache | +| 180-250ms R2 | Cache API (free, 9-15ms) as warm cache | + +## Cost Summary + +| Component | Size (500K objects) | Cost/month | +| ------------------- | ------------------- | ----------- | +| DO SQLite (index) | ~15MB | $0.003 | +| Cache API (objects) | Variable | **Free** | +| R2 (packfiles) | ~287MB | $0.004 | +| **Total/repo** | | **~$0.007** | +| **1000 repos** | | **~$8.50** | diff --git a/docs/maintenance.md b/docs/maintenance.md new file mode 100644 index 0000000..dd33a99 --- /dev/null +++ b/docs/maintenance.md @@ -0,0 +1,420 @@ +# Repository Maintenance + +## Overview + +Git repositories require periodic maintenance to: + +- Merge small packfiles into larger ones +- Remove unreachable objects (garbage collection) +- Optimize storage costs + +This document covers maintenance operations with **cost-optimized** storage in mind. + +## Cost-Aware Architecture Recap + +| Storage | Cost/GB/mo | Use For | +| --------- | ---------- | ------------------------------- | +| DO SQLite | $0.20 | Index only (~15MB/500K objects) | +| Cache API | Free | Parsed commits/trees | +| R2 | $0.015 | Packfiles | + +**Key**: No object data in SQLite! Only index + refs. + +## Constraint-Aware Maintenance + +| Constraint | Limit | Maintenance Impact | +| ----------- | ------------ | ------------------------------------ | +| Subrequests | 1000/request | Batch R2 operations | +| DO CPU | 30s-300s | Use alarms for long tasks | +| DO memory | 128MB | Stream large operations | +| DO storage | 10GB | Index only, no cache eviction needed | + +## Pack Management + +### Pack Accumulation Problem + +Each push creates a new packfile: + +``` +Push 1 → pack-001.pack (100 objects) +Push 2 → pack-002.pack (50 objects) +... +``` + +**Problems**: + +- Many packs = more R2 reads for cache misses +- Object index grows with duplicate entries +- Clone may need multiple pack streams + +### Merge Strategy + +**Thresholds**: + +| Pack Count | Action | +| ---------- | ------------------------- | +| 1-5 | No action | +| 6-10 | Schedule background merge | +| 11+ | Immediate merge, alert | + +### Merge Implementation + +```typescript +async function mergeSmallPacks() { + // 1. Find packs to merge (0 subrequests - SQLite) + const packsToMerge = await db.query(` + SELECT pack_id, size_bytes FROM git_packs + WHERE status = 'active' + ORDER BY size_bytes ASC + LIMIT 5 + `) + + if (packsToMerge.length < 2) return + + // 2. Collect all OIDs (0 subrequests - SQLite) + const allOids = await db.query(` + SELECT oid FROM git_objects + WHERE pack_id IN (${packsToMerge.map(() => '?').join(',')}) + `, packsToMerge.map(p => p.pack_id)) + + // 3. Read objects - prefer SQLite cache (minimize R2) + const objects = await batchReadObjectsForMerge(allOids) + + // 4. Generate new pack (streaming, memory-safe) + const newPackId = `pack-${Date.now()}` + const packStream = generatePackStream(objects) + + // 5. Upload to R2 (1 subrequest) + await r2.put(`packs/${newPackId}.pack`, packStream) + + // 6. Build and upload idx (1 subrequest) + const idx = await buildPackIndex(newPackId) + await r2.put(`packs/${newPackId}.idx`, idx) + + // 7. ATOMIC SWITCH (0 subrequests - SQLite transaction) + await db.transaction(async () => { + // Insert new pack + await db.exec(`INSERT INTO git_packs ...`, [newPackId, ...]) + + // Update object index to new pack + // (offsets recalculated from new pack) + + // Mark old packs as deprecated + await db.exec(` + UPDATE git_packs + SET status = 'deprecated', deprecated_at = ? + WHERE pack_id IN (...) + `, [Date.now(), ...]) + }) + + // 8. Schedule cleanup (5 min grace period) + await ctx.storage.setAlarm(Date.now() + 5 * 60 * 1000) +} + +// Total subrequests: 2 (R2 writes) + some R2 reads for uncached objects +``` + +### Subrequest Budget for Merge + +| Step | Subrequests | +| ------------------------- | -------------------- | +| Query packs (SQLite) | 0 | +| Query objects (SQLite) | 0 | +| Read from Cache API | N (free, 1 sub each) | +| R2 reads for cache misses | M (batched) | +| Upload new pack | 1 | +| Upload new idx | 1 | +| Update index (SQLite) | 0 | +| **Total** | **2 + N + M** | + +**Optimization**: Objects likely in Cache API (free), so M is small. + +## No SQLite Cache - Use Cache API Instead! + +### Why No Cache in SQLite? + +| Storage | Cost/GB/mo | For 500MB cache | +| --------- | ---------- | ----------------- | +| DO SQLite | $0.20 | **$0.10/repo/mo** | +| Cache API | Free | **$0** | + +**Cache API is free!** Don't store object data in SQLite. + +### SQLite Contains Only: + +| Data | Size | Cost (500K objects) | +| ------------- | ---------- | ------------------- | +| Object index | ~14MB | $0.0028/mo | +| Refs | ~1KB | Negligible | +| Pack registry | ~100B/pack | Negligible | +| **Total** | ~15MB | **~$0.003/mo** | + +### Cache API Handles: + +| Data | TTL | Cost | +| ------------------- | ------ | ---- | +| Parsed commits | 1 year | Free | +| Parsed trees | 1 year | Free | +| Small blobs (<10KB) | 1 week | Free | + +**No eviction strategy needed** - Cache API handles its own eviction. + +## Concurrent GC Safety + +### Problem + +During GC, reads must continue without errors: + +- Old packs being merged still valid +- New pack becomes valid after atomic switch +- Old packs deleted after grace period + +### Solution: Pack Status States + +```sql +CREATE TABLE git_packs ( + ... + status TEXT DEFAULT 'active', -- 'active', 'deprecated' + deprecated_at INTEGER +); +``` + +**Read flow during GC**: + +```typescript +async function readObject(oid: string) { + // Object index always points to correct pack + // - Before switch: old pack (still in R2) + // - After switch: new pack + const index = await db.query(`SELECT * FROM git_objects WHERE oid = ?`, [ + oid, + ]); + + // Pack status doesn't matter - R2 data still exists + return fetchFromR2(index.pack_id, index.pack_offset); +} +``` + +### Grace Period + +Deprecated packs kept 5 minutes before deletion: + +```typescript +async alarm() { + // Delete packs deprecated >5 min ago (2 subrequests per pack) + const oldPacks = await db.query(` + SELECT * FROM git_packs + WHERE status = 'deprecated' + AND deprecated_at < ? + `, [Date.now() - 5 * 60 * 1000]) + + for (const pack of oldPacks) { + await r2.delete(pack.r2_pack_key) // 1 subrequest + await r2.delete(pack.r2_idx_key) // 1 subrequest + await db.exec(`DELETE FROM git_packs WHERE pack_id = ?`, [pack.pack_id]) + } +} +``` + +## Garbage Collection + +### Unreachable Objects + +Objects not reachable from any ref: + +- Abandoned commits from force pushes +- Orphaned blobs from amended commits + +### GC Algorithm + +```typescript +async function garbageCollect() { + // 1. Mark reachable (SQLite index + Cache API) + const reachable = new Set(); + + const refs = await db.query(`SELECT value FROM git_refs`); + for (const ref of refs) { + await walkObject(ref.value, reachable); + } + + // 2. Find unreachable + const allObjects = await db.query(`SELECT oid FROM git_object_index`); + const unreachable = allObjects.filter((o) => !reachable.has(o.oid)); + + // 3. Mark for pruning (2 week delay) + await db.exec( + ` + UPDATE git_object_index + SET prune_at = ? + WHERE oid IN (...) + AND prune_at IS NULL + `, + [Date.now() + 14 * 24 * 60 * 60 * 1000, ...unreachable], + ); +} + +async function walkObject(oid: string, reachable: Set) { + if (reachable.has(oid)) return; + reachable.add(oid); + + // Get type from index (0 subrequests) + const index = await db.query( + `SELECT type FROM git_object_index WHERE oid = ?`, + [oid], + ); + + if (index?.type === 1) { + // commit + // Try Cache API first (FREE, 1 subrequest) + const cached = await caches.default.match( + new Request(`https://cache/obj:${repoId}:${oid}`), + ); + if (cached) { + const commit = await cached.json(); + await walkObject(commit.tree, reachable); + for (const parent of commit.parents) { + await walkObject(parent, reachable); + } + } + } else if (index?.type === 2) { + // tree + const cached = await caches.default.match( + new Request(`https://cache/obj:${repoId}:${oid}`), + ); + if (cached) { + const tree = await cached.json(); + for (const entry of tree.entries) { + await walkObject(entry.oid, reachable); + } + } + } +} +``` + +**Subrequests**: N (Cache API reads, but FREE). + +## Scheduling Maintenance + +### Using DO Alarms + +```typescript +class RepoDO extends DurableObject { + async receivePack(...) { + // ... handle push ... + + // Check if maintenance needed (0 subrequests) + const packCount = await db.query(`SELECT COUNT(*) FROM git_packs WHERE status = 'active'`) + + if (packCount > 5) { + // Schedule maintenance + await ctx.storage.setAlarm(Date.now() + 60_000) + } + } + + async alarm() { + // Run maintenance (within 30s CPU budget) + await this.mergeSmallPacks() + // No cache eviction needed - Cache API handles itself + await this.garbageCollect() + + // Check if more needed + const packCount = await db.query(`SELECT COUNT(*) FROM git_packs WHERE status = 'active'`) + if (packCount > 5) { + await ctx.storage.setAlarm(Date.now() + 60_000) + } + } +} +``` + +### Alarm Budget + +| Task | Est. Time | CPU Safe? | +| -------------------- | --------- | -------------- | +| Pack merge (5 packs) | 5-10s | ✅ | +| GC mark phase | 5-15s | ✅ | +| **Total** | 10-25s | ✅ (30s limit) | + +**Note**: No cache eviction needed - Cache API manages itself. + +## Migration from DOFS + +### Phase 1: Add Hybrid Storage + +Deploy new schema alongside DOFS. New pushes go to R2 + SQLite. + +### Phase 2: Migrate Existing Data + +```typescript +async function migrateRepository() { + // 1. Read packfiles from DOFS + const packs = await dofs.readdir("objects/pack"); + + for (const packFile of packs.filter((f) => f.endsWith(".pack"))) { + // Stream to R2 (1 subrequest per pack) + const data = await dofs.readFile(`objects/pack/${packFile}`); + await r2.put(`packs/${packFile}`, data); + + // Build idx and upload (1 subrequest) + const idx = await buildPackIndex(data); + await r2.put(`packs/${packFile.replace(".pack", ".idx")}`, idx); + + // Populate object index (0 subrequests) + await populateObjectIndex(packFile, idx); + + // Populate SQLite cache with commits/trees (0 subrequests) + await populateCache(packFile); + } + + // 2. Migrate refs (0 subrequests) + for (const ref of await listRefs()) { + const value = await dofs.readFile(ref); + await db.exec(`INSERT INTO git_refs (name, value) VALUES (?, ?)`, [ + ref, + value, + ]); + } +} +``` + +### Phase 3: Remove DOFS + +1. Verify all repos migrated +2. Remove DOFS read fallback +3. Delete DOFS data + +## Monitoring + +### Key Metrics + +| Metric | Warning | Critical | +| -------------------- | ------- | -------- | +| Pack count | > 5 | > 10 | +| Index size | > 50MB | > 100MB | +| Unreachable objects | > 5% | > 15% | +| Deprecated packs age | > 10min | > 30min | + +**Note**: No cache size monitoring - Cache API is free and self-managing. + +### Health Check Endpoint + +```typescript +async function healthCheck() { + return { + packs: { + active: await db.query( + `SELECT COUNT(*) FROM git_packs WHERE status = 'active'`, + ), + deprecated: await db.query( + `SELECT COUNT(*) FROM git_packs WHERE status = 'deprecated'`, + ), + }, + index: { + objects: await db.query(`SELECT COUNT(*) FROM git_object_index`), + pendingPrune: await db.query( + `SELECT COUNT(*) FROM git_object_index WHERE prune_at IS NOT NULL`, + ), + }, + // Cache API metrics not available - it's managed by Cloudflare + }; +} +``` From 801e96d1ec36d6de76c209bfa56414986084040d Mon Sep 17 00:00:00 2001 From: mdhruvil Date: Fri, 23 Jan 2026 08:47:55 +0530 Subject: [PATCH 02/12] feat: introduce streaming PackfileParser - add vitest - add stream buffer for exact byte count reading from ReadableStream --- apps/web/package.json | 4 +- apps/web/src/git/pack/error.ts | 16 + apps/web/src/git/pack/index.ts | 463 +++++++++++++++++++++++++++++ apps/web/src/git/pack/pack.test.ts | 220 ++++++++++++++ apps/web/src/git/pack/types.ts | 46 +++ apps/web/src/lib/stream-buffer.ts | 145 +++++++++ apps/web/vitest.config.ts | 9 + biome.json | 3 +- pnpm-lock.yaml | 250 +++++++++++++++- 9 files changed, 1152 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/git/pack/error.ts create mode 100644 apps/web/src/git/pack/index.ts create mode 100644 apps/web/src/git/pack/pack.test.ts create mode 100644 apps/web/src/git/pack/types.ts create mode 100644 apps/web/src/lib/stream-buffer.ts create mode 100644 apps/web/vitest.config.ts diff --git a/apps/web/package.json b/apps/web/package.json index ceaee13..63750e3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,14 +41,15 @@ "@tanstack/react-start": "^1.145.3", "@tanstack/router-plugin": "^1.145.2", "alchemy": "catalog:", - "libsodium": "^0.7.16", "better-auth": "catalog:", + "better-result": "^2.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "dofs": "^0.1.0", "drizzle-orm": "^0.45.1", "isomorphic-git": "^1.36.1", + "libsodium": "^0.7.16", "lucide-react": "^0.525.0", "mime": "^4.1.0", "next-themes": "^0.4.6", @@ -83,6 +84,7 @@ "jsdom": "^26.1.0", "typescript": "catalog:", "vite": "^7.3.0", + "vitest": "^4.0.17", "web-vitals": "^5.1.0", "wrangler": "^4.54.0" } diff --git a/apps/web/src/git/pack/error.ts b/apps/web/src/git/pack/error.ts new file mode 100644 index 0000000..33f7e60 --- /dev/null +++ b/apps/web/src/git/pack/error.ts @@ -0,0 +1,16 @@ +import { TaggedError } from "better-result"; + +export type PackfileParseErrorCode = + | "INVALID_SIGNATURE" + | "UNSUPPORTED_VERSION" + | "UNEXPECTED_EOF" + | "CHECKSUM_MISMATCH" + | "INVALID_OBJECT_HEADER" + | "DECOMPRESSION_FAILED"; + +export class PackfileParseError extends TaggedError("PackfileParseError")<{ + offset: number; + code: PackfileParseErrorCode; + message: string; + objectsParsed: number; +}>() {} diff --git a/apps/web/src/git/pack/index.ts b/apps/web/src/git/pack/index.ts new file mode 100644 index 0000000..75bb424 --- /dev/null +++ b/apps/web/src/git/pack/index.ts @@ -0,0 +1,463 @@ +/** + * Streaming Git packfile parser. + * + * @see https://git-scm.com/docs/pack-format + */ + +import { createHash, type Hash } from "node:crypto"; +import { inflateSync } from "node:zlib"; +import { Result } from "better-result"; +import { StreamBuffer } from "@/lib/stream-buffer"; +import { PackfileParseError } from "./error"; +import type { + PackfileEvent, + PackfileHeader, + PackfileObject, + PackfileResult, + Progress, +} from "./types"; + +const PACK_SIGNATURE = new Uint8Array([0x50, 0x41, 0x43, 0x4b]); // "PACK" +const SHA1_LENGTH = 20; +const HEADER_LENGTH = 12; // signature (4) + version (4) + object count (4) + +const OBJECT_TYPE_MAP: Record = { + 1: "commit", + 2: "tree", + 3: "blob", + 4: "tag", + 6: "ofs_delta", + 7: "ref_delta", +}; + +export class PackfileParser { + private readonly buffer: StreamBuffer; + private readonly hash: Hash; + private readonly pendingHashData: Uint8Array[] = []; + private pendingBytes = 0; + private readonly _progress: Progress = { + bytesRead: 0, + objectsParsed: 0, + objectsTotal: 0, + }; + + constructor(source: ReadableStream) { + this.hash = createHash("sha1"); + // Delay hashing by SHA1_LENGTH bytes so we can exclude the trailing checksum + this.buffer = new StreamBuffer(source, { + onData: (chunk) => { + this.pendingHashData.push(chunk); + this.pendingBytes += chunk.length; + this.flushPendingHash(); + }, + }); + } + + /** Flush pending data to hash, keeping SHA1_LENGTH bytes buffered */ + private flushPendingHash(): void { + while (this.pendingBytes > SHA1_LENGTH && this.pendingHashData.length > 0) { + const chunk = this.pendingHashData[0]; + const available = this.pendingBytes - SHA1_LENGTH; + + if (chunk.length <= available) { + // Entire chunk can be hashed + this.hash.update(chunk); + this.pendingHashData.shift(); + this.pendingBytes -= chunk.length; + } else { + // Partial chunk - hash only what we can + const toHash = chunk.subarray(0, available); + this.hash.update(toHash); + this.pendingHashData[0] = chunk.subarray(available); + this.pendingBytes -= available; + break; + } + } + } + + get progress(): Progress { + return { ...this._progress }; + } + + async *parse(): AsyncIterable> { + const headerResult = await this.parseHeader(); + yield headerResult; + if (headerResult.isErr()) return; + + const header = headerResult.value; + this._progress.objectsTotal = header.objectCount; + + for (let i = 0; i < header.objectCount; i += 1) { + const objectResult = await this.parseObject(); + this._progress.bytesRead = this.buffer.bytesRead; + + if (objectResult.isErr()) { + yield objectResult; + return; + } + + this._progress.objectsParsed += 1; + yield objectResult; + } + + const checksumResult = await this.parseChecksum(); + this._progress.bytesRead = this.buffer.bytesRead; + yield checksumResult; + } + + private async parseHeader(): Promise< + Result + > { + if (!(await this.buffer.ensure(HEADER_LENGTH))) { + return Result.err( + new PackfileParseError({ + offset: this.buffer.bytesRead, + code: "UNEXPECTED_EOF", + message: "Unexpected EOF while reading packfile header", + objectsParsed: 0, + }) + ); + } + + const signature = this.buffer.consume(4); + if (!this.arrayEquals(signature, PACK_SIGNATURE)) { + return Result.err( + new PackfileParseError({ + offset: 0, + code: "INVALID_SIGNATURE", + message: `Invalid packfile signature: expected "PACK", got "${new TextDecoder().decode(signature)}"`, + objectsParsed: 0, + }) + ); + } + + const versionBytes = this.buffer.consume(4); + const version = this.readUint32BE(versionBytes); + if (version !== 2 && version !== 3) { + return Result.err( + new PackfileParseError({ + offset: 4, + code: "UNSUPPORTED_VERSION", + message: `Unsupported packfile version: ${version}`, + objectsParsed: 0, + }) + ); + } + + const countBytes = this.buffer.consume(4); + const objectCount = this.readUint32BE(countBytes); + + this._progress.bytesRead = this.buffer.bytesRead; + + return Result.ok({ + type: "header", + version, + objectCount, + }); + } + + private async parseObject(): Promise< + Result + > { + const objectOffset = this.buffer.bytesRead; + + const headerResult = await this.parseObjectHeader(); + if (headerResult.isErr()) return headerResult; + + const { objectType, size } = headerResult.value; + + let baseOffset: number | undefined; + let baseHash: string | undefined; + + if (objectType === "ofs_delta") { + const offsetResult = await this.parseOfsOffset(objectOffset); + if (offsetResult.isErr()) return Result.err(offsetResult.error); + baseOffset = offsetResult.value; + } else if (objectType === "ref_delta") { + const hashResult = await this.parseRefHash(); + if (hashResult.isErr()) return Result.err(hashResult.error); + baseHash = hashResult.value; + } + + const dataResult = await this.decompressObject(size); + if (dataResult.isErr()) { + return Result.err( + new PackfileParseError({ + offset: objectOffset, + code: "DECOMPRESSION_FAILED", + message: dataResult.error.message, + objectsParsed: this._progress.objectsParsed, + }) + ); + } + + const data = dataResult.value; + + if (objectType === "ofs_delta" && baseOffset !== undefined) { + return Result.ok({ + type: "object", + objectType: "ofs_delta", + data, + size, + offset: objectOffset, + baseOffset, + }); + } + + if (objectType === "ref_delta" && baseHash !== undefined) { + return Result.ok({ + type: "object", + objectType: "ref_delta", + data, + size, + offset: objectOffset, + baseHash, + }); + } + + return Result.ok({ + type: "object", + objectType: objectType as "commit" | "tree" | "blob" | "tag", + data, + size, + offset: objectOffset, + }); + } + + private async parseObjectHeader(): Promise< + Result< + { objectType: PackfileObject["objectType"]; size: number }, + PackfileParseError + > + > { + const firstByte = await this.buffer.readByte(); + if (firstByte === undefined) { + return Result.err( + new PackfileParseError({ + offset: this.buffer.bytesRead, + code: "UNEXPECTED_EOF", + message: "Unexpected EOF while reading object header", + objectsParsed: this._progress.objectsParsed, + }) + ); + } + + // First byte: 1TTTSSSS where T=type (bits 4-6), S=size (bits 0-3), MSB=continue + const typeNum = (firstByte >> 4) & 0x07; + const objectType = OBJECT_TYPE_MAP[typeNum]; + if (!objectType) { + return Result.err( + new PackfileParseError({ + offset: this.buffer.bytesRead - 1, + code: "INVALID_OBJECT_HEADER", + message: `Invalid object type: ${typeNum}`, + objectsParsed: this._progress.objectsParsed, + }) + ); + } + + // Variable-length size: first 4 bits from byte 0, then 7 bits per continuation byte + let size = firstByte & 0x0f; // bits 0-3 + let shift = 4; + let currentByte = firstByte; + + // MSB=1 means more bytes follow + while ((currentByte & 0x80) !== 0) { + const nextByte = await this.buffer.readByte(); + if (nextByte === undefined) { + return Result.err( + new PackfileParseError({ + offset: this.buffer.bytesRead, + code: "UNEXPECTED_EOF", + message: "Unexpected EOF while reading object size", + objectsParsed: this._progress.objectsParsed, + }) + ); + } + size |= (nextByte & 0x7f) << shift; // accumulate 7 bits at current shift position + shift += 7; // next byte contributes 7 bits higher + currentByte = nextByte; + } + + return Result.ok({ objectType, size }); + } + + private async parseOfsOffset( + currentOffset: number + ): Promise> { + const firstByte = await this.buffer.readByte(); + if (firstByte === undefined) { + return Result.err( + new PackfileParseError({ + offset: this.buffer.bytesRead, + code: "UNEXPECTED_EOF", + message: "Unexpected EOF while reading delta offset", + objectsParsed: this._progress.objectsParsed, + }) + ); + } + + // OFS_DELTA uses a variable-length encoding where each continuation byte + // adds 2^7 + 2^14 + ... to account for the "wasted" zero value in each byte + let offset = firstByte & 0x7f; // first byte contributes 7 bits directly + let currentByte = firstByte; + + while ((currentByte & 0x80) !== 0) { + const nextByte = await this.buffer.readByte(); + if (nextByte === undefined) { + return Result.err( + new PackfileParseError({ + offset: this.buffer.bytesRead, + code: "UNEXPECTED_EOF", + message: "Unexpected EOF while reading delta offset", + objectsParsed: this._progress.objectsParsed, + }) + ); + } + // +1 before shift accounts for the fact that 0 is never encoded (saves space) + offset = ((offset + 1) << 7) | (nextByte & 0x7f); + currentByte = nextByte; + } + + return Result.ok(currentOffset - offset); // offset is relative (backward) from current position + } + + private async parseRefHash(): Promise> { + if (!(await this.buffer.ensure(SHA1_LENGTH))) { + return Result.err( + new PackfileParseError({ + offset: this.buffer.bytesRead, + code: "UNEXPECTED_EOF", + message: "Unexpected EOF while reading ref delta hash", + objectsParsed: this._progress.objectsParsed, + }) + ); + } + + const hashBytes = this.buffer.consume(SHA1_LENGTH); + return Result.ok(this.bytesToHex(hashBytes)); + } + + private async decompressObject( + expectedSize: number + ): Promise> { + const maxAttempts = 100; + let bufferSize = Math.max(expectedSize * 2, 256); + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + // ensure() may return false at EOF, but we still try with available data + await this.buffer.ensure(bufferSize); + + const available = this.buffer.peek(bufferSize); + if (available.length === 0) { + return Result.err(new Error("No data available for decompression")); + } + + try { + // inflateSync with { info: true } returns { buffer, engine: { bytesWritten } } + // see https://nodejs.org/api/zlib.html#class-options + const result = inflateSync(available, { info: true }) as unknown as { + buffer: Buffer; + engine: { bytesWritten: number }; + }; + this.buffer.consume(result.engine.bytesWritten); + return Result.ok(new Uint8Array(result.buffer)); + } catch (err) { + if ( + this.buffer.bytesRead + bufferSize >= + this._progress.bytesRead + bufferSize * 2 + ) { + return Result.err( + err instanceof Error ? err : new Error(String(err)) + ); + } + bufferSize *= 2; + } + } + + return Result.err( + new Error("Failed to decompress object after maximum attempts") + ); + } + + private async parseChecksum(): Promise< + Result + > { + if (!(await this.buffer.ensure(SHA1_LENGTH))) { + return Result.err( + new PackfileParseError({ + offset: this.buffer.bytesRead, + code: "UNEXPECTED_EOF", + message: "Unexpected EOF while reading checksum", + objectsParsed: this._progress.objectsParsed, + }) + ); + } + + const expectedChecksum = this.buffer.consume(SHA1_LENGTH); + const checksumHex = this.bytesToHex(expectedChecksum); + const computedHash = new Uint8Array(this.hash.digest()); + const valid = this.arrayEquals(expectedChecksum, computedHash); + + if (!valid) { + return Result.err( + new PackfileParseError({ + offset: this.buffer.bytesRead - SHA1_LENGTH, + code: "CHECKSUM_MISMATCH", + message: `Checksum mismatch: expected ${checksumHex}, got ${this.bytesToHex(computedHash)}`, + objectsParsed: this._progress.objectsParsed, + }) + ); + } + + return Result.ok({ + type: "result", + checksum: checksumHex, + valid, + }); + } + + private arrayEquals(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; + } + + /** + * Reads 4 bytes as a big-endian unsigned 32-bit integer. + * + * Big-endian means most significant byte first (network byte order). + * Git packfiles use big-endian for multi-byte integers. + * + * @example + * bytes: [0x00, 0x01, 0x02, 0x03] + * result: 0x00010203 = 66051 + * + * Bit layout: + * ``` + * bytes[0] << 24 → 0x00______ (bits 24-31) + * bytes[1] << 16 → 0x__01____ (bits 16-23) + * bytes[2] << 8 → 0x____02__ (bits 8-15) + * bytes[3] → 0x______03 (bits 0-7) + * ────────── + * OR → 0x00010203 + * ``` + * + * The `>>> 0` at the end converts the result to an unsigned 32-bit integer. + * Without it, if the high bit is set (bytes[0] >= 0x80), JavaScript would + * treat the result as a negative signed 32-bit integer. + */ + private readUint32BE(bytes: Uint8Array): number { + return ( + ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0 + ); + } + + private bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } +} diff --git a/apps/web/src/git/pack/pack.test.ts b/apps/web/src/git/pack/pack.test.ts new file mode 100644 index 0000000..dbd27aa --- /dev/null +++ b/apps/web/src/git/pack/pack.test.ts @@ -0,0 +1,220 @@ +import { exec } from "node:child_process"; +import { createReadStream } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import { promisify } from "node:util"; +import { beforeAll, describe, expect, it } from "vitest"; +import { PackfileParser } from "./index"; +import type { + PackfileEvent, + PackfileHeader, + PackfileObject, + PackfileResult, +} from "./types"; + +const execAsync = promisify(exec); + +/** Compute git object hash: SHA-1 of "type size\0content" */ +async function computeGitObjectHash( + type: string, + data: Uint8Array +): Promise { + const header = new TextEncoder().encode(`${type} ${data.length}\0`); + const full = new Uint8Array(header.length + data.length); + full.set(header); + full.set(data, header.length); + + const hashBuffer = await crypto.subtle.digest("SHA-1", full); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +type GitVerifyPackEntry = { + sha: string; + type: string; + size: number; + sizeInPack: number; + offset: number; + depth?: number; + baseSha?: string; +}; + +/** + * + * parses the output of `git verify-pack -v ` and returns an array of entries + * + * When specifying the -v option the format used is: + * SHA-1 type size size-in-packfile offset-in-packfile + * for objects that are not deltified in the pack, and + * SHA-1 type size size-in-packfile offset-in-packfile depth base-SHA-1 + * for objects that are deltified. + */ +async function parseGitVerifyPack(packPath: string) { + const { stdout } = await execAsync(`git verify-pack -v "${packPath}"`); + const lines = stdout.trim().split("\n"); + const entries: GitVerifyPackEntry[] = []; + + for (const line of lines) { + // Skip summary lines (non-SHA1, chain length, etc.) + if (!line.match(/^[0-9a-f]{40}\s/)) continue; + + const parts = line.split(/\s+/); + if (parts.length < 5) continue; + + const entry: GitVerifyPackEntry = { + sha: parts[0], + type: parts[1], + size: Number.parseInt(parts[2], 10), + sizeInPack: Number.parseInt(parts[3], 10), + offset: Number.parseInt(parts[4], 10), + }; + + // Delta objects have depth and base SHA + if (parts.length >= 7) { + entry.depth = Number.parseInt(parts[5], 10); + entry.baseSha = parts[6]; + } + + entries.push(entry); + } + + return entries; +} + +async function findGitRoot(startDir: string): Promise { + let dir = startDir; + while (dir !== "/") { + try { + await readdir(join(dir, ".git")); + return dir; + } catch { + dir = join(dir, ".."); + } + } + throw new Error("Could not find .git directory"); +} + +async function findPackFiles(): Promise { + const gitRoot = await findGitRoot(process.cwd()); + const packDir = join(gitRoot, ".git", "objects", "pack"); + const files = await readdir(packDir); + return files.filter((f) => f.endsWith(".pack")).map((f) => join(packDir, f)); +} + +function fileToWebStream(path: string) { + const nodeStream = createReadStream(path); + return Readable.toWeb(nodeStream) as ReadableStream; +} + +describe("PackfileParser", () => { + let packPath: string; + let events: PackfileEvent[]; + let parsedHeader: PackfileHeader; + let parsedObjects: PackfileObject[]; + let parsedResult: PackfileResult; + let gitEntries: GitVerifyPackEntry[]; + let lastParser: PackfileParser; + + beforeAll(async () => { + const packFiles = await findPackFiles(); + if (packFiles.length === 0) throw new Error("No pack files found"); + packPath = packFiles[0]; + + const stream = fileToWebStream(packPath); + const parser = new PackfileParser(stream); + lastParser = parser; + + events = []; + parsedObjects = []; + + for await (const res of parser.parse()) { + if (res.isErr()) throw new Error(`Parse error: ${res.error.message}`); + events.push(res.value); + if (res.value.type === "object") { + parsedObjects.push(res.value); + } + } + + const h = events[0]; + const r = events.at(-1); + + if (h?.type !== "header") throw new Error("First event should be header"); + if (r?.type !== "result") throw new Error("Last event should be result"); + + parsedHeader = h; + parsedResult = r; + + gitEntries = await parseGitVerifyPack(packPath); + }); + + it("parses packfile header correctly", () => { + expect(parsedHeader.type).toBe("header"); + expect(parsedHeader.version).toBeOneOf([2, 3]); + expect(parsedHeader.objectCount).toBeGreaterThan(0); + }); + + it("parses all objects without errors", () => { + expect(parsedObjects.length).toBeGreaterThan(0); + + const validTypes = [ + "commit", + "tree", + "blob", + "tag", + "ofs_delta", + "ref_delta", + ]; + for (const obj of parsedObjects) { + expect(validTypes).toContain(obj.objectType); + expect(obj.data).toBeInstanceOf(Uint8Array); + expect(obj.size).toBeGreaterThanOrEqual(0); + expect(obj.offset).toBeGreaterThanOrEqual(0); + } + }); + + it("validates checksum correctly", () => { + expect(parsedResult.type).toBe("result"); + expect(parsedResult.valid).toBe(true); + expect(parsedResult.checksum).toMatch(/^[0-9a-f]{40}$/); + }); + + it("tracks progress correctly", () => { + expect(lastParser.progress.objectsParsed).toBe(parsedHeader.objectCount); + expect(lastParser.progress.objectsTotal).toBe(parsedHeader.objectCount); + }); + + it("object count matches header", () => { + expect(parsedObjects.length).toBe(parsedHeader.objectCount); + }); + + it("parsed objects match git verify-pack output", async () => { + expect(gitEntries.length).toBeGreaterThan(0); + + const gitShas = new Set(gitEntries.map((e) => e.sha)); + const gitBaseObjects = gitEntries.filter((e) => e.depth === undefined); + const gitDeltaObjects = gitEntries.filter((e) => e.depth !== undefined); + + const parsedBaseObjects = parsedObjects.filter( + (o) => o.objectType !== "ofs_delta" && o.objectType !== "ref_delta" + ); + const parsedDeltaObjects = parsedObjects.filter( + (o) => o.objectType === "ofs_delta" || o.objectType === "ref_delta" + ); + + expect(parsedBaseObjects.length).toBe(gitBaseObjects.length); + expect(parsedDeltaObjects.length).toBe(gitDeltaObjects.length); + + for (const obj of parsedBaseObjects) { + const hash = await computeGitObjectHash(obj.objectType, obj.data); + expect(gitShas.has(hash)).toBe(true); + } + }); + + it("decompressed data size matches declared size", () => { + for (const obj of parsedObjects) { + expect(obj.data.length).toBe(obj.size); + } + }); +}); diff --git a/apps/web/src/git/pack/types.ts b/apps/web/src/git/pack/types.ts new file mode 100644 index 0000000..02587d0 --- /dev/null +++ b/apps/web/src/git/pack/types.ts @@ -0,0 +1,46 @@ +export type Progress = { + bytesRead: number; + objectsParsed: number; + objectsTotal: number; +}; + +export type PackfileHeader = { + type: "header"; + version: 2 | 3; + objectCount: number; +}; + +export type PackfileResult = { + type: "result"; + checksum: string; + valid: boolean; +}; + +export type BaseObject = { + type: "object"; + objectType: "commit" | "tree" | "blob" | "tag"; + data: Uint8Array; + size: number; + offset: number; +}; + +export type OfsDeltaObject = { + type: "object"; + objectType: "ofs_delta"; + data: Uint8Array; + size: number; + offset: number; + baseOffset: number; +}; + +export type RefDeltaObject = { + type: "object"; + objectType: "ref_delta"; + data: Uint8Array; + size: number; + offset: number; + baseHash: string; +}; + +export type PackfileObject = BaseObject | OfsDeltaObject | RefDeltaObject; +export type PackfileEvent = PackfileHeader | PackfileObject | PackfileResult; diff --git a/apps/web/src/lib/stream-buffer.ts b/apps/web/src/lib/stream-buffer.ts new file mode 100644 index 0000000..d9bf0d8 --- /dev/null +++ b/apps/web/src/lib/stream-buffer.ts @@ -0,0 +1,145 @@ +/** + * Buffered reader for ReadableStream that provides exact byte count reads. + * + * Streams deliver arbitrary chunk sizes, but binary protocols need exact amounts: + * "give me 4 bytes", "give me 20 bytes", etc. StreamBuffer bridges this gap. + * + * Features: + * - `ensure(n)` - wait until n bytes available + * - `peek(n)` / `consume(n)` - read exact byte counts across chunk boundaries + * - Memory efficient - drops fully consumed chunks + * - Optional `onData` callback for incremental processing (e.g., streaming hash) + */ +export class StreamBuffer { + private readonly chunks: Uint8Array[] = []; + private totalLength = 0; + private position = 0; + private readonly reader: ReadableStreamDefaultReader; + private done = false; + private readonly onData: ((chunk: Uint8Array) => void) | null; + + constructor( + source: ReadableStream, + options?: { onData?: (chunk: Uint8Array) => void } + ) { + this.reader = source.getReader(); + this.onData = options?.onData ?? null; + } + + /** Total bytes consumed so far */ + get bytesRead(): number { + return this.position; + } + + /** + * Ensures at least `length` bytes are available in the buffer. + * @returns false if EOF reached before enough bytes available + */ + async ensure(length: number): Promise { + while (this.totalLength - this.position < length && !this.done) { + const { value, done } = await this.reader.read(); + if (done) { + this.done = true; + break; + } + if (value && value.length > 0) { + this.chunks.push(value); + this.onData?.(value); + this.totalLength += value.length; + } + } + return this.totalLength - this.position >= length; + } + + /** + * Peeks at the next `length` bytes without consuming them. + * Call `ensure(length)` first to guarantee bytes are available. + */ + peek(length: number): Uint8Array { + return this.readInternal(length, false); + } + + /** + * Reads and consumes `length` bytes from the buffer. + * Call `ensure(length)` first to guarantee bytes are available. + */ + consume(length: number): Uint8Array { + return this.readInternal(length, true); + } + + /** + * Reads a single byte and advances position. + * @returns undefined if EOF + */ + async readByte(): Promise { + if (!(await this.ensure(1))) return undefined; + return this.consume(1)[0]; + } + + private readInternal(length: number, advance: boolean): Uint8Array { + const result = new Uint8Array(length); + let resultOffset = 0; + let remaining = length; + let chunkIndex = 0; + let localPosition = this.position; + + // Find starting chunk + let chunkStart = 0; + while ( + chunkIndex < this.chunks.length && + chunkStart + this.chunks[chunkIndex].length <= localPosition + ) { + chunkStart += this.chunks[chunkIndex].length; + chunkIndex += 1; + } + + // Read bytes across chunks + while (remaining > 0 && chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const offsetInChunk = localPosition - chunkStart; + const availableInChunk = chunk.length - offsetInChunk; + const toCopy = Math.min(remaining, availableInChunk); + + result.set( + chunk.subarray(offsetInChunk, offsetInChunk + toCopy), + resultOffset + ); + resultOffset += toCopy; + remaining -= toCopy; + localPosition += toCopy; + + if (offsetInChunk + toCopy >= chunk.length) { + chunkStart += chunk.length; + chunkIndex += 1; + } + } + + if (advance) { + this.position = localPosition; + this.compactIfNeeded(); + } + + return result; + } + + /** Remove fully consumed chunks to free memory */ + private compactIfNeeded(): void { + let consumed = 0; + let chunksToRemove = 0; + + for (const chunk of this.chunks) { + if (consumed + chunk.length <= this.position) { + consumed += chunk.length; + chunksToRemove += 1; + } else { + break; + } + } + + if (chunksToRemove > 0) { + this.chunks.splice(0, chunksToRemove); + this.position -= consumed; + this.totalLength -= consumed; + } + } +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..4173a5c --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,9 @@ +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + globals: true, + }, +}); diff --git a/biome.json b/biome.json index e3b3897..ea2dea7 100644 --- a/biome.json +++ b/biome.json @@ -44,7 +44,8 @@ "noUnknownAtRules": "off", "noEmptyBlock": "off", "noEmptyBlockStatements": "off", - "useAwait": "off" + "useAwait": "off", + "noBitwiseOperators": "off" }, "performance": { "noNamespaceImport": "off", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a828e5..c0af078 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,7 +112,10 @@ importers: version: 0.82.2(@cloudflare/vite-plugin@1.17.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20260103.0)))(@opentelemetry/api@1.9.0)(kysely@0.28.8)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20260103.0)) better-auth: specifier: 'catalog:' - version: 1.4.9(@tanstack/react-start@1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.10) + version: 1.4.9(@tanstack/react-start@1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.10)(vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + better-result: + specifier: ^2.5.1 + version: 2.5.1 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -231,6 +234,9 @@ importers: vite: specifier: ^7.3.0 version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + vitest: + specifier: ^4.0.17 + version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -3003,9 +3009,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -3050,6 +3062,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.0.17': + resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==} + + '@vitest/mocker@4.0.17': + resolution: {integrity: sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.17': + resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==} + + '@vitest/runner@4.0.17': + resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==} + + '@vitest/snapshot@4.0.17': + resolution: {integrity: sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==} + + '@vitest/spy@4.0.17': + resolution: {integrity: sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==} + + '@vitest/utils@4.0.17': + resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==} + '@vue/reactivity@3.5.24': resolution: {integrity: sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==} @@ -3178,6 +3219,10 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -3278,6 +3323,10 @@ packages: zod: optional: true + better-result@2.5.1: + resolution: {integrity: sha512-guw/CpafxGh6QeRoc3H9KBxbynQfdouzPLysMAxyFl52mKQ8wa6SfuDLbWDdglPHca6GNA6DmBh4LdsmQlxSxA==} + hasBin: true + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -3331,6 +3380,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3678,6 +3731,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -3723,6 +3779,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -3739,6 +3798,10 @@ packages: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} @@ -4457,6 +4520,9 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -4818,6 +4884,9 @@ packages: shiki@3.20.0: resolution: {integrity: sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4869,6 +4938,12 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stoppable@1.1.0: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} @@ -4938,13 +5013,24 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.1: resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} @@ -5218,6 +5304,40 @@ packages: vite: optional: true + vitest@4.0.17: + resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.17 + '@vitest/browser-preview': 4.0.17 + '@vitest/browser-webdriverio': 4.0.17 + '@vitest/ui': 4.0.17 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -5263,6 +5383,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + workerd@1.20251210.0: resolution: {integrity: sha512-9MUUneP1BnRE9XAYi94FXxHmiLGbO75EHQZsgWqSiOXjoXSqJCw8aQbIEPxCy19TclEl/kHUFYce8ST2W+Qpjw==} engines: {node: '>=16'} @@ -8256,10 +8381,17 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -8309,6 +8441,45 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.0.17': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.17 + '@vitest/utils': 4.0.17 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.17(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.17 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + + '@vitest/pretty-format@4.0.17': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.17': + dependencies: + '@vitest/utils': 4.0.17 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.17': + dependencies: + '@vitest/pretty-format': 4.0.17 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.17': {} + + '@vitest/utils@4.0.17': + dependencies: + '@vitest/pretty-format': 4.0.17 + tinyrainbow: 3.0.3 + '@vue/reactivity@3.5.24': dependencies: '@vue/shared': 3.5.24 @@ -8430,6 +8601,8 @@ snapshots: dependencies: dequal: 2.0.3 + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -8461,7 +8634,7 @@ snapshots: before-after-hook@3.0.2: {} - better-auth@1.4.9(@tanstack/react-start@1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.10): + better-auth@1.4.9(@tanstack/react-start@1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.10)(vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)): dependencies: '@better-auth/core': 1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.2))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) '@better-auth/telemetry': 1.4.9(@better-auth/core@1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.2))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)) @@ -8482,6 +8655,7 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) solid-js: 1.9.10 + vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) better-call@1.1.7(zod@4.3.2): dependencies: @@ -8492,6 +8666,10 @@ snapshots: optionalDependencies: zod: 4.3.2 + better-result@2.5.1: + dependencies: + '@clack/prompts': 0.11.0 + binary-extensions@2.3.0: {} blake3-wasm@2.1.5: {} @@ -8548,6 +8726,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -8800,6 +8980,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -8931,6 +9113,10 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + event-target-shim@5.0.1: {} events@3.3.0: {} @@ -8952,6 +9138,8 @@ snapshots: exit-hook@2.2.1: {} + expect-type@1.3.0: {} + exsolve@1.0.7: {} extend@3.0.2: {} @@ -9898,6 +10086,8 @@ snapshots: pkg-types: 2.3.0 tinyexec: 1.0.1 + obug@2.1.1: {} + ohash@2.0.11: {} once@1.4.0: @@ -10387,6 +10577,8 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -10431,6 +10623,10 @@ snapshots: srvx@0.10.0: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + stoppable@1.1.0: {} string-width@4.2.3: @@ -10496,13 +10692,19 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + tinyexec@1.0.1: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + tldts-core@6.1.86: {} tldts@6.1.86: @@ -10745,6 +10947,45 @@ snapshots: optionalDependencies: vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.17 + '@vitest/mocker': 4.0.17(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.17 + '@vitest/runner': 4.0.17 + '@vitest/snapshot': 4.0.17 + '@vitest/spy': 4.0.17 + '@vitest/utils': 4.0.17 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 24.10.4 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -10789,6 +11030,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + workerd@1.20251210.0: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20251210.0 From 544d400ae37aeb9d89d88696f36d4f618d04035d Mon Sep 17 00:00:00 2001 From: mdhruvil Date: Fri, 23 Jan 2026 09:06:42 +0530 Subject: [PATCH 03/12] ci: add lint, test job before pr preview --- .github/workflows/pr-preview.yml | 29 ++++++++++++++++++++++++----- apps/web/package.json | 2 ++ apps/web/src/components/header.tsx | 25 ------------------------- package.json | 4 +++- turbo.json | 6 ++++++ 5 files changed, 35 insertions(+), 31 deletions(-) delete mode 100644 apps/web/src/components/header.tsx diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index ec85dd1..fdc0d9f 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -25,18 +25,14 @@ env: # App Secrets BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} jobs: - deploy-preview: + ci: if: ${{ github.event.action != 'closed' }} runs-on: ubuntu-latest - # Requires approval for external contributors (configured in repo Settings → Environments) - environment: preview permissions: contents: read - pull-requests: write steps: - uses: actions/checkout@v4 with: - # Checkout the PR head commit (safe after environment approval) ref: ${{ github.event.pull_request.head.sha }} - &a1 name: Setup pnpm @@ -53,6 +49,29 @@ jobs: name: Install dependencies shell: bash run: pnpm install + - name: Lint + run: pnpm check + - name: Typecheck + run: pnpm typecheck + - name: Test + run: pnpm test + deploy-preview: + needs: [ci] + if: ${{ github.event.action != 'closed' }} + runs-on: ubuntu-latest + # Requires approval for external contributors (configured in repo Settings → Environments) + environment: preview + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + # Checkout the PR head commit (safe after environment approval) + ref: ${{ github.event.pull_request.head.sha }} + - *a1 + - *a2 + - *a3 - name: Deploy Preview run: pnpm --filter ./apps/web run deploy cleanup-preview: diff --git a/apps/web/package.json b/apps/web/package.json index 63750e3..7060493 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,8 @@ "dev": "alchemy dev", "deploy": "alchemy deploy", "destroy": "alchemy destroy", + "test": "vitest run", + "typecheck": "tsc --noEmit", "cf-typegen": "wrangler types --env-interface Env", "sentry:sourcemaps": "_SENTRY_RELEASE=$(sentry-cli releases propose-version) && sentry-cli releases new $_SENTRY_RELEASE --org=mdhruvil --project=gitvex-workers && sentry-cli sourcemaps upload --org=mdhruvil --project=gitvex-workers --release=$_SENTRY_RELEASE --strip-prefix 'dist/..' dist", "postdeploy:noop": "pnpm sentry:sourcemaps", diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx deleted file mode 100644 index 8e904e3..0000000 --- a/apps/web/src/components/header.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Link } from "@tanstack/react-router"; - -export default function Header() { - const links = [ - { to: "/", label: "Home" }, - { to: "/dashboard", label: "Dashboard" }, - { to: "/todos", label: "Todos" }, - ] as const; - - return ( -
-
- -
-
-
-
- ); -} diff --git a/package.json b/package.json index d92cade..eb6b208 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "check": "biome check .", "check:fix": "biome check --write .", "dev": "turbo dev", - "build": "turbo build" + "build": "turbo build", + "test": "turbo test", + "typecheck": "turbo typecheck" }, "devDependencies": { "@biomejs/biome": "2.3.2", diff --git a/turbo.json b/turbo.json index f876866..6c570ca 100644 --- a/turbo.json +++ b/turbo.json @@ -14,6 +14,12 @@ "dev:setup": { "cache": false, "persistent": true + }, + "test": { + "inputs": ["$TURBO_DEFAULT$", "**/*.test.ts", "**/*.test.tsx"] + }, + "typecheck": { + "inputs": ["$TURBO_DEFAULT$", "tsconfig.json"] } } } From b05bab46a5415a9e2defb6c1d8545ab2f0f8629f Mon Sep 17 00:00:00 2001 From: mdhruvil Date: Fri, 23 Jan 2026 09:24:20 +0530 Subject: [PATCH 04/12] fix(pack): early exit decompression retry when stream exhausted --- apps/web/src/git/pack/index.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/web/src/git/pack/index.ts b/apps/web/src/git/pack/index.ts index 75bb424..fb3e605 100644 --- a/apps/web/src/git/pack/index.ts +++ b/apps/web/src/git/pack/index.ts @@ -345,8 +345,7 @@ export class PackfileParser { let bufferSize = Math.max(expectedSize * 2, 256); for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - // ensure() may return false at EOF, but we still try with available data - await this.buffer.ensure(bufferSize); + const hasMore = await this.buffer.ensure(bufferSize); const available = this.buffer.peek(bufferSize); if (available.length === 0) { @@ -363,10 +362,8 @@ export class PackfileParser { this.buffer.consume(result.engine.bytesWritten); return Result.ok(new Uint8Array(result.buffer)); } catch (err) { - if ( - this.buffer.bytesRead + bufferSize >= - this._progress.bytesRead + bufferSize * 2 - ) { + // EOF and still failing - no point retrying + if (!hasMore) { return Result.err( err instanceof Error ? err : new Error(String(err)) ); From 994666d4976a46268a5048e0c855f3e259342e41 Mon Sep 17 00:00:00 2001 From: mdhruvil Date: Fri, 23 Jan 2026 09:39:27 +0530 Subject: [PATCH 05/12] ci: ignore deploy-preview for draft and setup turborepo cache --- .github/workflows/pr-preview.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index fdc0d9f..b71a1f3 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -6,6 +6,7 @@ on: - reopened - synchronize - closed + - ready_for_review concurrency: group: pr-preview-${{ github.event.pull_request.number }} cancel-in-progress: false @@ -45,6 +46,14 @@ jobs: with: node-version: "24" cache: pnpm + - &a4 + name: Cache turbo + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- - &a3 name: Install dependencies shell: bash @@ -57,7 +66,7 @@ jobs: run: pnpm test deploy-preview: needs: [ci] - if: ${{ github.event.action != 'closed' }} + if: ${{ github.event.action != 'closed' && !github.event.pull_request.draft }} runs-on: ubuntu-latest # Requires approval for external contributors (configured in repo Settings → Environments) environment: preview @@ -71,6 +80,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - *a1 - *a2 + - *a4 - *a3 - name: Deploy Preview run: pnpm --filter ./apps/web run deploy From fcbbb51f472df25180c45ebd0db68e28c6d75bf0 Mon Sep 17 00:00:00 2001 From: mdhruvil Date: Fri, 23 Jan 2026 18:20:19 +0530 Subject: [PATCH 06/12] chore: update deps --- apps/web/package.json | 50 +- apps/web/src/routeTree.gen.ts | 8 +- package.json | 4 +- pnpm-lock.yaml | 2896 ++++++++++++++++++--------------- pnpm-workspace.yaml | 4 +- 5 files changed, 1588 insertions(+), 1374 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 7060493..11f935e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,11 +19,10 @@ }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", - "@git-diff-view/file": "^0.0.35", - "@git-diff-view/react": "^0.0.35", + "@git-diff-view/file": "^0.0.36", + "@git-diff-view/react": "^0.0.36", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-avatar": "^1.1.11", - "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", @@ -32,16 +31,13 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", - "@sentry/cloudflare": "^10.32.1", - "@tailwindcss/typography": "^0.5.19", + "@sentry/cloudflare": "^10.36.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-form": "^1.27.7", - "@tanstack/react-query": "^5.90.16", - "@tanstack/react-router": "^1.144.0", - "@tanstack/react-router-ssr-query": "^1.144.0", - "@tanstack/react-router-with-query": "^1.130.17", - "@tanstack/react-start": "^1.145.3", - "@tanstack/router-plugin": "^1.145.2", + "@tanstack/react-query": "^5.90.19", + "@tanstack/react-router": "^1.154.13", + "@tanstack/react-router-ssr-query": "^1.154.13", + "@tanstack/react-start": "^1.154.13", "alchemy": "catalog:", "better-auth": "catalog:", "better-result": "^2.5.1", @@ -51,43 +47,37 @@ "dofs": "^0.1.0", "drizzle-orm": "^0.45.1", "isomorphic-git": "^1.36.1", - "libsodium": "^0.7.16", - "lucide-react": "^0.525.0", + "lucide-react": "^0.563.0", "mime": "^4.1.0", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "19.1.0", "react-dom": "19.1.0", - "react-hook-form": "^7.69.0", + "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", "react-shiki": "^0.9.1", - "refractor": "^5.0.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", - "shiki": "^3.20.0", + "shiki": "^3.21.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0", - "vite-tsconfig-paths": "^5.1.4", - "zod": "^4.3.2" + "vite-tsconfig-paths": "^6.0.4", + "zod": "^4.3.6" }, "devDependencies": { - "@cloudflare/vite-plugin": "^1.17.1", - "@cloudflare/workers-types": "^4.20260103.0", - "@sentry/cli": "^2.58.4", - "@tanstack/react-router-devtools": "^1.144.0", - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.1", - "@types/react": "~19.1.17", + "@cloudflare/workers-types": "^4.20260123.0", + "@sentry/cli": "^3.1.0", + "@tanstack/react-router-devtools": "^1.154.13", + "@types/react": "~19.2.9", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", "drizzle-kit": "^0.31.8", - "jsdom": "^26.1.0", + "jsdom": "^27.4.0", "typescript": "catalog:", - "vite": "^7.3.0", - "vitest": "^4.0.17", - "web-vitals": "^5.1.0", - "wrangler": "^4.54.0" + "vite": "^7.3.1", + "vitest": "^4.0.18", + "wrangler": "^4.60.0" } } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 5458096..1eb949f 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -189,7 +189,7 @@ export interface FileRoutesByFullPath { '/$owner/$repo/commits/$commitId': typeof OwnerRepoLayoutCommitsCommitIdRoute '/$owner/$repo/issues/$issueNumber': typeof OwnerRepoLayoutIssuesIssueNumberRoute '/$owner/$repo/issues/new': typeof OwnerRepoLayoutIssuesNewRoute - '/$owner/$repo/issues': typeof OwnerRepoLayoutIssuesIndexRoute + '/$owner/$repo/issues/': typeof OwnerRepoLayoutIssuesIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -268,7 +268,7 @@ export interface FileRouteTypes { | '/$owner/$repo/commits/$commitId' | '/$owner/$repo/issues/$issueNumber' | '/$owner/$repo/issues/new' - | '/$owner/$repo/issues' + | '/$owner/$repo/issues/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -353,7 +353,7 @@ declare module '@tanstack/react-router' { '/_layout': { id: '/_layout' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } @@ -465,7 +465,7 @@ declare module '@tanstack/react-router' { '/$owner/$repo/_layout/issues/': { id: '/$owner/$repo/_layout/issues/' path: '/issues' - fullPath: '/$owner/$repo/issues' + fullPath: '/$owner/$repo/issues/' preLoaderRoute: typeof OwnerRepoLayoutIssuesIndexRouteImport parentRoute: typeof OwnerRepoLayoutRoute } diff --git a/package.json b/package.json index eb6b208..414d35c 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,10 @@ }, "devDependencies": { "@biomejs/biome": "2.3.2", - "turbo": "^2.7.2", + "turbo": "^2.7.5", "ultracite": "6.2.0" }, - "packageManager": "pnpm@10.27.0", + "packageManager": "pnpm@10.28.1", "pnpm": { "overrides": { "libsodium-wrappers": "0.7.15" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0af078..f2193ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,11 +7,11 @@ settings: catalogs: default: alchemy: - specifier: ^0.82.2 - version: 0.82.2 + specifier: ^0.83.1 + version: 0.83.1 better-auth: - specifier: ^1.4.9 - version: 1.4.9 + specifier: ^1.4.17 + version: 1.4.17 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -27,8 +27,8 @@ importers: specifier: 2.3.2 version: 2.3.2 turbo: - specifier: ^2.7.2 - version: 2.7.2 + specifier: ^2.7.5 + version: 2.7.5 ultracite: specifier: 6.2.0 version: 6.2.0(typescript@5.9.3) @@ -39,80 +39,68 @@ importers: specifier: ^5.2.8 version: 5.2.8 '@git-diff-view/file': - specifier: ^0.0.35 - version: 0.0.35 + specifier: ^0.0.36 + version: 0.0.36 '@git-diff-view/react': - specifier: ^0.0.35 - version: 0.0.35(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^0.0.36 + version: 0.0.36(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@hookform/resolvers': specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.69.0(react@19.1.0)) + version: 5.2.2(react-hook-form@7.71.1(react@19.1.0)) '@radix-ui/react-avatar': specifier: ^1.1.11 - version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-collapsible': - specifier: ^1.1.12 - version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-label': specifier: ^2.1.8 - version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-popover': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-radio-group': specifier: ^1.3.8 - version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-select': specifier: ^2.2.6 - version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-separator': specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': specifier: ^1.2.4 - version: 1.2.4(@types/react@19.1.17)(react@19.1.0) + version: 1.2.4(@types/react@19.2.9)(react@19.1.0) '@radix-ui/react-tabs': specifier: ^1.1.13 - version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@sentry/cloudflare': - specifier: ^10.32.1 - version: 10.32.1(@cloudflare/workers-types@4.20260103.0) - '@tailwindcss/typography': - specifier: ^0.5.19 - version: 0.5.19(tailwindcss@4.1.18) + specifier: ^10.36.0 + version: 10.36.0(@cloudflare/workers-types@4.20260123.0) '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + version: 4.1.18(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) '@tanstack/react-form': specifier: ^1.27.7 - version: 1.27.7(@tanstack/react-start@1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.27.7(@tanstack/react-start@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tanstack/react-query': - specifier: ^5.90.16 - version: 5.90.16(react@19.1.0) + specifier: ^5.90.19 + version: 5.90.19(react@19.1.0) '@tanstack/react-router': - specifier: ^1.144.0 - version: 1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^1.154.13 + version: 1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tanstack/react-router-ssr-query': - specifier: ^1.144.0 - version: 1.144.0(@tanstack/query-core@5.90.16)(@tanstack/react-query@5.90.16(react@19.1.0))(@tanstack/react-router@1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.144.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/react-router-with-query': - specifier: ^1.130.17 - version: 1.130.17(@tanstack/react-query@5.90.16(react@19.1.0))(@tanstack/react-router@1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.144.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^1.154.13 + version: 1.154.13(@tanstack/query-core@5.90.19)(@tanstack/react-query@5.90.19(react@19.1.0))(@tanstack/react-router@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.154.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tanstack/react-start': - specifier: ^1.145.3 - version: 1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) - '@tanstack/router-plugin': - specifier: ^1.145.2 - version: 1.145.2(@tanstack/react-router@1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + specifier: ^1.154.13 + version: 1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) alchemy: specifier: 'catalog:' - version: 0.82.2(@cloudflare/vite-plugin@1.17.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20260103.0)))(@opentelemetry/api@1.9.0)(kysely@0.28.8)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20260103.0)) + version: 0.83.1(@cloudflare/vite-plugin@1.17.1(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20260120.0)(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0)))(@opentelemetry/api@1.9.0)(kysely@0.28.8)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20260120.0)(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0)) better-auth: specifier: 'catalog:' - version: 1.4.9(@tanstack/react-start@1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.10)(vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + version: 1.4.17(@tanstack/react-start@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260123.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.10)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) better-result: specifier: ^2.5.1 version: 2.5.1 @@ -130,16 +118,13 @@ importers: version: 0.1.0(hono@4.10.4) drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@cloudflare/workers-types@4.20260103.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8) + version: 0.45.1(@cloudflare/workers-types@4.20260123.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8) isomorphic-git: specifier: ^1.36.1 version: 1.36.1 - libsodium: - specifier: ^0.7.16 - version: 0.7.16 lucide-react: - specifier: ^0.525.0 - version: 0.525.0(react@19.1.0) + specifier: ^0.563.0 + version: 0.563.0(react@19.1.0) mime: specifier: ^4.1.0 version: 4.1.0 @@ -148,7 +133,7 @@ importers: version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) radix-ui: specifier: ^1.4.3 - version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: 19.1.0 version: 19.1.0 @@ -156,17 +141,14 @@ importers: specifier: 19.1.0 version: 19.1.0(react@19.1.0) react-hook-form: - specifier: ^7.69.0 - version: 7.69.0(react@19.1.0) + specifier: ^7.71.1 + version: 7.71.1(react@19.1.0) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.1.17)(react@19.1.0) + version: 10.1.0(@types/react@19.2.9)(react@19.1.0) react-shiki: specifier: ^0.9.1 - version: 0.9.1(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - refractor: - specifier: ^5.0.0 - version: 5.0.0 + version: 0.9.1(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) rehype-raw: specifier: ^7.0.0 version: 7.0.0 @@ -174,8 +156,8 @@ importers: specifier: ^4.0.1 version: 4.0.1 shiki: - specifier: ^3.20.0 - version: 3.20.0 + specifier: ^3.21.0 + version: 3.21.0 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -189,65 +171,62 @@ importers: specifier: ^1.4.0 version: 1.4.0 vite-tsconfig-paths: - specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + specifier: ^6.0.4 + version: 6.0.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) zod: - specifier: ^4.3.2 - version: 4.3.2 + specifier: ^4.3.6 + version: 4.3.6 devDependencies: - '@cloudflare/vite-plugin': - specifier: ^1.17.1 - version: 1.17.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20260103.0)) '@cloudflare/workers-types': - specifier: ^4.20260103.0 - version: 4.20260103.0 + specifier: ^4.20260123.0 + version: 4.20260123.0 '@sentry/cli': - specifier: ^2.58.4 - version: 2.58.4 + specifier: ^3.1.0 + version: 3.1.0 '@tanstack/react-router-devtools': - specifier: ^1.144.0 - version: 1.144.0(@tanstack/react-router@1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.144.0)(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.10) - '@testing-library/dom': - specifier: ^10.4.1 - version: 10.4.1 - '@testing-library/react': - specifier: ^16.3.1 - version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^1.154.13 + version: 1.154.13(@tanstack/react-router@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.154.13)(csstype@3.2.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@types/react': - specifier: ~19.1.17 - version: 19.1.17 + specifier: ~19.2.9 + version: 19.2.9 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.1.17) + version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^5.1.2 - version: 5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.2(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) drizzle-kit: specifier: ^0.31.8 version: 0.31.8 jsdom: - specifier: ^26.1.0 - version: 26.1.0 + specifier: ^27.4.0 + version: 27.4.0(@noble/hashes@2.0.1) typescript: specifier: 'catalog:' version: 5.9.3 vite: - specifier: ^7.3.0 - version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + specifier: ^7.3.1 + version: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) vitest: - specifier: ^4.0.17 - version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) - web-vitals: - specifier: ^5.1.0 - version: 5.1.0 + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) wrangler: - specifier: ^4.54.0 - version: 4.54.0(@cloudflare/workers-types@4.20260103.0) + specifier: ^4.60.0 + version: 4.60.0(@cloudflare/workers-types@4.20260123.0) packages: - '@asamuzakjp/css-color@3.2.0': - resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@4.1.1': + resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} + + '@asamuzakjp/dom-selector@6.7.6': + resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -457,10 +436,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.28.4': - resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} - engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -473,20 +448,20 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - '@better-auth/core@1.4.9': - resolution: {integrity: sha512-JT2q4NDkQzN22KclUEoZ7qU6tl9HUTfK1ctg2oWlT87SEagkwJcnrUwS9VznL+u9ziOIfY27P0f7/jSnmvLcoQ==} + '@better-auth/core@1.4.17': + resolution: {integrity: sha512-WSaEQDdUO6B1CzAmissN6j0lx9fM9lcslEYzlApB5UzFaBeAOHNUONTdglSyUs6/idiZBoRvt0t/qMXCgIU8ug==} peerDependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 - better-call: 1.1.7 + better-call: 1.1.8 jose: ^6.1.0 kysely: ^0.28.5 nanostores: ^1.0.1 - '@better-auth/telemetry@1.4.9': - resolution: {integrity: sha512-Tthy1/Gmx+pYlbvRQPBTKfVei8+pJwvH1NZp+5SbhwA6K2EXIaoonx/K6N/AXYs2aKUpyR4/gzqDesDjL7zd6A==} + '@better-auth/telemetry@1.4.17': + resolution: {integrity: sha512-R1BC4e/bNjQbXu7lG6ubpgmsPj7IMqky5DvMlzAtnAJWJhh99pMh/n6w5gOHa0cqDZgEAuj75IPTxv+q3YiInA==} peerDependencies: - '@better-auth/core': 1.4.9 + '@better-auth/core': 1.4.17 '@better-auth/utils@0.3.0': resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} @@ -553,10 +528,19 @@ packages: '@clack/prompts@0.11.0': resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} - '@cloudflare/kv-asset-handler@0.4.1': - resolution: {integrity: sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg==} + '@cloudflare/kv-asset-handler@0.4.2': + resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} + '@cloudflare/unenv-preset@2.11.0': + resolution: {integrity: sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: ^1.20260115.0 + peerDependenciesMeta: + workerd: + optional: true + '@cloudflare/unenv-preset@2.7.13': resolution: {integrity: sha512-NulO1H8R/DzsJguLC0ndMuk4Ufv0KSlN+E54ay9rn9ZCQo0kpAPwwh3LhgpZ96a3Dr6L9LqW57M4CqC34iLOvw==} peerDependencies: @@ -587,32 +571,62 @@ packages: cpu: [x64] os: [darwin] + '@cloudflare/workerd-darwin-64@1.20260120.0': + resolution: {integrity: sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20251210.0': resolution: {integrity: sha512-Mg8iYIZQFnbevq/ls9eW/eneWTk/EE13Pej1MwfkY5et0jVpdHnvOLywy/o+QtMJFef1AjsqXGULwAneYyBfHw==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260120.0': + resolution: {integrity: sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + '@cloudflare/workerd-linux-64@1.20251210.0': resolution: {integrity: sha512-kjC2fCZhZ2Gkm1biwk2qByAYpGguK5Gf5ic8owzSCUw0FOUfQxTZUT9Lp3gApxsfTLbbnLBrX/xzWjywH9QR4g==} engines: {node: '>=16'} cpu: [x64] os: [linux] + '@cloudflare/workerd-linux-64@1.20260120.0': + resolution: {integrity: sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + '@cloudflare/workerd-linux-arm64@1.20251210.0': resolution: {integrity: sha512-2IB37nXi7PZVQLa1OCuO7/6pNxqisRSO8DmCQ5x/3sezI5op1vwOxAcb1osAnuVsVN9bbvpw70HJvhKruFJTuA==} engines: {node: '>=16'} cpu: [arm64] os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260120.0': + resolution: {integrity: sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + '@cloudflare/workerd-windows-64@1.20251210.0': resolution: {integrity: sha512-Uaz6/9XE+D6E7pCY4OvkCuJHu7HcSDzeGcCGY1HLhojXhHd7yL52c3yfiyJdS8hPatiAa0nn5qSI/42+aTdDSw==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20260103.0': - resolution: {integrity: sha512-jANmoGpJcXARnwlkvrQOeWyjYD1quTfHcs+++Z544XRHOSfLc4XSlts7snIhbiIGgA5bo66zDhraF+9lKUr2hw==} + '@cloudflare/workerd-windows-64@1.20260120.0': + resolution: {integrity: sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260123.0': + resolution: {integrity: sha512-pQccZ8IDLFKkvdKBXZRPkbMtWtS7vVz1giJGkAAZ5cZH2RHK5Bs6p1OoVZA8Z2Sry8Q0tZbZ5Yjud4R7SrG3KQ==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -642,6 +656,10 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-syntax-patches-for-csstree@1.0.25': + resolution: {integrity: sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==} + engines: {node: '>=18'} + '@csstools/css-tokenizer@3.0.4': resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} @@ -649,8 +667,8 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} - '@emnapi/runtime@1.6.0': - resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} @@ -1260,6 +1278,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.9.0': + resolution: {integrity: sha512-lagqsvnk09NKogQaN/XrtlWeUF8SRhT12odMvbTIIaVObqzwAogL6jhR4DAp0gPuKoM1AOVrKUshJpRdpMFrww==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1278,17 +1305,17 @@ packages: '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} - '@git-diff-view/core@0.0.35': - resolution: {integrity: sha512-cdH3BopR6AWUW+6hP78zGyryKxR9JkPgryd1JN78i5k+F9Eo4x/4S23ZF1VZnrpPlGLrSuYfiAZ0ho5m+pTuKg==} + '@git-diff-view/core@0.0.36': + resolution: {integrity: sha512-A3mN21C/ZdIJz0J9/PSZvLR1dLBu8BG13tVE+w4A9v8dLS5omkoQVjHM9K5GaoJideMhK/11y6V7CMbMXCPQoQ==} - '@git-diff-view/file@0.0.35': - resolution: {integrity: sha512-1dDtW3fFpvM4Sj37m251Ofr/8mYJKE3YdiK3lKIDHBkdymF7vWp3O2dHUASKy1/HSd27NcHPrMRFqGUIMQN5Tg==} + '@git-diff-view/file@0.0.36': + resolution: {integrity: sha512-3I5GMpZzdTwSurzbz8ZT9HL2xo74W5/o/SgWhUpEWjn2Z6pCZrHLAEzWeYyqewv30YM25PuK/JMODT4S5zhvzw==} - '@git-diff-view/lowlight@0.0.35': - resolution: {integrity: sha512-MVpOxrNn1oHVOTOWUjxLbbf1W4OtVHjj6CHxwJbBRg9ZWZdShBINjuEgHVMSGB6vZuHKfwruRfXw8XxV3aF8zw==} + '@git-diff-view/lowlight@0.0.36': + resolution: {integrity: sha512-8H3fUfnW+jw8EEbhEOr0vq5jXxZfbhNgvf9ruY6GljQdymj8j2R69k83gwMB/GltWEMt5o5624d8MxD21vqsZA==} - '@git-diff-view/react@0.0.35': - resolution: {integrity: sha512-jIzESQ/oB2FZgEGK1urvAqju89cThv5+enEC4r2atDX15W5xMDNsDBe8FT2U74zBj8JkgROaiGmbF93KIJrWpg==} + '@git-diff-view/react@0.0.36': + resolution: {integrity: sha512-RqM4vkU2uoiav8di5LavR8UDkSP+fNDftlNOfAMQmcuLDFpDnLhtqcgot4+Q5rflkwK04UlYL6G72ps9XSGgDw==} 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 @@ -1301,111 +1328,248 @@ packages: '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + '@img/sharp-darwin-arm64@0.33.5': resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.33.5': resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.0.4': resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.0.4': resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.0.4': resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.33.5': resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.33.5': resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -2418,60 +2582,60 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry/cli-darwin@2.58.4': - resolution: {integrity: sha512-kbTD+P4X8O+nsNwPxCywtj3q22ecyRHWff98rdcmtRrvwz8CKi/T4Jxn/fnn2i4VEchy08OWBuZAqaA5Kh2hRQ==} - engines: {node: '>=10'} + '@sentry/cli-darwin@3.1.0': + resolution: {integrity: sha512-xT1WlCHenGGO29Lq/wKaIthdqZzNzZhlPs7dXrzlBx9DyA2Jnl0g7WEau0oWi8GyJGVRXCJMiCydR//Tb5qVwA==} + engines: {node: '>=18'} os: [darwin] - '@sentry/cli-linux-arm64@2.58.4': - resolution: {integrity: sha512-0g0KwsOozkLtzN8/0+oMZoOuQ0o7W6O+hx+ydVU1bktaMGKEJLMAWxOQNjsh1TcBbNIXVOKM/I8l0ROhaAb8Ig==} - engines: {node: '>=10'} + '@sentry/cli-linux-arm64@3.1.0': + resolution: {integrity: sha512-Jm/iHLKiHxrZYlAq2tT07amiegEVCOAQT9Unilr6djjcZzS2tcI9ThSRQvjP9tFpFRKop+NyNGE3XHXf69r00g==} + engines: {node: '>=18'} cpu: [arm64] os: [linux, freebsd, android] - '@sentry/cli-linux-arm@2.58.4': - resolution: {integrity: sha512-rdQ8beTwnN48hv7iV7e7ZKucPec5NJkRdrrycMJMZlzGBPi56LqnclgsHySJ6Kfq506A2MNuQnKGaf/sBC9REA==} - engines: {node: '>=10'} + '@sentry/cli-linux-arm@3.1.0': + resolution: {integrity: sha512-kbP3/8/Ct/Jbm569KDXbFIyMyPypIegObvIT7LdSsfdYSZdBd396GV7vUpSGKiLUVVN0xjn8OqQ48AVGfjmuMg==} + engines: {node: '>=18'} cpu: [arm] os: [linux, freebsd, android] - '@sentry/cli-linux-i686@2.58.4': - resolution: {integrity: sha512-NseoIQAFtkziHyjZNPTu1Gm1opeQHt7Wm1LbLrGWVIRvUOzlslO9/8i6wETUZ6TjlQxBVRgd3Q0lRBG2A8rFYA==} - engines: {node: '>=10'} + '@sentry/cli-linux-i686@3.1.0': + resolution: {integrity: sha512-f/PK/EGK5vFOy7LC4Riwb+BEE20Nk7RbEFEMjvRq26DpETCrZYUGlbpIKvJFKOaUmr79aAkFCA/EjJiYfcQP2Q==} + engines: {node: '>=18'} cpu: [x86, ia32] os: [linux, freebsd, android] - '@sentry/cli-linux-x64@2.58.4': - resolution: {integrity: sha512-d3Arz+OO/wJYTqCYlSN3Ktm+W8rynQ/IMtSZLK8nu0ryh5mJOh+9XlXY6oDXw4YlsM8qCRrNquR8iEI1Y/IH+Q==} - engines: {node: '>=10'} + '@sentry/cli-linux-x64@3.1.0': + resolution: {integrity: sha512-T+v8x1ujhixZrOrH0sVhsW6uLwK4n0WS+B+5xV46WqUKe32cbYotursp2y53ROjgat8SQDGeP/VnC0Qa3Y2fEA==} + engines: {node: '>=18'} cpu: [x64] os: [linux, freebsd, android] - '@sentry/cli-win32-arm64@2.58.4': - resolution: {integrity: sha512-bqYrF43+jXdDBh0f8HIJU3tbvlOFtGyRjHB8AoRuMQv9TEDUfENZyCelhdjA+KwDKYl48R1Yasb4EHNzsoO83w==} - engines: {node: '>=10'} + '@sentry/cli-win32-arm64@3.1.0': + resolution: {integrity: sha512-2DIPq6aW2DC34EDC9J0xwD+9BpFnKdFGdIcQUZMS+5pXlU6V7o8wpZxZAM8TdYNmsPkkQGKp7Dhl/arWpvNgrw==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@sentry/cli-win32-i686@2.58.4': - resolution: {integrity: sha512-3triFD6jyvhVcXOmGyttf+deKZcC1tURdhnmDUIBkiDPJKGT/N5xa4qAtHJlAB/h8L9jgYih9bvJnvvFVM7yug==} - engines: {node: '>=10'} + '@sentry/cli-win32-i686@3.1.0': + resolution: {integrity: sha512-2NuywEiiZn6xJ1yAV2xjv/nuHiy6kZU5XR3RSAIrPdEZD1nBoMsH/gB2FufQw58Ziz/7otFcX+vtGpJjbIT5mQ==} + engines: {node: '>=18'} cpu: [x86, ia32] os: [win32] - '@sentry/cli-win32-x64@2.58.4': - resolution: {integrity: sha512-cSzN4PjM1RsCZ4pxMjI0VI7yNCkxiJ5jmWncyiwHXGiXrV1eXYdQ3n1LhUYLZ91CafyprR0OhDcE+RVZ26Qb5w==} - engines: {node: '>=10'} + '@sentry/cli-win32-x64@3.1.0': + resolution: {integrity: sha512-Ip405Yqdrr+l9TImsZOJz6c9Nb4zvXcmtOIBKLHc9cowpfXfmlqsHbDp7Xh4+k4L0uLr9i+8ilgQ6ypcuF4UCg==} + engines: {node: '>=18'} cpu: [x64] os: [win32] - '@sentry/cli@2.58.4': - resolution: {integrity: sha512-ArDrpuS8JtDYEvwGleVE+FgR+qHaOp77IgdGSacz6SZy6Lv90uX0Nu4UrHCQJz8/xwIcNxSqnN22lq0dH4IqTg==} - engines: {node: '>= 10'} + '@sentry/cli@3.1.0': + resolution: {integrity: sha512-ngnx6E8XjXpg1uzma45INfKCS8yurb/fl3cZdXTCa2wmek8b4N6WIlmOlTKFTBrV54OauF6mloJxAlpuzoQR6g==} + engines: {node: '>= 18'} hasBin: true - '@sentry/cloudflare@10.32.1': - resolution: {integrity: sha512-LP5p+OyCjZ+eT2WEso4YHj8b9+9Z8sx15tQcyxP6SWdYPuu54AmbgNS5zxG5z7+q2neF5HA/TtEhr/t/l5ph2g==} + '@sentry/cloudflare@10.36.0': + resolution: {integrity: sha512-OKEsiLFc1HmoLDRgOVNw8DkaAd45gRcB0wW9jL3q8Z2LQUrKtj+1IAsPyuh684R+NhgGFXgsBWUYZ/6raGg7BA==} engines: {node: '>=18'} peerDependencies: '@cloudflare/workers-types': ^4.x @@ -2479,27 +2643,27 @@ packages: '@cloudflare/workers-types': optional: true - '@sentry/core@10.32.1': - resolution: {integrity: sha512-PH2ldpSJlhqsMj2vCTyU0BI2Fx1oIDhm7Izo5xFALvjVCS0gmlqHt1udu6YlKn8BtpGH6bGzssvv5APrk+OdPQ==} + '@sentry/core@10.36.0': + resolution: {integrity: sha512-EYJjZvofI+D93eUsPLDIUV0zQocYqiBRyXS6CCV6dHz64P/Hob5NJQOwPa8/v6nD+UvJXvwsFfvXOHhYZhZJOQ==} engines: {node: '>=18'} - '@shikijs/core@3.20.0': - resolution: {integrity: sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g==} + '@shikijs/core@3.21.0': + resolution: {integrity: sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==} - '@shikijs/engine-javascript@3.20.0': - resolution: {integrity: sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg==} + '@shikijs/engine-javascript@3.21.0': + resolution: {integrity: sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ==} - '@shikijs/engine-oniguruma@3.20.0': - resolution: {integrity: sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ==} + '@shikijs/engine-oniguruma@3.21.0': + resolution: {integrity: sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==} - '@shikijs/langs@3.20.0': - resolution: {integrity: sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA==} + '@shikijs/langs@3.21.0': + resolution: {integrity: sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==} - '@shikijs/themes@3.20.0': - resolution: {integrity: sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ==} + '@shikijs/themes@3.21.0': + resolution: {integrity: sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==} - '@shikijs/types@3.20.0': - resolution: {integrity: sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw==} + '@shikijs/types@3.21.0': + resolution: {integrity: sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==} '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -2778,11 +2942,6 @@ packages: resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} engines: {node: '>= 10'} - '@tailwindcss/typography@0.5.19': - resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} - peerDependencies: - tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.1.18': resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} peerDependencies: @@ -2795,16 +2954,16 @@ packages: '@tanstack/form-core@1.27.7': resolution: {integrity: sha512-nvogpyE98fhb0NDw1Bf2YaCH+L7ZIUgEpqO9TkHucDn6zg3ni521boUpv0i8HKIrmmFwDYjWZoCnrgY4HYWTkw==} - '@tanstack/history@1.141.0': - resolution: {integrity: sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ==} + '@tanstack/history@1.154.7': + resolution: {integrity: sha512-YBgwS9qG4rs1ZY/ZrhQtjOH8BG9Qa2wf2AsxT/SnZ4HZJ1DcCEqkoiHH0yH6CYvdDit31X5HokOqQrRSsZEwGA==} engines: {node: '>=12'} '@tanstack/pacer-lite@0.1.1': resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} engines: {node: '>=18'} - '@tanstack/query-core@5.90.16': - resolution: {integrity: sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==} + '@tanstack/query-core@5.90.19': + resolution: {integrity: sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA==} '@tanstack/react-form@1.27.7': resolution: {integrity: sha512-xTg4qrUY0fuLaSnkATLZcK3BWlnwLp7IuAb6UTbZKngiDEvvDCNTvVvHgPlgef1O2qN4klZxInRyRY6oEkXZ2A==} @@ -2815,25 +2974,25 @@ packages: '@tanstack/react-start': optional: true - '@tanstack/react-query@5.90.16': - resolution: {integrity: sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==} + '@tanstack/react-query@5.90.19': + resolution: {integrity: sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==} peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.144.0': - resolution: {integrity: sha512-nstjZvZbOM4U0/Hzi82rtsP1DsR2tfigBidK+WuaDRVVstBsnwVor3DQXTGY5CcfgIiMI3eKzI17VOy3SQDDoQ==} + '@tanstack/react-router-devtools@1.154.13': + resolution: {integrity: sha512-xiDTXODhG2prvyHBrOpFRDYAuqVvLAhMp/gQo54Rmfp8aPlr3CwHejzCO7oShkMWsTamRbodcJyFPGERaQ6YrQ==} engines: {node: '>=12'} peerDependencies: - '@tanstack/react-router': ^1.144.0 - '@tanstack/router-core': ^1.144.0 + '@tanstack/react-router': ^1.154.13 + '@tanstack/router-core': ^1.154.13 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router-ssr-query@1.144.0': - resolution: {integrity: sha512-re83VQxo586dSCmo8qUQvOwRGZljElN1/7pkYVYxaRRhtW2hrVVnr7Z2B6UF7XHp6FtScU7QAvMjPj8i0mTm6g==} + '@tanstack/react-router-ssr-query@1.154.13': + resolution: {integrity: sha512-Sp5uxYEhhdvQZpcf+NV30/ByL2zLypizefzNeXxmPsvvV8NE8NT/tiOkk+kS+cwsmEoiKf0SqwvG9H+NcwQFfw==} engines: {node: '>=12'} peerDependencies: '@tanstack/query-core': '>=5.90.0' @@ -2842,39 +3001,29 @@ packages: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-router-with-query@1.130.17': - resolution: {integrity: sha512-TNaSocW20KuPwUojEm130DLWTr9M5hsSzxiu4QqS2jNCnrGLuDrwMHyP+6fq13lG3YuU4u9O1qajxfJIGomZCg==} + '@tanstack/react-router@1.154.13': + resolution: {integrity: sha512-/Vdqt7w4dmW2ARf85Qav6TBytn8iJiqle4BGJKDYsVuPtMuQKJnxNpKPaCN+H0DiV4KHoFoQrcPVyr9hqy/ltg==} engines: {node: '>=12'} peerDependencies: - '@tanstack/react-query': '>=5.49.2' - '@tanstack/react-router': '>=1.43.2' - '@tanstack/router-core': '>=1.114.7' react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-router@1.144.0': - resolution: {integrity: sha512-GmRyIGmHtGj3VLTHXepIwXAxTcHyL5W7Vw7O1CnVEtFxQQWKMVOnWgI7tPY6FhlNwMKVb3n0mPFWz9KMYyd2GA==} - engines: {node: '>=12'} - peerDependencies: - react: '>=18.0.0 || >=19.0.0' - react-dom: '>=18.0.0 || >=19.0.0' - - '@tanstack/react-start-client@1.145.0': - resolution: {integrity: sha512-UC/+ONaOzuFnlHbOEudYS+AHOrcwAJaqbnfh9zZ5pUtTkJToBawW3YabDbMnS3o6lEiKggc8uGpsiCglUJrBcA==} + '@tanstack/react-start-client@1.154.13': + resolution: {integrity: sha512-xHhMDpMwsI10xIvOu0qFSs59zu4tKAM/x3cqU5hTru43nqLnBygYQn/HsDniX/MJAkUGxAYcaJ3CmtWv2txenQ==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start-server@1.145.3': - resolution: {integrity: sha512-HHFq8KTUUsgjifNpYfU7o1jJaVmrwhrjtqQuabGiRseaeIRd4qIGsIS6M1bmOM4+5sYZLKm+lkP6oxgOBuvvaQ==} + '@tanstack/react-start-server@1.154.13': + resolution: {integrity: sha512-wKVm1DF4k6CjE1nRlSToRpQGKD0aVIHZFxcVEg4IfBN64pSAW/uva7mV+UPyisJbBERNyo+u8YER3V17NEh7Pg==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start@1.145.3': - resolution: {integrity: sha512-ZRd0VbcpPSmYTGdR7PF5LdyPnB7rd4zfyuf8bjtUbjphh4P0wjE3DUTA7Mk29RMvvo6sS7Advjsax9ZqEevLgg==} + '@tanstack/react-start@1.154.13': + resolution: {integrity: sha512-fA/r/Pu07sep5AILaEgU/MMVKeOn6OyV1BEl/L6Rm0o7UJzXr9bwTMj7uKrAFWUFsWbHT7+wyNgpZTZNHinSIA==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -2887,31 +3036,30 @@ packages: 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 - '@tanstack/router-core@1.144.0': - resolution: {integrity: sha512-6oVERtK9XDHCP4XojgHsdHO56ZSj11YaWjF5g/zw39LhyA6Lx+/X86AEIHO4y0BUrMQaJfcjdAQMVSAs6Vjtdg==} + '@tanstack/router-core@1.154.13': + resolution: {integrity: sha512-ZtSM/qcytCDI6Pt5eZLwYhDbVheXI54HN29J+PpC6Lp6xUq/Q2ZOMwb/wJF942EDeqTjxRtl7W9Bpvz7DBwq8g==} engines: {node: '>=12'} - '@tanstack/router-devtools-core@1.144.0': - resolution: {integrity: sha512-rbpQn1aHUtcfY3U3SyJqOZRqDu0a2uPK+TE2CH50HieJApmCuNKj5RsjVQYHgwiFFvR0w0LUmueTnl2X2hiWTg==} + '@tanstack/router-devtools-core@1.154.13': + resolution: {integrity: sha512-gUKcfAQT9QkkBvDive7f3DUJIfJ0uHYfIsh/8+AUvKEeojMq3uIEk6LdvLHA89icb+t+m7H6jj9bwfxZlDBlwA==} engines: {node: '>=12'} peerDependencies: - '@tanstack/router-core': ^1.144.0 + '@tanstack/router-core': ^1.154.13 csstype: ^3.0.10 - solid-js: '>=1.9.5' peerDependenciesMeta: csstype: optional: true - '@tanstack/router-generator@1.145.2': - resolution: {integrity: sha512-6DLwfqhexgxw2T2QuS9Y349Vb49hCXBIz9mjWyynjMrpejLXJL+PaHaKJw0Y+H7Ao6RE2vlvXCc2cMjgbz5c7Q==} + '@tanstack/router-generator@1.154.13': + resolution: {integrity: sha512-aoasV7bM0vU7FvuVoTXpPCyX4v1qkYy24OQUOGfpgduCkw/S7cZBttqxWxcAt3WB6KnDVPlrdkl2Il8TLyCzVw==} engines: {node: '>=12'} - '@tanstack/router-plugin@1.145.2': - resolution: {integrity: sha512-dOABjCE4M2KxB+f/mY71dDZduwVTpf+tCPb4NxmAqbF5Rxes24QaaIZQmiU12jte/L8zYyIA/yX9fi93xZue5Q==} + '@tanstack/router-plugin@1.154.13': + resolution: {integrity: sha512-PSkc1HPcmPSAxKze11RHHN4jF6f70c9/TQh+dSp50gyEAgOIm5hlQ+0RkuKIOGzFDlBvXe0cStcXq45LsHesjQ==} engines: {node: '>=12'} peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.144.0 + '@tanstack/react-router': ^1.154.13 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' vite-plugin-solid: ^2.11.10 webpack: '>=5.92.0' @@ -2927,37 +3075,37 @@ packages: webpack: optional: true - '@tanstack/router-ssr-query-core@1.144.0': - resolution: {integrity: sha512-ECM4T6Gtj768Oi62iprDr45KDR6J/jvmVifJK9SRmoXW9WtiASymxB9H8tkPsX+yPEs22ZCs/j38hv63Mo30sQ==} + '@tanstack/router-ssr-query-core@1.154.13': + resolution: {integrity: sha512-QfyrUJA7klsM+erqpJTtFAMI0k6UrvEzh4wNPq8dIVM8rP43wbWBZhX3oC9O13lY9KC+wm9k4R98VwIv2TBClQ==} engines: {node: '>=12'} peerDependencies: '@tanstack/query-core': '>=5.90.0' '@tanstack/router-core': '>=1.127.0' - '@tanstack/router-utils@1.143.11': - resolution: {integrity: sha512-N24G4LpfyK8dOlnP8BvNdkuxg1xQljkyl6PcrdiPSA301pOjatRT1y8wuCCJZKVVD8gkd0MpCZ0VEjRMGILOtA==} + '@tanstack/router-utils@1.154.7': + resolution: {integrity: sha512-61bGx32tMKuEpVRseu2sh1KQe8CfB7793Mch/kyQt0EP3tD7X0sXmimCl3truRiDGUtI0CaSoQV1NPjAII1RBA==} engines: {node: '>=12'} - '@tanstack/start-client-core@1.145.0': - resolution: {integrity: sha512-pqINeN7ZqdfTZrkhy9C7isxRr8U3cByH5ZtLVnUxJp9fvLgwX7LlI+OWpGI0q3E8f/mHMUqJdeE56+atSs8Khw==} + '@tanstack/start-client-core@1.154.13': + resolution: {integrity: sha512-uGyXt35esKA32W+w2F4/1yGFz6Q9GNdXv1eZ6XY7NCAtGdmNAoRqRlJwaoLC29l2zOip+OE0V5Ee19jEL+carA==} engines: {node: '>=22.12.0'} - '@tanstack/start-fn-stubs@1.143.8': - resolution: {integrity: sha512-2IKUPh/TlxwzwHMiHNeFw95+L2sD4M03Es27SxMR0A60Qc4WclpaD6gpC8FsbuNASM2jBxk2UyeYClJxW1GOAQ==} + '@tanstack/start-fn-stubs@1.154.7': + resolution: {integrity: sha512-D69B78L6pcFN5X5PHaydv7CScQcKLzJeEYqs7jpuyyqGQHSUIZUjS955j+Sir8cHhuDIovCe2LmsYHeZfWf3dQ==} engines: {node: '>=22.12.0'} - '@tanstack/start-plugin-core@1.145.3': - resolution: {integrity: sha512-PUWKI/8OMyvq8Yjn8ccbEwenASBs5YPEHpXmUjeZ0qb8REGJ6v71Twlqtuva6/fBqZrAKl+2CZrWjgbYZr/h8g==} + '@tanstack/start-plugin-core@1.154.13': + resolution: {integrity: sha512-ZBs1CYz4bPssxZI2Xz3JgnMKP/O/rgBtx8IcMK/Hef4tvUMSl/1VbGUNG8cgFoudxNbm0iEXRlQ5PCX3YQMt+w==} engines: {node: '>=22.12.0'} peerDependencies: vite: '>=7.0.0' - '@tanstack/start-server-core@1.145.3': - resolution: {integrity: sha512-atsi0fyzymG9BRDJL4kb0oJjhCdB+Wqds+OGPDiWj5VOteCXLpop0ulDlak6wNL2QJZbqqv5BgtGbTQ6rlNyJg==} + '@tanstack/start-server-core@1.154.13': + resolution: {integrity: sha512-qbydwn3BQEtmzTaI4L9uraxGfEufO5yumXeaTygD7WqtYiHC7hVBhQPXg6rj25/ZU9YPiS743NOFoSYk+xLDdg==} engines: {node: '>=22.12.0'} - '@tanstack/start-storage-context@1.144.0': - resolution: {integrity: sha512-DuUx5CXfLNettyJlsHDQp66y5haeqzXJkUor7kp5p10SVv24p76dTYqBOpw+wQz//RfJlOciIZFVBcKezXXY0w==} + '@tanstack/start-storage-context@1.154.13': + resolution: {integrity: sha512-iImN6+4yM6EFi0pNukJPgV7FxV93d9ASXJpCRJ9JYvrJq9FZGMskcBJIAyQEIZTfjOYCzPSAs1fzN8c8g4Gn6g==} engines: {node: '>=22.12.0'} '@tanstack/store@0.7.7': @@ -2966,37 +3114,15 @@ packages: '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} - '@tanstack/virtual-file-routes@1.141.0': - resolution: {integrity: sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A==} + '@tanstack/virtual-file-routes@1.154.7': + resolution: {integrity: sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg==} engines: {node: '>=12'} - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} - engines: {node: '>=18'} - - '@testing-library/react@16.3.1': - resolution: {integrity: sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==} - engines: {node: '>=18'} - peerDependencies: - '@testing-library/dom': ^10.0.0 - '@types/react': ^18.0.0 || ^19.0.0 - '@types/react-dom': ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@trpc/server@11.7.1': resolution: {integrity: sha512-N3U8LNLIP4g9C7LJ/sLkjuPHwqlvE3bnspzC4DEFVdvx2+usbn70P80E3wj5cjOTLhmhRiwJCSXhlB+MHfGeCw==} peerDependencies: typescript: '>=5.7.2' - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -3036,16 +3162,13 @@ packages: '@types/node@24.10.4': resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} - '@types/prismjs@1.26.5': - resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} - '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.1.17': - resolution: {integrity: sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==} + '@types/react@19.2.9': + resolution: {integrity: sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==} '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3062,11 +3185,11 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/expect@4.0.17': - resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} - '@vitest/mocker@4.0.17': - resolution: {integrity: sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==} + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0-0 @@ -3076,20 +3199,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.0.17': - resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==} + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} - '@vitest/runner@4.0.17': - resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==} + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} - '@vitest/snapshot@4.0.17': - resolution: {integrity: sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==} + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} - '@vitest/spy@4.0.17': - resolution: {integrity: sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==} + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} - '@vitest/utils@4.0.17': - resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==} + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} '@vue/reactivity@3.5.24': resolution: {integrity: sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==} @@ -3115,16 +3238,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - agent-base@6.0.2: - resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} - engines: {node: '>= 6.0.0'} - agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - alchemy@0.82.2: - resolution: {integrity: sha512-qNyP9fScDIFJdnimbL8EXAU5HczFrb5+xanjBpYBfZRyKSrmfLhW4J2272CfQ2u9jBM6wgNVdeGUPmtj4SAmZw==} + alchemy@0.83.1: + resolution: {integrity: sha512-lbXjwhdAYxtF1R4O/XE204cIYx8AbMa34pNChAEucuFYlNfOBbBW96uiFYMSTA+VhrOHt+EiCUy85LXwh2s0YA==} hasBin: true peerDependencies: '@astrojs/cloudflare': ^12.6.4 @@ -3193,10 +3312,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -3216,9 +3331,6 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3256,13 +3368,14 @@ packages: before-after-hook@3.0.2: resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} - better-auth@1.4.9: - resolution: {integrity: sha512-usSdjuyTzZwIvM8fjF8YGhPncxV3MAg3dHUO9uPUnf0yklXUSYISiH1+imk6/Z+UBqsscyyPRnbIyjyK97p7YA==} + better-auth@1.4.17: + resolution: {integrity: sha512-VmHGQyKsEahkEs37qguROKg/6ypYpNF13D7v/lkbO7w7Aivz0Bv2h+VyUkH4NzrGY0QBKXi1577mGhDCVwp0ew==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 '@sveltejs/kit': ^2.0.0 '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 better-sqlite3: ^12.0.0 drizzle-kit: '>=0.31.4' drizzle-orm: '>=0.41.0' @@ -3286,6 +3399,8 @@ packages: optional: true '@tanstack/react-start': optional: true + '@tanstack/solid-start': + optional: true better-sqlite3: optional: true drizzle-kit: @@ -3315,8 +3430,8 @@ packages: vue: optional: true - better-call@1.1.7: - resolution: {integrity: sha512-6gaJe1bBIEgVebQu/7q9saahVzvBsGaByEnE8aDVncZEDiJO7sdNB28ot9I6iXSbR25egGmmZ6aIURXyQHRraQ==} + better-call@1.1.8: + resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: @@ -3327,6 +3442,9 @@ packages: resolution: {integrity: sha512-guw/CpafxGh6QeRoc3H9KBxbynQfdouzPLysMAxyFl52mKQ8wa6SfuDLbWDdglPHca6GNA6DmBh4LdsmQlxSxA==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -3481,25 +3599,24 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - cssstyle@4.6.0: - resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} - engines: {node: '>=18'} + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} + data-urls@6.0.1: + resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} + engines: {node: '>=20'} date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -3573,9 +3690,6 @@ packages: peerDependencies: hono: ^4.7.11 - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -3920,8 +4034,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - h3@2.0.1-rc.7: - resolution: {integrity: sha512-qbrRu1OLXmUYnysWOCVrYhtC/m8ZuXu/zCbo3U/KyphJxbPFiC76jHYwVrmEcss9uNAHO5BoUguQ46yEpgI2PA==} + h3@2.0.1-rc.11: + resolution: {integrity: sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw==} engines: {node: '>=20.11.1'} peerDependencies: crossws: ^0.4.1 @@ -3980,9 +4094,9 @@ packages: resolution: {integrity: sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg==} engines: {node: '>=16.9.0'} - html-encoding-sniffer@4.0.0: - resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} - engines: {node: '>=18'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -3997,10 +4111,6 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} - https-proxy-agent@5.0.1: - resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} - engines: {node: '>= 6'} - https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -4140,9 +4250,9 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - jsdom@26.1.0: - resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} - engines: {node: '>=18'} + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 peerDependenciesMeta: @@ -4269,18 +4379,18 @@ packages: resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} engines: {node: 20 || >=22} + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@0.525.0: - resolution: {integrity: sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==} + lucide-react@0.563.0: + resolution: {integrity: sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4336,6 +4446,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -4420,11 +4533,6 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} - hasBin: true - mime@4.1.0: resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} engines: {node: '>=16'} @@ -4439,6 +4547,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + miniflare@4.20260120.0: + resolution: {integrity: sha512-XXZyE2pDKMtP5OLuv0LPHEAzIYhov4jrYjcqrhhqtxGGtXneWOHvXIPo+eV8sqwqWd3R7j4DlEKcyb+87BR49Q==} + engines: {node: '>=18.0.0'} + hasBin: true + minimatch@10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -4485,15 +4598,6 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -4512,9 +4616,6 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - nwsapi@2.2.22: - resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} - nypm@0.6.2: resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==} engines: {node: ^14.16.0 || >=16.10.0} @@ -4564,6 +4665,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4608,10 +4712,6 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss-selector-parser@6.0.10: - resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} - engines: {node: '>=4'} - postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -4621,10 +4721,6 @@ packages: engines: {node: '>=14'} hasBin: true - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -4674,15 +4770,12 @@ packages: peerDependencies: react: ^19.1.0 - react-hook-form@7.69.0: - resolution: {integrity: sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==} + react-hook-form@7.71.1: + resolution: {integrity: sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -4760,9 +4853,6 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} - refractor@5.0.0: - resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==} - regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -4787,6 +4877,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -4802,9 +4896,6 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} - rrweb-cssom@0.8.0: - resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} - run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -4873,6 +4964,10 @@ packages: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4881,8 +4976,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shiki@3.20.0: - resolution: {integrity: sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg==} + shiki@3.21.0: + resolution: {integrity: sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==} siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -4933,8 +5028,8 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - srvx@0.10.0: - resolution: {integrity: sha512-NqIsR+wQCfkvvwczBh8J8uM4wTZx41K2lLSEp/3oMp917ODVVMtW5Me4epCmQ3gH8D+0b+/t4xxkUKutyhimTA==} + srvx@0.10.1: + resolution: {integrity: sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg==} engines: {node: '>=20.16.0'} hasBin: true @@ -5031,11 +5126,11 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tldts-core@6.1.86: - resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} - tldts@6.1.86: - resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} hasBin: true to-buffer@1.2.2: @@ -5046,16 +5141,13 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - tough-cookie@5.1.2: - resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - - tr46@5.1.1: - resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} - engines: {node: '>=18'} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -5106,38 +5198,38 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo-darwin-64@2.7.2: - resolution: {integrity: sha512-dxY3X6ezcT5vm3coK6VGixbrhplbQMwgNsCsvZamS/+/6JiebqW9DKt4NwpgYXhDY2HdH00I7FWs3wkVuan4rA==} + turbo-darwin-64@2.7.5: + resolution: {integrity: sha512-nN3wfLLj4OES/7awYyyM7fkU8U8sAFxsXau2bYJwAWi6T09jd87DgHD8N31zXaJ7LcpyppHWPRI2Ov9MuZEwnQ==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.7.2: - resolution: {integrity: sha512-1bXmuwPLqNFt3mzrtYcVx1sdJ8UYb124Bf48nIgcpMCGZy3kDhgxNv1503kmuK/37OGOZbsWSQFU4I08feIuSg==} + turbo-darwin-arm64@2.7.5: + resolution: {integrity: sha512-wCoDHMiTf3FgLAbZHDDx/unNNonSGhsF5AbbYODbxnpYyoKDpEYacUEPjZD895vDhNvYCH0Nnk24YsP4n/cD6g==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.7.2: - resolution: {integrity: sha512-kP+TiiMaiPugbRlv57VGLfcjFNsFbo8H64wMBCPV2270Or2TpDCBULMzZrvEsvWFjT3pBFvToYbdp8/Kw0jAQg==} + turbo-linux-64@2.7.5: + resolution: {integrity: sha512-KKPvhOmJMmzWj/yjeO4LywkQ85vOJyhru7AZk/+c4B6OUh/odQ++SiIJBSbTG2lm1CuV5gV5vXZnf/2AMlu3Zg==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.7.2: - resolution: {integrity: sha512-VDJwQ0+8zjAfbyY6boNaWfP6RIez4ypKHxwkuB6SrWbOSk+vxTyW5/hEjytTwK8w/TsbKVcMDyvpora8tEsRFw==} + turbo-linux-arm64@2.7.5: + resolution: {integrity: sha512-8PIva4L6BQhiPikUTds9lSFSHXVDAsEvV6QUlgwPsXrtXVQMVi6Sv9p+IxtlWQFvGkdYJUgX9GnK2rC030Xcmw==} cpu: [arm64] os: [linux] - turbo-windows-64@2.7.2: - resolution: {integrity: sha512-rPjqQXVnI6A6oxgzNEE8DNb6Vdj2Wwyhfv3oDc+YM3U9P7CAcBIlKv/868mKl4vsBtz4ouWpTQNXG8vljgJO+w==} + turbo-windows-64@2.7.5: + resolution: {integrity: sha512-rupskv/mkIUgQXzX/wUiK00mKMorQcK8yzhGFha/D5lm05FEnLx8dsip6rWzMcVpvh+4GUMA56PgtnOgpel2AA==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.7.2: - resolution: {integrity: sha512-tcnHvBhO515OheIFWdxA+qUvZzNqqcHbLVFc1+n+TJ1rrp8prYicQtbtmsiKgMvr/54jb9jOabU62URAobnB7g==} + turbo-windows-arm64@2.7.5: + resolution: {integrity: sha512-G377Gxn6P42RnCzfMyDvsqQV7j69kVHKlhz9J4RhtJOB5+DyY4yYh/w0oTIxZQ4JRMmhjwLu3w9zncMoQ6nNDw==} cpu: [arm64] os: [win32] - turbo@2.7.2: - resolution: {integrity: sha512-5JIA5aYBAJSAhrhbyag1ZuMSgUZnHtI+Sq3H8D3an4fL8PeF+L1yYvbEJg47akP1PFfATMf5ehkqFnxfkmuwZQ==} + turbo@2.7.5: + resolution: {integrity: sha512-7Imdmg37joOloTnj+DPrab9hIaQcDdJ5RwSzcauo/wMOSAgO+A/I/8b3hsGGs6PWQz70m/jkPgdqWsfNKtwwDQ==} hasBin: true tw-animate-css@1.4.0: @@ -5162,6 +5254,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@6.23.0: + resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} + engines: {node: '>=18.17'} + undici@7.14.0: resolution: {integrity: sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ==} engines: {node: '>=20.18.1'} @@ -5170,6 +5266,10 @@ packages: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} + undici@7.18.2: + resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.21: resolution: {integrity: sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==} @@ -5248,16 +5348,16 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite-tsconfig-paths@5.1.4: - resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + vite-tsconfig-paths@6.0.4: + resolution: {integrity: sha512-iIsEJ+ek5KqRTK17pmxtgIxXtqr3qDdE6OxrP9mVeGhVDNXRJTKN/l9oMbujTQNzMLe6XZ8qmpztfbkPu2TiFQ==} peerDependencies: vite: '*' peerDependenciesMeta: vite: optional: true - vite@7.3.0: - resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -5304,18 +5404,18 @@ packages: vite: optional: true - vitest@4.0.17: - resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.17 - '@vitest/browser-preview': 4.0.17 - '@vitest/browser-webdriverio': 4.0.17 - '@vitest/ui': 4.0.17 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -5345,15 +5445,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - web-vitals@5.1.0: - resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} - - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -5367,12 +5461,13 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} - whatwg-url@14.2.0: - resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} - engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} @@ -5393,12 +5488,17 @@ packages: engines: {node: '>=16'} hasBin: true - wrangler@4.54.0: - resolution: {integrity: sha512-bANFsjDwJLbprYoBK+hUDZsVbUv2SqJd8QvArLIcZk+fPq4h/Ohtj5vkKXD3k0s2bD1DXLk08D+hYmeNH+xC6A==} + workerd@1.20260120.0: + resolution: {integrity: sha512-R6X/VQOkwLTBGLp4VRUwLQZZVxZ9T9J8pGiJ6GQUMaRkY7TVWrCSkVfoNMM1/YyFsY5UYhhPoQe5IehnhZ3Pdw==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.60.0: + resolution: {integrity: sha512-n4kibm/xY0Qd5G2K/CbAQeVeOIlwPNVglmFjlDRCCYk3hZh8IggO/rg8AXt/vByK2Sxsugl5Z7yvgWxrUbmS6g==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20251210.0 + '@cloudflare/workers-types': ^4.20260120.0 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -5480,21 +5580,33 @@ packages: zod@4.1.12: resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} - zod@4.3.2: - resolution: {integrity: sha512-b8L8yn4rIVfiXyHAmnr52/ZEpDumlT0bmxiq3Ws1ybrinhflGpt12Hvv54kYnEsGPRs6o/Ka3/ppA2OWY21IVg==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@asamuzakjp/css-color@3.2.0': + '@acemir/cssom@0.9.31': {} + + '@asamuzakjp/css-color@4.1.1': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - lru-cache: 10.4.3 + lru-cache: 11.2.4 + + '@asamuzakjp/dom-selector@6.7.6': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.4 + + '@asamuzakjp/nwsapi@2.3.9': {} '@aws-crypto/sha256-browser@5.2.0': dependencies: @@ -5997,8 +6109,6 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/runtime@7.28.4': {} - '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -6022,20 +6132,20 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@better-auth/core@1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.2))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)': + '@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)': dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@standard-schema/spec': 1.0.0 - better-call: 1.1.7(zod@4.3.2) + better-call: 1.1.8(zod@4.3.6) jose: 6.1.0 kysely: 0.28.8 nanostores: 1.0.1 - zod: 4.3.2 + zod: 4.3.6 - '@better-auth/telemetry@1.4.9(@better-auth/core@1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.2))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))': + '@better-auth/telemetry@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))': dependencies: - '@better-auth/core': 1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.2))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) + '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -6089,25 +6199,30 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 - '@cloudflare/kv-asset-handler@0.4.1': + '@cloudflare/kv-asset-handler@0.4.2': {} + + '@cloudflare/unenv-preset@2.11.0(unenv@2.0.0-rc.24)(workerd@1.20260120.0)': dependencies: - mime: 3.0.0 + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260120.0 - '@cloudflare/unenv-preset@2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251210.0)': + '@cloudflare/unenv-preset@2.7.13(unenv@2.0.0-rc.24)(workerd@1.20260120.0)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20251210.0 + workerd: 1.20260120.0 + optional: true - '@cloudflare/unenv-preset@2.7.7(unenv@2.0.0-rc.21)(workerd@1.20251210.0)': + '@cloudflare/unenv-preset@2.7.7(unenv@2.0.0-rc.21)(workerd@1.20260120.0)': dependencies: unenv: 2.0.0-rc.21 optionalDependencies: - workerd: 1.20251210.0 + workerd: 1.20260120.0 - '@cloudflare/vite-plugin@1.17.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20260103.0))': + '@cloudflare/vite-plugin@1.17.1(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20260120.0)(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0))': dependencies: - '@cloudflare/unenv-preset': 2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251210.0) + '@cloudflare/unenv-preset': 2.7.13(unenv@2.0.0-rc.24)(workerd@1.20260120.0) '@remix-run/node-fetch-server': 0.8.1 defu: 6.1.4 get-port: 7.1.0 @@ -6115,30 +6230,46 @@ snapshots: picocolors: 1.1.1 tinyglobby: 0.2.15 unenv: 2.0.0-rc.24 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) - wrangler: 4.54.0(@cloudflare/workers-types@4.20260103.0) + vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + wrangler: 4.60.0(@cloudflare/workers-types@4.20260123.0) ws: 8.18.0 transitivePeerDependencies: - bufferutil - utf-8-validate - workerd + optional: true '@cloudflare/workerd-darwin-64@1.20251210.0': optional: true + '@cloudflare/workerd-darwin-64@1.20260120.0': + optional: true + '@cloudflare/workerd-darwin-arm64@1.20251210.0': optional: true + '@cloudflare/workerd-darwin-arm64@1.20260120.0': + optional: true + '@cloudflare/workerd-linux-64@1.20251210.0': optional: true + '@cloudflare/workerd-linux-64@1.20260120.0': + optional: true + '@cloudflare/workerd-linux-arm64@1.20251210.0': optional: true + '@cloudflare/workerd-linux-arm64@1.20260120.0': + optional: true + '@cloudflare/workerd-windows-64@1.20251210.0': optional: true - '@cloudflare/workers-types@4.20260103.0': {} + '@cloudflare/workerd-windows-64@1.20260120.0': + optional: true + + '@cloudflare/workers-types@4.20260123.0': {} '@cspotcode/source-map-support@0.8.1': dependencies: @@ -6162,11 +6293,13 @@ snapshots: dependencies: '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-syntax-patches-for-csstree@1.0.25': {} + '@csstools/css-tokenizer@3.0.4': {} '@drizzle-team/brocli@0.10.2': {} - '@emnapi/runtime@1.6.0': + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true @@ -6481,6 +6614,10 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true + '@exodus/bytes@1.9.0(@noble/hashes@2.0.1)': + optionalDependencies: + '@noble/hashes': 2.0.1 + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -6500,30 +6637,30 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} - '@git-diff-view/core@0.0.35': + '@git-diff-view/core@0.0.36': dependencies: - '@git-diff-view/lowlight': 0.0.35 + '@git-diff-view/lowlight': 0.0.36 fast-diff: 1.3.0 highlight.js: 11.11.1 lowlight: 3.3.0 - '@git-diff-view/file@0.0.35': + '@git-diff-view/file@0.0.36': dependencies: - '@git-diff-view/core': 0.0.35 + '@git-diff-view/core': 0.0.36 diff: 8.0.2 fast-diff: 1.3.0 highlight.js: 11.11.1 lowlight: 3.3.0 - '@git-diff-view/lowlight@0.0.35': + '@git-diff-view/lowlight@0.0.36': dependencies: '@types/hast': 3.0.4 highlight.js: 11.11.1 lowlight: 3.3.0 - '@git-diff-view/react@0.0.35(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@git-diff-view/react@0.0.36(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@git-diff-view/core': 0.0.35 + '@git-diff-view/core': 0.0.36 '@types/hast': 3.0.4 fast-diff: 1.3.0 highlight.js: 11.11.1 @@ -6533,88 +6670,184 @@ snapshots: reactivity-store: 0.3.12(react@19.1.0) use-sync-external-store: 1.6.0(react@19.1.0) - '@hookform/resolvers@5.2.2(react-hook-form@7.69.0(react@19.1.0))': + '@hookform/resolvers@5.2.2(react-hook-form@7.71.1(react@19.1.0))': dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.69.0(react@19.1.0) + react-hook-form: 7.71.1(react@19.1.0) '@iarna/toml@2.2.5': {} + '@img/colour@1.0.0': {} + '@img/sharp-darwin-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.0.4 optional: true + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + '@img/sharp-darwin-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.0.4 optional: true + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + '@img/sharp-libvips-darwin-arm64@1.0.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + '@img/sharp-libvips-darwin-x64@1.0.4': optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + '@img/sharp-libvips-linux-arm64@1.0.4': optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + '@img/sharp-libvips-linux-arm@1.0.5': optional: true + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + '@img/sharp-libvips-linux-s390x@1.0.4': optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + '@img/sharp-libvips-linux-x64@1.0.4': optional: true + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.0.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + '@img/sharp-linux-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.0.4 optional: true + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + '@img/sharp-linux-arm@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.0.5 optional: true + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + '@img/sharp-linux-s390x@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.0.4 optional: true + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + '@img/sharp-linux-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.0.4 optional: true + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + '@img/sharp-linuxmusl-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + '@img/sharp-linuxmusl-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.0.4 optional: true + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + '@img/sharp-wasm32@0.33.5': dependencies: - '@emnapi/runtime': 1.6.0 + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': optional: true '@img/sharp-win32-ia32@0.33.5': optional: true + '@img/sharp-win32-ia32@0.34.5': + optional: true + '@img/sharp-win32-x64@0.33.5': optional: true + '@img/sharp-win32-x64@0.34.5': + optional: true + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -6764,803 +6997,804 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-context': 1.1.3(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.9)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-context@1.1.2(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.9)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-context@1.1.3(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-context@1.1.3(@types/react@19.2.9)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) aria-hidden: 1.2.6 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.1(@types/react@19.1.17)(react@19.1.0) + react-remove-scroll: 2.7.1(@types/react@19.2.9)(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-direction@1.1.1(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-direction@1.1.1(@types/react@19.2.9)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.9)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-id@1.1.1(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.9)(react@19.1.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) aria-hidden: 1.2.6 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.1(@types/react@19.1.17)(react@19.1.0) + react-remove-scroll: 2.7.1(@types/react@19.2.9)(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) aria-hidden: 1.2.6 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.1(@types/react@19.1.17)(react@19.1.0) + react-remove-scroll: 2.7.1(@types/react@19.2.9)(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.9)(react@19.1.0) '@radix-ui/rect': 1.1.1 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) aria-hidden: 1.2.6 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.1(@types/react@19.1.17)(react@19.1.0) + react-remove-scroll: 2.7.1(@types/react@19.2.9)(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-slot@1.2.3(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.9)(react@19.1.0)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-slot@1.2.4(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-slot@1.2.4(@types/react@19.2.9)(react@19.1.0)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.9)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.9)(react@19.1.0)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.9)(react@19.1.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.9)(react@19.1.0)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.9)(react@19.1.0)': dependencies: react: 19.1.0 use-sync-external-store: 1.6.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.9)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.9)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.9)(react@19.1.0)': dependencies: '@radix-ui/rect': 1.1.1 react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-use-size@1.1.1(@types/react@19.1.17)(react@19.1.0)': + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.9)(react@19.1.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) react: 19.1.0 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) '@radix-ui/rect@1.1.1': {} - '@remix-run/node-fetch-server@0.8.1': {} + '@remix-run/node-fetch-server@0.8.1': + optional: true '@rolldown/pluginutils@1.0.0-beta.40': {} @@ -7634,86 +7868,82 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry/cli-darwin@2.58.4': + '@sentry/cli-darwin@3.1.0': optional: true - '@sentry/cli-linux-arm64@2.58.4': + '@sentry/cli-linux-arm64@3.1.0': optional: true - '@sentry/cli-linux-arm@2.58.4': + '@sentry/cli-linux-arm@3.1.0': optional: true - '@sentry/cli-linux-i686@2.58.4': + '@sentry/cli-linux-i686@3.1.0': optional: true - '@sentry/cli-linux-x64@2.58.4': + '@sentry/cli-linux-x64@3.1.0': optional: true - '@sentry/cli-win32-arm64@2.58.4': + '@sentry/cli-win32-arm64@3.1.0': optional: true - '@sentry/cli-win32-i686@2.58.4': + '@sentry/cli-win32-i686@3.1.0': optional: true - '@sentry/cli-win32-x64@2.58.4': + '@sentry/cli-win32-x64@3.1.0': optional: true - '@sentry/cli@2.58.4': + '@sentry/cli@3.1.0': dependencies: - https-proxy-agent: 5.0.1 - node-fetch: 2.7.0 progress: 2.0.3 proxy-from-env: 1.1.0 + undici: 6.23.0 which: 2.0.2 optionalDependencies: - '@sentry/cli-darwin': 2.58.4 - '@sentry/cli-linux-arm': 2.58.4 - '@sentry/cli-linux-arm64': 2.58.4 - '@sentry/cli-linux-i686': 2.58.4 - '@sentry/cli-linux-x64': 2.58.4 - '@sentry/cli-win32-arm64': 2.58.4 - '@sentry/cli-win32-i686': 2.58.4 - '@sentry/cli-win32-x64': 2.58.4 - transitivePeerDependencies: - - encoding - - supports-color + '@sentry/cli-darwin': 3.1.0 + '@sentry/cli-linux-arm': 3.1.0 + '@sentry/cli-linux-arm64': 3.1.0 + '@sentry/cli-linux-i686': 3.1.0 + '@sentry/cli-linux-x64': 3.1.0 + '@sentry/cli-win32-arm64': 3.1.0 + '@sentry/cli-win32-i686': 3.1.0 + '@sentry/cli-win32-x64': 3.1.0 - '@sentry/cloudflare@10.32.1(@cloudflare/workers-types@4.20260103.0)': + '@sentry/cloudflare@10.36.0(@cloudflare/workers-types@4.20260123.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@sentry/core': 10.32.1 + '@sentry/core': 10.36.0 optionalDependencies: - '@cloudflare/workers-types': 4.20260103.0 + '@cloudflare/workers-types': 4.20260123.0 - '@sentry/core@10.32.1': {} + '@sentry/core@10.36.0': {} - '@shikijs/core@3.20.0': + '@shikijs/core@3.21.0': dependencies: - '@shikijs/types': 3.20.0 + '@shikijs/types': 3.21.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@3.20.0': + '@shikijs/engine-javascript@3.21.0': dependencies: - '@shikijs/types': 3.20.0 + '@shikijs/types': 3.21.0 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.4 - '@shikijs/engine-oniguruma@3.20.0': + '@shikijs/engine-oniguruma@3.21.0': dependencies: - '@shikijs/types': 3.20.0 + '@shikijs/types': 3.21.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@3.20.0': + '@shikijs/langs@3.21.0': dependencies: - '@shikijs/types': 3.20.0 + '@shikijs/types': 3.21.0 - '@shikijs/themes@3.20.0': + '@shikijs/themes@3.21.0': dependencies: - '@shikijs/types': 3.20.0 + '@shikijs/types': 3.21.0 - '@shikijs/types@3.20.0': + '@shikijs/types@3.21.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -8065,17 +8295,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/typography@0.5.19(tailwindcss@4.1.18)': - dependencies: - postcss-selector-parser: 6.0.10 - tailwindcss: 4.1.18 - - '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) '@tanstack/devtools-event-client@0.4.0': {} @@ -8085,104 +8310,95 @@ snapshots: '@tanstack/pacer-lite': 0.1.1 '@tanstack/store': 0.7.7 - '@tanstack/history@1.141.0': {} + '@tanstack/history@1.154.7': {} '@tanstack/pacer-lite@0.1.1': {} - '@tanstack/query-core@5.90.16': {} + '@tanstack/query-core@5.90.19': {} - '@tanstack/react-form@1.27.7(@tanstack/react-start@1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@tanstack/react-form@1.27.7(@tanstack/react-start@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@tanstack/form-core': 1.27.7 '@tanstack/react-store': 0.8.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 optionalDependencies: - '@tanstack/react-start': 1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + '@tanstack/react-start': 1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) transitivePeerDependencies: - react-dom - '@tanstack/react-query@5.90.16(react@19.1.0)': + '@tanstack/react-query@5.90.19(react@19.1.0)': dependencies: - '@tanstack/query-core': 5.90.16 + '@tanstack/query-core': 5.90.19 react: 19.1.0 - '@tanstack/react-router-devtools@1.144.0(@tanstack/react-router@1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.144.0)(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.10)': + '@tanstack/react-router-devtools@1.154.13(@tanstack/react-router@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.154.13)(csstype@3.2.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@tanstack/react-router': 1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/router-devtools-core': 1.144.0(@tanstack/router-core@1.144.0)(csstype@3.1.3)(solid-js@1.9.10) + '@tanstack/react-router': 1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/router-devtools-core': 1.154.13(@tanstack/router-core@1.154.13)(csstype@3.2.3) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@tanstack/router-core': 1.144.0 + '@tanstack/router-core': 1.154.13 transitivePeerDependencies: - csstype - - solid-js - '@tanstack/react-router-ssr-query@1.144.0(@tanstack/query-core@5.90.16)(@tanstack/react-query@5.90.16(react@19.1.0))(@tanstack/react-router@1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.144.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@tanstack/react-router-ssr-query@1.154.13(@tanstack/query-core@5.90.19)(@tanstack/react-query@5.90.19(react@19.1.0))(@tanstack/react-router@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.154.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@tanstack/query-core': 5.90.16 - '@tanstack/react-query': 5.90.16(react@19.1.0) - '@tanstack/react-router': 1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/router-ssr-query-core': 1.144.0(@tanstack/query-core@5.90.16)(@tanstack/router-core@1.144.0) + '@tanstack/query-core': 5.90.19 + '@tanstack/react-query': 5.90.19(react@19.1.0) + '@tanstack/react-router': 1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/router-ssr-query-core': 1.154.13(@tanstack/query-core@5.90.19)(@tanstack/router-core@1.154.13) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: - '@tanstack/router-core' - '@tanstack/react-router-with-query@1.130.17(@tanstack/react-query@5.90.16(react@19.1.0))(@tanstack/react-router@1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.144.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@tanstack/react-router@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@tanstack/react-query': 5.90.16(react@19.1.0) - '@tanstack/react-router': 1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/router-core': 1.144.0 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - - '@tanstack/react-router@1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@tanstack/history': 1.141.0 + '@tanstack/history': 1.154.7 '@tanstack/react-store': 0.8.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/router-core': 1.144.0 + '@tanstack/router-core': 1.154.13 isbot: 5.1.31 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-start-client@1.145.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@tanstack/react-start-client@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@tanstack/react-router': 1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/router-core': 1.144.0 - '@tanstack/start-client-core': 1.145.0 + '@tanstack/react-router': 1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/router-core': 1.154.13 + '@tanstack/start-client-core': 1.154.13 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-start-server@1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@tanstack/react-start-server@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@tanstack/history': 1.141.0 - '@tanstack/react-router': 1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/router-core': 1.144.0 - '@tanstack/start-client-core': 1.145.0 - '@tanstack/start-server-core': 1.145.3 + '@tanstack/history': 1.154.7 + '@tanstack/react-router': 1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/router-core': 1.154.13 + '@tanstack/start-client-core': 1.154.13 + '@tanstack/start-server-core': 1.154.13 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: - crossws - '@tanstack/react-start@1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': + '@tanstack/react-start@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': dependencies: - '@tanstack/react-router': 1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/react-start-client': 1.145.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/react-start-server': 1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/router-utils': 1.143.11 - '@tanstack/start-client-core': 1.145.0 - '@tanstack/start-plugin-core': 1.145.3(@tanstack/react-router@1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) - '@tanstack/start-server-core': 1.145.3 + '@tanstack/react-router': 1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-start-client': 1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-start-server': 1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/router-utils': 1.154.7 + '@tanstack/start-client-core': 1.154.13 + '@tanstack/start-plugin-core': 1.154.13(@tanstack/react-router@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + '@tanstack/start-server-core': 1.154.13 pathe: 2.0.3 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -8197,9 +8413,9 @@ snapshots: react-dom: 19.1.0(react@19.1.0) use-sync-external-store: 1.6.0(react@19.1.0) - '@tanstack/router-core@1.144.0': + '@tanstack/router-core@1.154.13': dependencies: - '@tanstack/history': 1.141.0 + '@tanstack/history': 1.154.7 '@tanstack/store': 0.8.0 cookie-es: 2.0.0 seroval: 1.4.2 @@ -8207,21 +8423,20 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.144.0(@tanstack/router-core@1.144.0)(csstype@3.1.3)(solid-js@1.9.10)': + '@tanstack/router-devtools-core@1.154.13(@tanstack/router-core@1.154.13)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.144.0 + '@tanstack/router-core': 1.154.13 clsx: 2.1.1 - goober: 2.1.18(csstype@3.1.3) - solid-js: 1.9.10 + goober: 2.1.18(csstype@3.2.3) tiny-invariant: 1.3.3 optionalDependencies: - csstype: 3.1.3 + csstype: 3.2.3 - '@tanstack/router-generator@1.145.2': + '@tanstack/router-generator@1.154.13': dependencies: - '@tanstack/router-core': 1.144.0 - '@tanstack/router-utils': 1.143.11 - '@tanstack/virtual-file-routes': 1.141.0 + '@tanstack/router-core': 1.154.13 + '@tanstack/router-utils': 1.154.7 + '@tanstack/virtual-file-routes': 1.154.7 prettier: 3.6.2 recast: 0.23.11 source-map: 0.7.6 @@ -8230,7 +8445,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.145.2(@tanstack/react-router@1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': + '@tanstack/router-plugin@1.154.13(@tanstack/react-router@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) @@ -8238,26 +8453,26 @@ snapshots: '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 - '@tanstack/router-core': 1.144.0 - '@tanstack/router-generator': 1.145.2 - '@tanstack/router-utils': 1.143.11 - '@tanstack/virtual-file-routes': 1.141.0 + '@tanstack/router-core': 1.154.13 + '@tanstack/router-generator': 1.154.13 + '@tanstack/router-utils': 1.154.7 + '@tanstack/virtual-file-routes': 1.154.7 babel-dead-code-elimination: 1.0.11 chokidar: 3.6.0 unplugin: 2.3.10 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + '@tanstack/react-router': 1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@tanstack/router-ssr-query-core@1.144.0(@tanstack/query-core@5.90.16)(@tanstack/router-core@1.144.0)': + '@tanstack/router-ssr-query-core@1.154.13(@tanstack/query-core@5.90.19)(@tanstack/router-core@1.154.13)': dependencies: - '@tanstack/query-core': 5.90.16 - '@tanstack/router-core': 1.144.0 + '@tanstack/query-core': 5.90.19 + '@tanstack/router-core': 1.154.13 - '@tanstack/router-utils@1.143.11': + '@tanstack/router-utils@1.154.7': dependencies: '@babel/core': 7.28.5 '@babel/generator': 7.28.5 @@ -8269,38 +8484,38 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/start-client-core@1.145.0': + '@tanstack/start-client-core@1.154.13': dependencies: - '@tanstack/router-core': 1.144.0 - '@tanstack/start-fn-stubs': 1.143.8 - '@tanstack/start-storage-context': 1.144.0 + '@tanstack/router-core': 1.154.13 + '@tanstack/start-fn-stubs': 1.154.7 + '@tanstack/start-storage-context': 1.154.13 seroval: 1.4.2 tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/start-fn-stubs@1.143.8': {} + '@tanstack/start-fn-stubs@1.154.7': {} - '@tanstack/start-plugin-core@1.145.3(@tanstack/react-router@1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': + '@tanstack/start-plugin-core@1.154.13(@tanstack/react-router@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 '@babel/types': 7.28.5 '@rolldown/pluginutils': 1.0.0-beta.40 - '@tanstack/router-core': 1.144.0 - '@tanstack/router-generator': 1.145.2 - '@tanstack/router-plugin': 1.145.2(@tanstack/react-router@1.144.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) - '@tanstack/router-utils': 1.143.11 - '@tanstack/start-client-core': 1.145.0 - '@tanstack/start-server-core': 1.145.3 + '@tanstack/router-core': 1.154.13 + '@tanstack/router-generator': 1.154.13 + '@tanstack/router-plugin': 1.154.13(@tanstack/react-router@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + '@tanstack/router-utils': 1.154.7 + '@tanstack/start-client-core': 1.154.13 + '@tanstack/start-server-core': 1.154.13 babel-dead-code-elimination: 1.0.11 cheerio: 1.1.2 exsolve: 1.0.7 pathe: 2.0.3 - srvx: 0.10.0 + srvx: 0.10.1 tinyglobby: 0.2.15 ufo: 1.6.1 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) xmlbuilder2: 4.0.3 zod: 3.25.76 transitivePeerDependencies: @@ -8311,55 +8526,32 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/start-server-core@1.145.3': + '@tanstack/start-server-core@1.154.13': dependencies: - '@tanstack/history': 1.141.0 - '@tanstack/router-core': 1.144.0 - '@tanstack/start-client-core': 1.145.0 - '@tanstack/start-storage-context': 1.144.0 - h3-v2: h3@2.0.1-rc.7 + '@tanstack/history': 1.154.7 + '@tanstack/router-core': 1.154.13 + '@tanstack/start-client-core': 1.154.13 + '@tanstack/start-storage-context': 1.154.13 + h3-v2: h3@2.0.1-rc.11 seroval: 1.4.2 tiny-invariant: 1.3.3 transitivePeerDependencies: - crossws - '@tanstack/start-storage-context@1.144.0': + '@tanstack/start-storage-context@1.154.13': dependencies: - '@tanstack/router-core': 1.144.0 + '@tanstack/router-core': 1.154.13 '@tanstack/store@0.7.7': {} '@tanstack/store@0.8.0': {} - '@tanstack/virtual-file-routes@1.141.0': {} - - '@testing-library/dom@10.4.1': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.28.4 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - picocolors: 1.1.1 - pretty-format: 27.5.1 - - '@testing-library/react@16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@babel/runtime': 7.28.4 - '@testing-library/dom': 10.4.1 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@tanstack/virtual-file-routes@1.154.7': {} '@trpc/server@11.7.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@types/aria-query@5.0.4': {} - '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -8413,15 +8605,13 @@ snapshots: undici-types: 7.16.0 optional: true - '@types/prismjs@1.26.5': {} - - '@types/react-dom@19.2.3(@types/react@19.1.17)': + '@types/react-dom@19.2.3(@types/react@19.2.9)': dependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - '@types/react@19.1.17': + '@types/react@19.2.9': dependencies: - csstype: 3.1.3 + csstype: 3.2.3 '@types/unist@2.0.11': {} @@ -8429,7 +8619,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': + '@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -8437,47 +8627,47 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/expect@4.0.17': + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.0.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.17 - '@vitest/utils': 4.0.17 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.17(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': dependencies: - '@vitest/spy': 4.0.17 + '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) - '@vitest/pretty-format@4.0.17': + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 - '@vitest/runner@4.0.17': + '@vitest/runner@4.0.18': dependencies: - '@vitest/utils': 4.0.17 + '@vitest/utils': 4.0.18 pathe: 2.0.3 - '@vitest/snapshot@4.0.17': + '@vitest/snapshot@4.0.18': dependencies: - '@vitest/pretty-format': 4.0.17 + '@vitest/pretty-format': 4.0.18 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.17': {} + '@vitest/spy@4.0.18': {} - '@vitest/utils@4.0.17': + '@vitest/utils@4.0.18': dependencies: - '@vitest/pretty-format': 4.0.17 + '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 '@vue/reactivity@3.5.24': @@ -8490,31 +8680,27 @@ snapshots: dependencies: event-target-shim: 5.0.1 - acorn-walk@8.3.2: {} + acorn-walk@8.3.2: + optional: true - acorn@8.14.0: {} + acorn@8.14.0: + optional: true acorn@8.15.0: {} - agent-base@6.0.2: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - agent-base@7.1.4: {} - alchemy@0.82.2(@cloudflare/vite-plugin@1.17.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20260103.0)))(@opentelemetry/api@1.9.0)(kysely@0.28.8)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20260103.0)): + alchemy@0.83.1(@cloudflare/vite-plugin@1.17.1(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20260120.0)(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0)))(@opentelemetry/api@1.9.0)(kysely@0.28.8)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20260120.0)(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0)): dependencies: '@aws-sdk/credential-providers': 3.958.0 - '@cloudflare/unenv-preset': 2.7.7(unenv@2.0.0-rc.21)(workerd@1.20251210.0) - '@cloudflare/workers-types': 4.20260103.0 + '@cloudflare/unenv-preset': 2.7.7(unenv@2.0.0-rc.21)(workerd@1.20260120.0) + '@cloudflare/workers-types': 4.20260123.0 '@iarna/toml': 2.2.5 '@octokit/rest': 21.1.1 '@smithy/node-config-provider': 4.3.7 '@smithy/types': 4.11.0 aws4fetch: 1.0.20 - drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260103.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8) + drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260123.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8) env-paths: 3.0.0 esbuild: 0.25.12 execa: 9.6.1 @@ -8524,7 +8710,7 @@ snapshots: glob: 10.5.0 jszip: 3.10.1 libsodium-wrappers: 0.7.15 - miniflare: 4.20251210.0 + miniflare: 4.20260120.0 neverthrow: 8.2.0 open: 10.2.0 openapi-types: 12.1.3 @@ -8533,12 +8719,12 @@ snapshots: proper-lockfile: 4.1.2 signal-exit: 4.1.0 unenv: 2.0.0-rc.21 - wrangler: 4.54.0(@cloudflare/workers-types@4.20260103.0) + wrangler: 4.60.0(@cloudflare/workers-types@4.20260123.0) ws: 8.18.3 yaml: 2.8.2 optionalDependencies: - '@cloudflare/vite-plugin': 1.17.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251210.0)(wrangler@4.54.0(@cloudflare/workers-types@4.20260103.0)) - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + '@cloudflare/vite-plugin': 1.17.1(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20260120.0)(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0)) + vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@electric-sql/pglite' @@ -8580,8 +8766,6 @@ snapshots: dependencies: color-convert: 2.0.1 - ansi-styles@5.2.0: {} - ansi-styles@6.2.3: {} ansis@4.2.0: {} @@ -8597,10 +8781,6 @@ snapshots: dependencies: tslib: 2.8.1 - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - assertion-error@2.0.1: {} ast-types@0.16.1: @@ -8634,42 +8814,46 @@ snapshots: before-after-hook@3.0.2: {} - better-auth@1.4.9(@tanstack/react-start@1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.10)(vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)): + better-auth@1.4.17(@tanstack/react-start@1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260123.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.10)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)): dependencies: - '@better-auth/core': 1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.2))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) - '@better-auth/telemetry': 1.4.9(@better-auth/core@1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.2))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)) + '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) + '@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.0.1 '@noble/hashes': 2.0.1 - better-call: 1.1.7(zod@4.3.2) + better-call: 1.1.8(zod@4.3.6) defu: 6.1.4 jose: 6.1.0 kysely: 0.28.8 nanostores: 1.0.1 - zod: 4.3.2 + zod: 4.3.6 optionalDependencies: - '@tanstack/react-start': 1.145.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + '@tanstack/react-start': 1.154.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) drizzle-kit: 0.31.8 - drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260103.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8) + drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260123.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) solid-js: 1.9.10 - vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) - better-call@1.1.7(zod@4.3.2): + better-call@1.1.8(zod@4.3.6): dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 rou3: 0.7.12 set-cookie-parser: 2.7.2 optionalDependencies: - zod: 4.3.2 + zod: 4.3.6 better-result@2.5.1: dependencies: '@clack/prompts': 0.11.0 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} blake3-wasm@2.1.5: {} @@ -8798,11 +8982,13 @@ snapshots: dependencies: color-name: 1.1.4 simple-swizzle: 0.2.4 + optional: true color@4.2.3: dependencies: color-convert: 2.0.1 color-string: 1.9.1 + optional: true comma-separated-tokens@2.0.3: {} @@ -8838,21 +9024,26 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 - css-what@6.2.2: {} + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 - cssesc@3.0.0: {} + css-what@6.2.2: {} - cssstyle@4.6.0: + cssstyle@5.3.7: dependencies: - '@asamuzakjp/css-color': 3.2.0 - rrweb-cssom: 0.8.0 + '@asamuzakjp/css-color': 4.1.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.25 + css-tree: 3.1.0 + lru-cache: 11.2.4 - csstype@3.1.3: {} + csstype@3.2.3: {} - data-urls@5.0.0: + data-urls@6.0.1: dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 + whatwg-mimetype: 5.0.0 + whatwg-url: 15.1.0 date-fns@4.1.0: {} @@ -8909,8 +9100,6 @@ snapshots: hono: 4.10.4 neofuse: 0.0.1-rc.3 - dom-accessibility-api@0.5.16: {} - dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -8938,9 +9127,9 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8): + drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260123.0)(@opentelemetry/api@1.9.0)(kysely@0.28.8): optionalDependencies: - '@cloudflare/workers-types': 4.20260103.0 + '@cloudflare/workers-types': 4.20260123.0 '@opentelemetry/api': 1.9.0 kysely: 0.28.8 @@ -9136,7 +9325,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - exit-hook@2.2.1: {} + exit-hook@2.2.1: + optional: true expect-type@1.3.0: {} @@ -9207,7 +9397,8 @@ snapshots: get-nonce@1.0.1: {} - get-port@7.1.0: {} + get-port@7.1.0: + optional: true get-proto@1.0.1: dependencies: @@ -9227,7 +9418,8 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-to-regexp@0.4.1: {} + glob-to-regexp@0.4.1: + optional: true glob@10.5.0: dependencies: @@ -9249,18 +9441,18 @@ snapshots: globrex@0.1.2: {} - goober@2.1.18(csstype@3.1.3): + goober@2.1.18(csstype@3.2.3): dependencies: - csstype: 3.1.3 + csstype: 3.2.3 gopd@1.2.0: {} graceful-fs@4.2.11: {} - h3@2.0.1-rc.7: + h3@2.0.1-rc.11: dependencies: rou3: 0.7.12 - srvx: 0.10.0 + srvx: 0.10.1 has-flag@4.0.0: {} @@ -9369,9 +9561,11 @@ snapshots: hono@4.10.4: {} - html-encoding-sniffer@4.0.0: + html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): dependencies: - whatwg-encoding: 3.1.1 + '@exodus/bytes': 1.9.0(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' html-url-attributes@3.0.1: {} @@ -9391,13 +9585,6 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@5.0.1: - dependencies: - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -9428,7 +9615,8 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 - is-arrayish@0.3.4: {} + is-arrayish@0.3.4: + optional: true is-binary-path@2.1.0: dependencies: @@ -9514,29 +9702,30 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@26.1.0: + jsdom@27.4.0(@noble/hashes@2.0.1): dependencies: - cssstyle: 4.6.0 - data-urls: 5.0.0 + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.7.6 + '@exodus/bytes': 1.9.0(@noble/hashes@2.0.1) + cssstyle: 5.3.7 + data-urls: 6.0.1 decimal.js: 10.6.0 - html-encoding-sniffer: 4.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.22 - parse5: 7.3.0 - rrweb-cssom: 0.8.0 + parse5: 8.0.0 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 5.1.2 + tough-cookie: 6.0.0 w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 + webidl-conversions: 8.0.1 whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 + whatwg-url: 15.1.0 ws: 8.18.3 xml-name-validator: 5.0.0 transitivePeerDependencies: + - '@noble/hashes' - bufferutil - supports-color - utf-8-validate @@ -9631,16 +9820,16 @@ snapshots: lru-cache@11.2.2: {} + lru-cache@11.2.4: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lucide-react@0.525.0(react@19.1.0): + lucide-react@0.563.0(react@19.1.0): dependencies: react: 19.1.0 - lz-string@1.5.0: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -9802,6 +9991,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.12.2: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.2.0 @@ -9993,8 +10184,6 @@ snapshots: transitivePeerDependencies: - supports-color - mime@3.0.0: {} - mime@4.1.0: {} mimic-response@3.1.0: {} @@ -10016,6 +10205,20 @@ snapshots: transitivePeerDependencies: - bufferutil - utf-8-validate + optional: true + + miniflare@4.20260120.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.18.2 + workerd: 1.20260120.0 + ws: 8.18.0 + youch: 4.1.0-beta.10 + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - utf-8-validate minimatch@10.1.1: dependencies: @@ -10057,10 +10260,6 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - node-gyp-build@4.8.4: {} node-releases@2.0.27: {} @@ -10076,8 +10275,6 @@ snapshots: dependencies: boolbase: 1.0.0 - nwsapi@2.2.22: {} - nypm@0.6.2: dependencies: citty: 0.1.6 @@ -10140,6 +10337,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-key@3.1.1: {} path-key@4.0.0: {} @@ -10174,11 +10375,6 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-selector-parser@6.0.10: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -10187,12 +10383,6 @@ snapshots: prettier@3.6.2: {} - pretty-format@27.5.1: - dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 - pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -10217,85 +10407,83 @@ snapshots: punycode@2.3.1: {} - radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.9)(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 scheduler: 0.26.0 - react-hook-form@7.69.0(react@19.1.0): + react-hook-form@7.71.1(react@19.1.0): dependencies: react: 19.1.0 - react-is@17.0.2: {} - - react-markdown@10.1.0(@types/react@19.1.17)(react@19.1.0): + react-markdown@10.1.0(@types/react@19.2.9)(react@19.1.0): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 19.1.17 + '@types/react': 19.2.9 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 @@ -10311,47 +10499,47 @@ snapshots: react-refresh@0.18.0: {} - react-remove-scroll-bar@2.3.8(@types/react@19.1.17)(react@19.1.0): + react-remove-scroll-bar@2.3.8(@types/react@19.2.9)(react@19.1.0): dependencies: react: 19.1.0 - react-style-singleton: 2.2.3(@types/react@19.1.17)(react@19.1.0) + react-style-singleton: 2.2.3(@types/react@19.2.9)(react@19.1.0) tslib: 2.8.1 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - react-remove-scroll@2.7.1(@types/react@19.1.17)(react@19.1.0): + react-remove-scroll@2.7.1(@types/react@19.2.9)(react@19.1.0): dependencies: react: 19.1.0 - react-remove-scroll-bar: 2.3.8(@types/react@19.1.17)(react@19.1.0) - react-style-singleton: 2.2.3(@types/react@19.1.17)(react@19.1.0) + react-remove-scroll-bar: 2.3.8(@types/react@19.2.9)(react@19.1.0) + react-style-singleton: 2.2.3(@types/react@19.2.9)(react@19.1.0) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.1.17)(react@19.1.0) - use-sidecar: 1.1.3(@types/react@19.1.17)(react@19.1.0) + use-callback-ref: 1.3.3(@types/react@19.2.9)(react@19.1.0) + use-sidecar: 1.1.3(@types/react@19.2.9)(react@19.1.0) optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - react-shiki@0.9.1(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + react-shiki@0.9.1(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: clsx: 2.1.1 dequal: 2.0.3 hast-util-to-jsx-runtime: 2.3.6 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - shiki: 3.20.0 + shiki: 3.21.0 unist-util-visit: 5.0.0 optionalDependencies: - '@types/react': 19.1.17 - '@types/react-dom': 19.2.3(@types/react@19.1.17) + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) transitivePeerDependencies: - supports-color - react-style-singleton@2.2.3(@types/react@19.1.17)(react@19.1.0): + react-style-singleton@2.2.3(@types/react@19.2.9)(react@19.1.0): dependencies: get-nonce: 1.0.1 react: 19.1.0 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 react@19.1.0: {} @@ -10392,13 +10580,6 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - refractor@5.0.0: - dependencies: - '@types/hast': 3.0.4 - '@types/prismjs': 1.26.5 - hastscript: 9.0.1 - parse-entities: 4.0.2 - regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -10449,6 +10630,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} retry@0.12.0: {} @@ -10483,8 +10666,6 @@ snapshots: rou3@0.7.12: {} - rrweb-cssom@0.8.0: {} - run-applescript@7.1.0: {} safe-buffer@5.1.2: {} @@ -10506,12 +10687,14 @@ snapshots: seroval-plugins@1.3.3(seroval@1.3.2): dependencies: seroval: 1.3.2 + optional: true seroval-plugins@1.4.2(seroval@1.4.2): dependencies: seroval: 1.4.2 - seroval@1.3.2: {} + seroval@1.3.2: + optional: true seroval@1.4.2: {} @@ -10559,6 +10742,38 @@ snapshots: '@img/sharp-wasm32': 0.33.5 '@img/sharp-win32-ia32': 0.33.5 '@img/sharp-win32-x64': 0.33.5 + optional: true + + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 shebang-command@2.0.0: dependencies: @@ -10566,14 +10781,14 @@ snapshots: shebang-regex@3.0.0: {} - shiki@3.20.0: + shiki@3.21.0: dependencies: - '@shikijs/core': 3.20.0 - '@shikijs/engine-javascript': 3.20.0 - '@shikijs/engine-oniguruma': 3.20.0 - '@shikijs/langs': 3.20.0 - '@shikijs/themes': 3.20.0 - '@shikijs/types': 3.20.0 + '@shikijs/core': 3.21.0 + '@shikijs/engine-javascript': 3.21.0 + '@shikijs/engine-oniguruma': 3.21.0 + '@shikijs/langs': 3.21.0 + '@shikijs/themes': 3.21.0 + '@shikijs/types': 3.21.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -10594,14 +10809,16 @@ snapshots: simple-swizzle@0.2.4: dependencies: is-arrayish: 0.3.4 + optional: true sisteransi@1.0.5: {} solid-js@1.9.10: dependencies: - csstype: 3.1.3 + csstype: 3.2.3 seroval: 1.3.2 seroval-plugins: 1.3.3(seroval@1.3.2) + optional: true sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: @@ -10621,13 +10838,14 @@ snapshots: space-separated-tokens@2.0.2: {} - srvx@0.10.0: {} + srvx@0.10.1: {} stackback@0.0.2: {} std-env@3.10.0: {} - stoppable@1.1.0: {} + stoppable@1.1.0: + optional: true string-width@4.2.3: dependencies: @@ -10705,11 +10923,11 @@ snapshots: tinyrainbow@3.0.3: {} - tldts-core@6.1.86: {} + tldts-core@7.0.19: {} - tldts@6.1.86: + tldts@7.0.19: dependencies: - tldts-core: 6.1.86 + tldts-core: 7.0.19 to-buffer@1.2.2: dependencies: @@ -10721,13 +10939,11 @@ snapshots: dependencies: is-number: 7.0.0 - tough-cookie@5.1.2: + tough-cookie@6.0.0: dependencies: - tldts: 6.1.86 - - tr46@0.0.3: {} + tldts: 7.0.19 - tr46@5.1.1: + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -10755,32 +10971,32 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo-darwin-64@2.7.2: + turbo-darwin-64@2.7.5: optional: true - turbo-darwin-arm64@2.7.2: + turbo-darwin-arm64@2.7.5: optional: true - turbo-linux-64@2.7.2: + turbo-linux-64@2.7.5: optional: true - turbo-linux-arm64@2.7.2: + turbo-linux-arm64@2.7.5: optional: true - turbo-windows-64@2.7.2: + turbo-windows-64@2.7.5: optional: true - turbo-windows-arm64@2.7.2: + turbo-windows-arm64@2.7.5: optional: true - turbo@2.7.2: + turbo@2.7.5: optionalDependencies: - turbo-darwin-64: 2.7.2 - turbo-darwin-arm64: 2.7.2 - turbo-linux-64: 2.7.2 - turbo-linux-arm64: 2.7.2 - turbo-windows-64: 2.7.2 - turbo-windows-arm64: 2.7.2 + turbo-darwin-64: 2.7.5 + turbo-darwin-arm64: 2.7.5 + turbo-linux-64: 2.7.5 + turbo-linux-arm64: 2.7.5 + turbo-windows-64: 2.7.5 + turbo-windows-arm64: 2.7.5 tw-animate-css@1.4.0: {} @@ -10814,10 +11030,15 @@ snapshots: undici-types@7.16.0: optional: true - undici@7.14.0: {} + undici@6.23.0: {} + + undici@7.14.0: + optional: true undici@7.16.0: {} + undici@7.18.2: {} + unenv@2.0.0-rc.21: dependencies: defu: 6.1.4 @@ -10880,20 +11101,20 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - use-callback-ref@1.3.3(@types/react@19.1.17)(react@19.1.0): + use-callback-ref@1.3.3(@types/react@19.2.9)(react@19.1.0): dependencies: react: 19.1.0 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 - use-sidecar@1.1.3(@types/react@19.1.17)(react@19.1.0): + use-sidecar@1.1.3(@types/react@19.2.9)(react@19.1.0): dependencies: detect-node-es: 1.1.0 react: 19.1.0 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.9 use-sync-external-store@1.6.0(react@19.1.0): dependencies: @@ -10916,18 +11137,18 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)): + vite-tsconfig-paths@6.0.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2): + vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -10943,19 +11164,19 @@ snapshots: tsx: 4.20.6 yaml: 2.8.2 - vitefu@1.1.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)): + vitefu@1.1.1(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)): optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) - vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2): dependencies: - '@vitest/expect': 4.0.17 - '@vitest/mocker': 4.0.17(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.17 - '@vitest/runner': 4.0.17 - '@vitest/snapshot': 4.0.17 - '@vitest/spy': 4.0.17 - '@vitest/utils': 4.0.17 + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 es-module-lexer: 1.7.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -10967,12 +11188,12 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 24.10.4 - jsdom: 26.1.0 + jsdom: 27.4.0(@noble/hashes@2.0.1) transitivePeerDependencies: - jiti - less @@ -10992,11 +11213,7 @@ snapshots: web-namespaces@2.0.1: {} - web-vitals@5.1.0: {} - - webidl-conversions@3.0.1: {} - - webidl-conversions@7.0.0: {} + webidl-conversions@8.0.1: {} webpack-virtual-modules@0.6.2: {} @@ -11006,15 +11223,12 @@ snapshots: whatwg-mimetype@4.0.0: {} - whatwg-url@14.2.0: - dependencies: - tr46: 5.1.1 - webidl-conversions: 7.0.0 + whatwg-mimetype@5.0.0: {} - whatwg-url@5.0.0: + whatwg-url@15.1.0: dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 which-typed-array@1.1.19: dependencies: @@ -11042,19 +11256,28 @@ snapshots: '@cloudflare/workerd-linux-64': 1.20251210.0 '@cloudflare/workerd-linux-arm64': 1.20251210.0 '@cloudflare/workerd-windows-64': 1.20251210.0 + optional: true - wrangler@4.54.0(@cloudflare/workers-types@4.20260103.0): + workerd@1.20260120.0: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260120.0 + '@cloudflare/workerd-darwin-arm64': 1.20260120.0 + '@cloudflare/workerd-linux-64': 1.20260120.0 + '@cloudflare/workerd-linux-arm64': 1.20260120.0 + '@cloudflare/workerd-windows-64': 1.20260120.0 + + wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0): dependencies: - '@cloudflare/kv-asset-handler': 0.4.1 - '@cloudflare/unenv-preset': 2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251210.0) + '@cloudflare/kv-asset-handler': 0.4.2 + '@cloudflare/unenv-preset': 2.11.0(unenv@2.0.0-rc.24)(workerd@1.20260120.0) blake3-wasm: 2.1.5 esbuild: 0.27.0 - miniflare: 4.20251210.0 + miniflare: 4.20260120.0 path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20251210.0 + workerd: 1.20260120.0 optionalDependencies: - '@cloudflare/workers-types': 4.20260103.0 + '@cloudflare/workers-types': 4.20260123.0 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil @@ -11112,12 +11335,13 @@ snapshots: cookie: 1.0.2 youch-core: 0.3.3 - zod@3.22.3: {} + zod@3.22.3: + optional: true zod@3.25.76: {} zod@4.1.12: {} - zod@4.3.2: {} + zod@4.3.6: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 935fdd3..5c1cb08 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,8 +3,8 @@ packages: - packages/* catalog: - alchemy: ^0.82.2 - better-auth: ^1.4.9 + alchemy: ^0.83.1 + better-auth: ^1.4.17 typescript: ^5.9.3 onlyBuiltDependencies: From 7c2d695c6171b5c7ef4fd4c669014f166d9868f1 Mon Sep 17 00:00:00 2001 From: mdhruvil Date: Mon, 26 Jan 2026 21:59:50 +0530 Subject: [PATCH 07/12] chore: update turbo schema url and use explicit run command --- package.json | 8 ++++---- turbo.json | 6 +----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 414d35c..206159c 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ "scripts": { "check": "biome check .", "check:fix": "biome check --write .", - "dev": "turbo dev", - "build": "turbo build", - "test": "turbo test", - "typecheck": "turbo typecheck" + "dev": "turbo run dev", + "build": "turbo run build", + "test": "turbo run test", + "typecheck": "turbo run typecheck" }, "devDependencies": { "@biomejs/biome": "2.3.2", diff --git a/turbo.json b/turbo.json index 6c570ca..59acd1f 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turbo.build/schema.json", + "$schema": "https://turborepo.dev/schema.v2.json", "ui": "tui", "tasks": { "build": { @@ -11,10 +11,6 @@ "cache": false, "persistent": true }, - "dev:setup": { - "cache": false, - "persistent": true - }, "test": { "inputs": ["$TURBO_DEFAULT$", "**/*.test.ts", "**/*.test.tsx"] }, From 142cd3b38fce60ef4d8299c844afadf90fbd07dd Mon Sep 17 00:00:00 2001 From: mdhruvil Date: Tue, 27 Jan 2026 03:40:35 +0530 Subject: [PATCH 08/12] feat: add HybridRepo DO with Drizzle SQLite schema store git packs, objects, and refs in SQLite-backed DO enabling faster browsing of commits, trees, and blobs - schema: git_packs, git_object_index, git_refs tables - drizzle migrations for DO with sql loader vite plugin - test button on dashboard for verification --- .zed/settings.json | 30 +-- apps/web/alchemy.run.ts | 6 + apps/web/drizzle-do.config.ts | 8 + .../0000_opposite_mattie_franklin.sql | 26 +++ apps/web/drizzle-do/meta/0000_snapshot.json | 178 ++++++++++++++++++ apps/web/drizzle-do/meta/_journal.json | 13 ++ apps/web/drizzle-do/migrations.js | 9 + apps/web/package.json | 1 + apps/web/src/do/hybrid-repo.ts | 38 ++++ apps/web/src/do/schema/git.ts | 58 ++++++ apps/web/src/routes/_layout/dashboard.tsx | 14 ++ apps/web/src/server.ts | 1 + apps/web/vite.config.ts | 23 ++- 13 files changed, 391 insertions(+), 14 deletions(-) create mode 100644 apps/web/drizzle-do.config.ts create mode 100644 apps/web/drizzle-do/0000_opposite_mattie_franklin.sql create mode 100644 apps/web/drizzle-do/meta/0000_snapshot.json create mode 100644 apps/web/drizzle-do/meta/_journal.json create mode 100644 apps/web/drizzle-do/migrations.js create mode 100644 apps/web/src/do/hybrid-repo.ts create mode 100644 apps/web/src/do/schema/git.ts diff --git a/.zed/settings.json b/.zed/settings.json index 2951201..16167ff 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -16,6 +16,11 @@ "format_on_save": "on", "languages": { "JavaScript": { + "language_servers": [ + "!eslint", + "!typescript-language-server", + "..." + ], "formatter": { "language_server": { "name": "biome" @@ -27,17 +32,11 @@ } }, "TypeScript": { - "formatter": { - "language_server": { - "name": "biome" - } - }, - "code_actions_on_format": { - "source.fixAll.biome": true, - "source.organizeImports.biome": true - } - }, - "JSX": { + "language_servers": [ + "!eslint", + "!typescript-language-server", + "..." + ], "formatter": { "language_server": { "name": "biome" @@ -49,6 +48,11 @@ } }, "TSX": { + "language_servers": [ + "!eslint", + "!typescript-language-server", + "..." + ], "formatter": { "language_server": { "name": "biome" @@ -61,7 +65,7 @@ } }, "lsp": { - "typescript-language-server": { + "vtsls": { "settings": { "typescript": { "preferences": { @@ -69,6 +73,6 @@ } } } - } + }, } } diff --git a/apps/web/alchemy.run.ts b/apps/web/alchemy.run.ts index 246f059..7f3f088 100644 --- a/apps/web/alchemy.run.ts +++ b/apps/web/alchemy.run.ts @@ -22,6 +22,11 @@ const repoDO = DurableObjectNamespace("repos", { sqlite: true, }); +const hybridRepoDO = DurableObjectNamespace("hybrid-repos", { + className: "HybridRepo", + sqlite: true, +}); + const db = await D1Database("gitflare-db", { name: "gitflare-db", migrationsDir: "./migrations", @@ -43,6 +48,7 @@ function getCurrentUrl() { export const web = await TanStackStart("web", { bindings: { REPO: repoDO, + HYBRID_REPO: hybridRepoDO, DB: db, LOG_LEVEL: isProd ? "warn" : "debug", SITE_URL: getCurrentUrl(), diff --git a/apps/web/drizzle-do.config.ts b/apps/web/drizzle-do.config.ts new file mode 100644 index 0000000..3b88292 --- /dev/null +++ b/apps/web/drizzle-do.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/do/schema/git.ts", + out: "./drizzle-do", + dialect: "sqlite", + driver: "durable-sqlite", +}); diff --git a/apps/web/drizzle-do/0000_opposite_mattie_franklin.sql b/apps/web/drizzle-do/0000_opposite_mattie_franklin.sql new file mode 100644 index 0000000..f5a37aa --- /dev/null +++ b/apps/web/drizzle-do/0000_opposite_mattie_franklin.sql @@ -0,0 +1,26 @@ +CREATE TABLE `git_object_index` ( + `oid` text PRIMARY KEY NOT NULL, + `pack_id` text NOT NULL, + `pack_offset` integer NOT NULL, + `compressed_size` integer NOT NULL, + `type` text NOT NULL, + `is_delta` integer DEFAULT false NOT NULL, + `base_oid` text, + FOREIGN KEY (`pack_id`) REFERENCES `git_packs`(`pack_id`) ON UPDATE no action ON DELETE restrict +); +--> statement-breakpoint +CREATE INDEX `idx_object_pack` ON `git_object_index` (`pack_id`);--> statement-breakpoint +CREATE TABLE `git_packs` ( + `pack_id` text PRIMARY KEY NOT NULL, + `r2_key` text NOT NULL, + `object_count` integer, + `size_bytes` integer, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `git_refs` ( + `name` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `is_symbolic` integer DEFAULT false NOT NULL, + `updated_at` integer NOT NULL +); diff --git a/apps/web/drizzle-do/meta/0000_snapshot.json b/apps/web/drizzle-do/meta/0000_snapshot.json new file mode 100644 index 0000000..9c580f3 --- /dev/null +++ b/apps/web/drizzle-do/meta/0000_snapshot.json @@ -0,0 +1,178 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "508f4f44-ae66-4e0b-bbc2-d272c670ca53", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "git_object_index": { + "name": "git_object_index", + "columns": { + "oid": { + "name": "oid", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pack_offset": { + "name": "pack_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compressed_size": { + "name": "compressed_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_delta": { + "name": "is_delta", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "base_oid": { + "name": "base_oid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_object_pack": { + "name": "idx_object_pack", + "columns": ["pack_id"], + "isUnique": false + } + }, + "foreignKeys": { + "git_object_index_pack_id_git_packs_pack_id_fk": { + "name": "git_object_index_pack_id_git_packs_pack_id_fk", + "tableFrom": "git_object_index", + "tableTo": "git_packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["pack_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "git_packs": { + "name": "git_packs", + "columns": { + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_count": { + "name": "object_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "git_refs": { + "name": "git_refs", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_symbolic": { + "name": "is_symbolic", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/web/drizzle-do/meta/_journal.json b/apps/web/drizzle-do/meta/_journal.json new file mode 100644 index 0000000..115c730 --- /dev/null +++ b/apps/web/drizzle-do/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1769461805341, + "tag": "0000_opposite_mattie_franklin", + "breakpoints": true + } + ] +} diff --git a/apps/web/drizzle-do/migrations.js b/apps/web/drizzle-do/migrations.js new file mode 100644 index 0000000..074105b --- /dev/null +++ b/apps/web/drizzle-do/migrations.js @@ -0,0 +1,9 @@ +import m0000 from "./0000_opposite_mattie_franklin.sql"; +import journal from "./meta/_journal.json"; + +export default { + journal, + migrations: { + m0000, + }, +}; diff --git a/apps/web/package.json b/apps/web/package.json index 11f935e..55455af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "sentry:sourcemaps": "_SENTRY_RELEASE=$(sentry-cli releases propose-version) && sentry-cli releases new $_SENTRY_RELEASE --org=mdhruvil --project=gitvex-workers && sentry-cli sourcemaps upload --org=mdhruvil --project=gitvex-workers --release=$_SENTRY_RELEASE --strip-prefix 'dist/..' dist", "postdeploy:noop": "pnpm sentry:sourcemaps", "db:generate": "drizzle-kit generate", + "db:do:generate": "drizzle-kit generate --config=drizzle-do.config.ts", "db:migrate:local": "wrangler d1 migrations apply DB", "db:migrate:remote": "wrangler d1 migrations apply DB --remote" }, diff --git a/apps/web/src/do/hybrid-repo.ts b/apps/web/src/do/hybrid-repo.ts new file mode 100644 index 0000000..9ea89e9 --- /dev/null +++ b/apps/web/src/do/hybrid-repo.ts @@ -0,0 +1,38 @@ +import { DurableObject, env } from "cloudflare:workers"; +import { + type DrizzleSqliteDODatabase, + drizzle, +} from "drizzle-orm/durable-sqlite"; +import { migrate } from "drizzle-orm/durable-sqlite/migrator"; +// @ts-expect-error - drizzle-orm script +import migrations from "../../drizzle-do/migrations.js"; +import * as gitSchema from "./schema/git"; + +export function getHybridRepoDOStub(fullRepoName: string) { + return (env.HYBRID_REPO as DurableObjectNamespace).getByName( + fullRepoName + ); +} + +export class HybridRepo extends DurableObject { + private readonly db: DrizzleSqliteDODatabase; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + + this.db = drizzle(ctx.storage, { schema: gitSchema }); + + ctx.blockConcurrencyWhile(async () => { + this._migrate(); + }); + } + + async _migrate() { + migrate(this.db, migrations); + } + + /** Health check */ + async ping(): Promise { + return "pong"; + } +} diff --git a/apps/web/src/do/schema/git.ts b/apps/web/src/do/schema/git.ts new file mode 100644 index 0000000..afe7f7f --- /dev/null +++ b/apps/web/src/do/schema/git.ts @@ -0,0 +1,58 @@ +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const GIT_OBJECT_TYPE = { + commit: "commit", + tree: "tree", + blob: "blob", + tag: "tag", +} as const; + +export type GitObjectType = + (typeof GIT_OBJECT_TYPE)[keyof typeof GIT_OBJECT_TYPE]; + +const gitObjectTypes = Object.values(GIT_OBJECT_TYPE) as [ + GitObjectType, + ...GitObjectType[], +]; + +export const gitPacks = sqliteTable("git_packs", { + packId: text("pack_id").primaryKey(), + r2Key: text("r2_key").notNull(), + objectCount: integer("object_count"), + sizeBytes: integer("size_bytes"), + createdAt: integer("created_at", { mode: "timestamp" }) + .$defaultFn(() => new Date()) + .notNull(), +}); + +export const gitObjectIndex = sqliteTable( + "git_object_index", + { + oid: text("oid").primaryKey(), + packId: text("pack_id") + .notNull() + .references(() => gitPacks.packId, { onDelete: "restrict" }), + packOffset: integer("pack_offset").notNull(), + compressedSize: integer("compressed_size").notNull(), + type: text("type", { enum: gitObjectTypes }).notNull(), + isDelta: integer("is_delta", { mode: "boolean" }).default(false).notNull(), + /** For delta objects: OID of the base object */ + baseOid: text("base_oid"), + }, + (table) => [index("idx_object_pack").on(table.packId)] +); + +export const gitRefs = sqliteTable("git_refs", { + /** Full ref name (e.g., "refs/heads/main", "HEAD") */ + name: text("name").primaryKey(), + /** OID for direct refs, or ref path for symbolic refs */ + value: text("value").notNull(), + /** Whether this is a symbolic ref (e.g., HEAD -> refs/heads/main) */ + isSymbolic: integer("is_symbolic", { mode: "boolean" }) + .default(false) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()) + .notNull(), +}); diff --git a/apps/web/src/routes/_layout/dashboard.tsx b/apps/web/src/routes/_layout/dashboard.tsx index 1c9d606..2dafa87 100644 --- a/apps/web/src/routes/_layout/dashboard.tsx +++ b/apps/web/src/routes/_layout/dashboard.tsx @@ -1,7 +1,18 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { createFileRoute, redirect } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; import { getSessionOptions } from "@/api/session"; import { NotFoundComponent } from "@/components/404-components"; +import { Button } from "@/components/ui/button"; +import { getHybridRepoDOStub } from "@/do/hybrid-repo"; + +const createHybridRepo = createServerFn({ method: "POST" }).handler( + async () => { + const stub = getHybridRepoDOStub("hybrid/repo"); + const pong = await stub.ping(); + return pong; + } +); export const Route = createFileRoute("/_layout/dashboard")({ component: RouteComponent, @@ -23,6 +34,9 @@ function RouteComponent() { return (
Welcome, {username}
+
); } diff --git a/apps/web/src/server.ts b/apps/web/src/server.ts index 14d4c54..9e03ed0 100644 --- a/apps/web/src/server.ts +++ b/apps/web/src/server.ts @@ -25,4 +25,5 @@ export default Sentry.withSentry( ); // biome-ignore lint/performance/noBarrelFile: +export { HybridRepo } from "./do/hybrid-repo"; export { Repo } from "./do/repo"; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index de26f74..09c96b1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,11 +2,32 @@ import tailwindcss from "@tailwindcss/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; import alchemy from "alchemy/cloudflare/tanstack-start"; -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; +/** + * Custom Vite plugin to handle SQL file imports as text modules. + * Required for drizzle-orm migrations in Cloudflare Durable Objects. + * @see https://github.com/cloudflare/workers-sdk/issues/9011 + */ +function sqlLoader(): Plugin { + return { + name: "sql-loader", + transform(code, id) { + if (id.endsWith(".sql")) { + const escaped = code + .replace(/\\/g, "\\\\") + .replace(/`/g, "\\`") + .replace(/\$/g, "\\$"); + return `export default \`${escaped}\`;`; + } + }, + }; +} + export default defineConfig({ plugins: [ + sqlLoader(), tsconfigPaths(), tailwindcss(), alchemy(), From 517a582bddce1898eb42fd7fcfb5d845acae3cbc Mon Sep 17 00:00:00 2001 From: mdhruvil Date: Wed, 28 Jan 2026 19:35:38 +0530 Subject: [PATCH 09/12] refactor(errors): replace custom TaggedError with better-result pkg Removes ~150 lines of custom error class implementation by adopting the better-result package, reducing maintenance burden. Also adds node_modules exclusion to tsconfigs to fix IDE performance. --- apps/web/src/lib/errors.ts | 14 ++- apps/web/src/lib/tagged-error.ts | 143 ------------------------------- apps/web/tsconfig.json | 1 + tsconfig.base.json | 1 + 4 files changed, 8 insertions(+), 151 deletions(-) delete mode 100644 apps/web/src/lib/tagged-error.ts diff --git a/apps/web/src/lib/errors.ts b/apps/web/src/lib/errors.ts index 7e9d118..ba86f3d 100644 --- a/apps/web/src/lib/errors.ts +++ b/apps/web/src/lib/errors.ts @@ -1,5 +1,5 @@ +import { TaggedError } from "better-result"; import type * as z from "zod"; -import { TaggedError } from "./tagged-error"; /** * Validation error containing Zod issues. @@ -8,21 +8,19 @@ import { TaggedError } from "./tagged-error"; export class ValidationError extends TaggedError("ValidationError")<{ message?: string; issues: z.core.$ZodIssue[]; -}> {} +}>() {} /** * Database error wrapping underlying database exceptions. * Returned when a database operation fails. */ export class DatabaseError extends TaggedError("DatabaseError")<{ - cause?: unknown; -}> {} + cause: unknown; +}>() {} -export class UnauthorizedError extends TaggedError("UnauthorizedError")<{ - cause?: unknown; -}> {} +export class UnauthorizedError extends TaggedError("UnauthorizedError")() {} export class NotFoundError extends TaggedError("NotFoundError")<{ cause?: unknown; what?: string; -}> {} +}>() {} diff --git a/apps/web/src/lib/tagged-error.ts b/apps/web/src/lib/tagged-error.ts deleted file mode 100644 index 8629b8e..0000000 --- a/apps/web/src/lib/tagged-error.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Mixin factory for discriminated error classes with typed props. - * - * Props become readonly fields; `message`/`cause` auto-wire to Error; `cause.stack` chains. - * - * @see [TypeScript Mixins](https://www.typescriptlang.org/docs/handbook/mixins.html) - * @typeParam Tag - Literal string for `_tag` discriminant - * @returns Base class to extend with optional generic props - * - * @example - * ```typescript - * class CreateGitRepoError extends TaggedError("CreateGitRepoError")<{ - * repoName: string; - * org: string; - * cause?: Error; - * }> {} - * - * const err = new CreateGitRepoError({ repoName: "api", org: "acme", cause: httpErr }); - * err._tag; // "CreateGitRepoError" (literal) - * err.repoName; // string (typed) - * err.toJSON(); // serializable - * ``` - */ -/** biome-ignore-all lint/complexity/noBannedTypes: */ -export function TaggedError( - tag: Tag -): TaggedErrorClass { - const instances = new WeakMap>(); - - class TaggedErrorImpl extends Error { - static readonly _tag: Tag = tag; - readonly _tag: Tag = tag; - override readonly name = tag; - - constructor(props?: Record) { - const message = getMessage(tag, props); - const cause = getCause(props); - super(message, { cause }); - - if (props) { - Object.assign(this, props); - instances.set(this, props); - } - - if (Error.captureStackTrace) { - Error.captureStackTrace(this, new.target); - } - - if (cause) { - this.stack = `${this.stack}\nCaused by: ${cause.stack}`; - } - } - - toJSON(): Record { - return { - _tag: this._tag, - message: this.message, - stack: this.stack, - ...instances.get(this), - }; - } - - [Symbol.for("nodejs.util.inspect.custom")](): string { - return this.prettyPrint(); - } - - /** Formats error with tag, message, properties, and stack trace */ - prettyPrint(): string { - const props = instances.get(this); - const propsStr = props - ? Object.entries(props) - .filter(([k]) => k !== "message" && k !== "cause") - .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`) - .join("\n") - : ""; - - let result = `${tag}: ${this.message}`; - if (propsStr) result += `\n${propsStr}`; - if (this.stack) { - const stackLines = this.stack.split("\n").slice(1).join("\n"); - if (stackLines) result += `\n${stackLines}`; - } - return result; - } - } - - // Cast required: internal class can't express the generic `Readonly` constraint - // that gets applied per-instantiation. `never` is assignable to any type. - return TaggedErrorImpl as never; -} - -/** - * The class type returned by TaggedError factory. - * Includes static _tag for runtime tag access without instantiation. - */ -export type TaggedErrorClass = { - readonly _tag: Tag; - new = {}>( - ...args: {} extends TaggedErrorProps - ? [props?: TaggedErrorProps] - : [props: TaggedErrorProps] - ): TaggedErrorInstance; -}; - -/** - * Instance type of a TaggedError class. - */ -export type TaggedErrorInstance< - Tag extends string, - A extends Record = {}, -> = Error & { - readonly _tag: Tag; - toJSON(): Record; - prettyPrint(): string; -} & Readonly; - -/** - * Extract the _tag literal type from a TaggedError class or instance. - */ -export type InferTag = T extends { readonly _tag: infer Tag } ? Tag : never; - -/** - * Extract props type from a TaggedError instance (excluding _tag). - */ -export type InferProps = T extends TaggedErrorInstance - ? A - : never; - -type TaggedErrorProps> = { - readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; -}; - -function getMessage(tag: string, props?: Record): string { - return props && "message" in props && typeof props.message === "string" - ? props.message - : tag; -} - -function getCause(props?: Record): Error | undefined { - return props && "cause" in props && props.cause instanceof Error - ? props.cause - : undefined; -} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index d901b44..f4d7839 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,5 +1,6 @@ { "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"], "compilerOptions": { "target": "ES2022", "jsx": "react-jsx", diff --git a/tsconfig.base.json b/tsconfig.base.json index e375023..2470a64 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,5 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", + "exclude": ["node_modules"], "compilerOptions": { "target": "ESNext", "module": "ESNext", From a87beb2cb2f025ac3f47460e219576c5a5e13e60 Mon Sep 17 00:00:00 2001 From: mdhruvil Date: Wed, 28 Jan 2026 19:37:44 +0530 Subject: [PATCH 10/12] feat(storage): add R2 bucket for git pack file storage enables offloading large pack files to R2 object storage, allowing repos to exceed Durable Object size limits --- apps/web/alchemy.run.ts | 6 ++ apps/web/src/do/keys.ts | 7 +++ apps/web/src/do/r2.ts | 131 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 apps/web/src/do/keys.ts create mode 100644 apps/web/src/do/r2.ts diff --git a/apps/web/alchemy.run.ts b/apps/web/alchemy.run.ts index 7f3f088..acd5b25 100644 --- a/apps/web/alchemy.run.ts +++ b/apps/web/alchemy.run.ts @@ -2,6 +2,7 @@ import alchemy from "alchemy"; import { D1Database, DurableObjectNamespace, + R2Bucket, TanStackStart, Worker, } from "alchemy/cloudflare"; @@ -27,6 +28,10 @@ const hybridRepoDO = DurableObjectNamespace("hybrid-repos", { sqlite: true, }); +const bucket = await R2Bucket("gitflare-bucket", { + name: "gitflare-bucket", +}); + const db = await D1Database("gitflare-db", { name: "gitflare-db", migrationsDir: "./migrations", @@ -49,6 +54,7 @@ export const web = await TanStackStart("web", { bindings: { REPO: repoDO, HYBRID_REPO: hybridRepoDO, + BUCKET: bucket, DB: db, LOG_LEVEL: isProd ? "warn" : "debug", SITE_URL: getCurrentUrl(), diff --git a/apps/web/src/do/keys.ts b/apps/web/src/do/keys.ts new file mode 100644 index 0000000..85fcb58 --- /dev/null +++ b/apps/web/src/do/keys.ts @@ -0,0 +1,7 @@ +function pack(repoId: string, packName: string): string { + return `repos/${repoId}/objects/packs/${packName}`; +} + +export const Keys = { + pack, +}; diff --git a/apps/web/src/do/r2.ts b/apps/web/src/do/r2.ts new file mode 100644 index 0000000..316bc39 --- /dev/null +++ b/apps/web/src/do/r2.ts @@ -0,0 +1,131 @@ +import { Result, TaggedError } from "better-result"; +import { NotFoundError } from "../lib/errors"; + +type R2Reason = + | { op: "head"; key: string } + | { op: "get"; key: string } + | { op: "put"; key: string } + | { op: "delete"; keys: string | string[] } + | { op: "list" }; + +export class R2Error extends TaggedError("R2Error")<{ + reason: R2Reason; + cause: unknown; + message: string; +}>() { + constructor(args: { reason: R2Reason; cause: unknown }) { + const message = R2Error.#formatMessage(args.reason); + super({ ...args, message }); + } + + static #formatMessage(reason: R2Reason): string { + switch (reason.op) { + case "head": + return `Failed to get metadata for "${reason.key}"`; + case "get": + return `Failed to get "${reason.key}"`; + case "put": + return `Failed to put "${reason.key}"`; + case "delete": { + const keyStr = Array.isArray(reason.keys) + ? reason.keys.join(", ") + : reason.keys; + return `Failed to delete "${keyStr}"`; + } + case "list": + return "Failed to list objects"; + default: { + const _exhaustive: never = reason; + return _exhaustive; + } + } + } +} + +type R2PutValue = + | ReadableStream + | ArrayBuffer + | ArrayBufferView + | string + | null + | Blob; + +export class R2 { + readonly #bucket: R2Bucket; + + constructor(bucket: R2Bucket) { + this.#bucket = bucket; + } + + async head(key: string) { + return ( + await Result.tryPromise({ + try: () => this.#bucket.head(key), + catch: (cause) => new R2Error({ reason: { op: "head", key }, cause }), + }) + ).andThen((value) => + value === null + ? Result.err(new NotFoundError({ what: `R2 object "${key}"` })) + : Result.ok(value) + ); + } + + get( + key: string, + options: R2GetOptions & { onlyIf: R2Conditional | Headers } + ): Promise>; + + get( + key: string, + options?: R2GetOptions + ): Promise>; + + async get(key: string, options?: R2GetOptions) { + return ( + await Result.tryPromise({ + try: () => + this.#bucket.get(key, options as R2GetOptions) as Promise< + R2ObjectBody | R2Object | null + >, + catch: (cause) => new R2Error({ reason: { op: "get", key }, cause }), + }) + ).andThen((value) => + value === null + ? Result.err(new NotFoundError({ what: `R2 object "${key}"` })) + : Result.ok(value) + ); + } + + put( + key: string, + value: R2PutValue, + options: R2PutOptions & { onlyIf: R2Conditional | Headers } + ): Promise>; + + put( + key: string, + value: R2PutValue, + options?: R2PutOptions + ): Promise>; + + put(key: string, value: R2PutValue, options?: R2PutOptions) { + return Result.tryPromise({ + try: () => this.#bucket.put(key, value, options), + catch: (cause) => new R2Error({ reason: { op: "put", key }, cause }), + }); + } + + delete(keys: string | string[]) { + return Result.tryPromise({ + try: () => this.#bucket.delete(keys), + catch: (cause) => new R2Error({ reason: { op: "delete", keys }, cause }), + }); + } + + list(options?: R2ListOptions) { + return Result.tryPromise({ + try: () => this.#bucket.list(options), + catch: (cause) => new R2Error({ reason: { op: "list" }, cause }), + }); + } +} From 07f9412dbfc2cff6ef2a833e84e4e915ab76ecc6 Mon Sep 17 00:00:00 2001 From: mdhruvil Date: Fri, 30 Jan 2026 18:11:05 +0530 Subject: [PATCH 11/12] refactor!: stream push progress via custom packfile parser Users now see real-time progress during git push instead of silent waiting. Packfiles stored in R2 enabling larger repo support beyond DO SQLite limits. Temporarily disabled while migrating: - git clone/fetch (upload-pack returns 501) - repo browsing UI (shows placeholder data) BREAKING CHANGE: git clone/fetch unavailable until upload-pack implemented --- apps/web/src/api/branches.ts | 10 +- apps/web/src/api/commits.ts | 87 ++- apps/web/src/api/repos.ts | 6 +- apps/web/src/api/tree.ts | 101 +-- apps/web/src/do/cache.ts | 131 ---- apps/web/src/do/fs.ts | 361 --------- apps/web/src/do/hybrid-repo.ts | 174 ++++- apps/web/src/do/logger.ts | 53 -- apps/web/src/do/object-store.ts | 427 ++++++++++ apps/web/src/do/pack-indexer.ts | 318 ++++++++ apps/web/src/do/r2.ts | 22 +- apps/web/src/do/ref-store.ts | 304 +++++++ apps/web/src/do/repo.ts | 323 -------- apps/web/src/git/delta.ts | 207 +++++ apps/web/src/git/errors.ts | 15 + apps/web/src/git/handlers/receive-pack.ts | 174 +++++ apps/web/src/git/object.ts | 120 +++ apps/web/src/git/protocol.ts | 538 ------------- apps/web/src/git/protocol/capabilities.ts | 57 ++ apps/web/src/git/protocol/receive-pack.ts | 247 ++++++ apps/web/src/git/response.ts | 50 ++ apps/web/src/git/service.ts | 739 ------------------ apps/web/src/git/sideband-writer.ts | 97 +++ apps/web/src/lib/bytes.ts | 12 + apps/web/src/lib/stream-buffer.ts | 7 +- .../$owner/$repo/_layout/_viewer/blob.tsx | 1 + .../routes/$owner/$repo/git-receive-pack.ts | 35 +- .../routes/$owner/$repo/git-upload-pack.ts | 39 +- apps/web/src/routes/$owner/$repo/info/refs.ts | 39 +- apps/web/src/server.ts | 1 - 30 files changed, 2384 insertions(+), 2311 deletions(-) delete mode 100644 apps/web/src/do/cache.ts delete mode 100644 apps/web/src/do/fs.ts delete mode 100644 apps/web/src/do/logger.ts create mode 100644 apps/web/src/do/object-store.ts create mode 100644 apps/web/src/do/pack-indexer.ts create mode 100644 apps/web/src/do/ref-store.ts delete mode 100644 apps/web/src/do/repo.ts create mode 100644 apps/web/src/git/delta.ts create mode 100644 apps/web/src/git/errors.ts create mode 100644 apps/web/src/git/handlers/receive-pack.ts create mode 100644 apps/web/src/git/object.ts delete mode 100644 apps/web/src/git/protocol.ts create mode 100644 apps/web/src/git/protocol/capabilities.ts create mode 100644 apps/web/src/git/protocol/receive-pack.ts create mode 100644 apps/web/src/git/response.ts delete mode 100644 apps/web/src/git/service.ts create mode 100644 apps/web/src/git/sideband-writer.ts create mode 100644 apps/web/src/lib/bytes.ts diff --git a/apps/web/src/api/branches.ts b/apps/web/src/api/branches.ts index ecd28bd..f469de5 100644 --- a/apps/web/src/api/branches.ts +++ b/apps/web/src/api/branches.ts @@ -1,7 +1,6 @@ import { queryOptions } from "@tanstack/react-query"; import { createServerFn } from "@tanstack/react-start"; import * as z from "zod"; -import { getRepoDOStub } from "@/do/repo"; export const getBrancesFnSchema = z.object({ owner: z.string(), @@ -11,12 +10,11 @@ export const getBrancesFnSchema = z.object({ export const getBranchesFn = createServerFn({ method: "GET" }) .inputValidator(getBrancesFnSchema) .handler(async ({ data }) => { - const fullName = `${data.owner}/${data.repo}`; - const stub = getRepoDOStub(fullName); - const result = await stub.getBranches(); + // TODO: Implement actual branch fetching from HybridRepo DO + console.log("getBranches called for:", data.owner, data.repo); return { - branches: result.branches, - currentBranch: result.currentBranch, + branches: ["main"], + currentBranch: "main", }; }); diff --git a/apps/web/src/api/commits.ts b/apps/web/src/api/commits.ts index 1e3d5bf..aaf9121 100644 --- a/apps/web/src/api/commits.ts +++ b/apps/web/src/api/commits.ts @@ -1,7 +1,26 @@ import { queryOptions } from "@tanstack/react-query"; import { createServerFn } from "@tanstack/react-start"; import * as z from "zod"; -import { getRepoDOStub } from "@/do/repo"; + +type Commit = { + oid: string; + commit: { + message: string; + author: { + name: string; + email: string; + timestamp: number; + }; + parent: string[]; + }; +}; + +type FileChange = { + path: string; + type: "add" | "remove" | "modify"; + old?: { content: string; isBinary: boolean }; + new?: { content: string; isBinary: boolean }; +}; export const getCommitFnSchema = z.object({ owner: z.string(), @@ -12,14 +31,23 @@ export const getCommitFnSchema = z.object({ export const getCommitsFn = createServerFn({ method: "GET" }) .inputValidator(getCommitFnSchema) - .handler(async ({ data }) => { - const fullName = `${data.owner}/${data.repo}`; - const stub = getRepoDOStub(fullName); - const commits = await stub.getCommits({ - depth: data.limit, - ref: data.ref, - }); - return commits; + .handler(async ({ data }): Promise => { + // TODO: Implement actual commit fetching from HybridRepo DO + console.log("getCommits called for:", data.owner, data.repo); + return [ + { + oid: "abc123def456789", + commit: { + message: "Initial commit", + author: { + name: "Demo User", + email: "demo@example.com", + timestamp: Date.now() / 1000, + }, + parent: [], + }, + }, + ]; }); export const getCommitsQueryOptions = ( @@ -40,15 +68,38 @@ export const getCommitSchema = z.object({ export const getCommitFn = createServerFn({ method: "GET" }) .inputValidator(getCommitSchema) - .handler(async ({ data }) => { - const fullName = `${data.owner}/${data.repo}`; - const stub = getRepoDOStub(fullName); - const { commit, changes } = await stub.getCommit(data.commitOid); - return { - commit, - changes, - }; - }); + .handler( + async ({ data }): Promise<{ commit: Commit; changes: FileChange[] }> => { + // TODO: Implement actual commit fetching from HybridRepo DO + console.log( + "getCommit called for:", + data.owner, + data.repo, + data.commitOid + ); + return { + commit: { + oid: data.commitOid, + commit: { + message: "Initial commit", + author: { + name: "Demo User", + email: "demo@example.com", + timestamp: Date.now() / 1000, + }, + parent: [], + }, + }, + changes: [ + { + path: "README.md", + type: "add", + new: { content: "# Hello World", isBinary: false }, + }, + ], + }; + } + ); export const getCommitQueryOptions = (data: z.infer) => queryOptions({ diff --git a/apps/web/src/api/repos.ts b/apps/web/src/api/repos.ts index 0978391..b92845e 100644 --- a/apps/web/src/api/repos.ts +++ b/apps/web/src/api/repos.ts @@ -4,7 +4,7 @@ import { createServerFn } from "@tanstack/react-start"; import { getRequestHeaders } from "@tanstack/react-start/server"; import * as z from "zod"; import { Repo } from "@/db/repo"; -import { getRepoDOStub } from "@/do/repo"; +import { getHybridRepoDOStub } from "@/do/hybrid-repo"; import { auth } from "@/lib/auth"; import { NotFoundError, UnauthorizedError } from "@/lib/errors"; import { Result } from "@/lib/result"; @@ -114,8 +114,8 @@ export const createRepoFn = createServerFn({ const fullName = `${repo.owner}/${repo.name}`; - const stub = getRepoDOStub(fullName); - await stub.ensureRepoInitialized(); + const stub = getHybridRepoDOStub(fullName); + await stub.initRepo(); return repo; }); diff --git a/apps/web/src/api/tree.ts b/apps/web/src/api/tree.ts index 5a45690..5c712be 100644 --- a/apps/web/src/api/tree.ts +++ b/apps/web/src/api/tree.ts @@ -1,10 +1,8 @@ import { queryOptions } from "@tanstack/react-query"; -import { notFound } from "@tanstack/react-router"; import { createServerFn } from "@tanstack/react-start"; import { createHighlighter } from "shiki"; import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; import * as z from "zod"; -import { getRepoDOStub } from "@/do/repo"; import { getLanguageFromFilename } from "@/lib/utils"; // Create highlighter instance with JavaScript regex engine @@ -16,6 +14,21 @@ const highlighterPromise = createHighlighter({ }), }); +type TreeEntry = { + oid: string; + path: string; + type: "tree" | "blob"; + lastCommit?: { + oid: string; + commit: { + message: string; + committer: { + timestamp: number; + }; + }; + }; +}; + export const getTreeFnSchema = z.object({ owner: z.string(), repo: z.string(), @@ -25,14 +38,35 @@ export const getTreeFnSchema = z.object({ export const getTreeFn = createServerFn({ method: "GET" }) .inputValidator(getTreeFnSchema) - .handler(async ({ data }) => { - const fullName = `${data.owner}/${data.repo}`; - const stub = getRepoDOStub(fullName); - const tree = await stub.getTree({ - ref: data.ref, - path: data.path, - }); - return tree; + .handler(async ({ data }): Promise => { + // TODO: Implement actual tree fetching from HybridRepo DO + console.log("getTree called for:", data.owner, data.repo); + return [ + { + oid: "abc123def456", + path: "src", + type: "tree", + lastCommit: { + oid: "commit123", + commit: { + message: "Initial commit", + committer: { timestamp: Date.now() / 1000 }, + }, + }, + }, + { + oid: "def456abc789", + path: "README.md", + type: "blob", + lastCommit: { + oid: "commit123", + commit: { + message: "Initial commit", + committer: { timestamp: Date.now() / 1000 }, + }, + }, + }, + ]; }); export const getTreeQueryOptions = (data: z.infer) => @@ -53,53 +87,26 @@ export const getBlobFnSchema = z.object({ export const getBlobFn = createServerFn({ method: "GET" }) .inputValidator(getBlobFnSchema) .handler(async ({ data }) => { - const fullName = `${data.owner}/${data.repo}`; - const stub = getRepoDOStub(fullName); - const blob = await stub.getBlob({ - ref: data.ref, - filepath: data.filepath, - }); - - if (!blob) { - throw notFound(); - } - - // WE HAVE TO DO THIS BECAUSE OF CACHE SERIALIZATION ISSUES (TODO: REFACTOR) - // Handle blob.content which might be a Uint8Array or a plain object from JSON serialization - let contentBuffer: Buffer; - if (blob.content instanceof Uint8Array) { - contentBuffer = Buffer.from(blob.content); - } else if (typeof blob.content === "object" && blob.content !== null) { - // Handle JSON-serialized Uint8Array (object with numeric keys) - contentBuffer = Buffer.from(Object.values(blob.content) as number[]); - } else { - contentBuffer = Buffer.from(blob.content as string); - } - - const contentBase64 = contentBuffer.toString("base64"); - - // Check if it's markdown + // TODO: Implement actual blob fetching from HybridRepo DO const filename = data.filepath.split("/").pop() || data.filepath; const isMarkdown = filename.toLowerCase().match(/\.(md|mdx|markdown)$/); - // Generate syntax-highlighted HTML for non-binary, non-markdown files + const dummyContent = `# ${data.repo}\n\nThis is a placeholder README for ${data.owner}/${data.repo}.`; + const contentBuffer = Buffer.from(dummyContent); + const contentBase64 = contentBuffer.toString("base64"); + let highlightedHtml: string | null = null; - if (!blob.isBinary && !isMarkdown) { + if (!isMarkdown) { const language = getLanguageFromFilename(filename); const highlighter = await highlighterPromise; - // Decode content for highlighting - const decoder = new TextDecoder("utf-8"); - const content = decoder.decode(contentBuffer); - try { await highlighter.loadLanguage(language); } catch { - // Language not found, will use no highlighting console.warn(`Language "${language}" not found for file "${filename}"`); } - highlightedHtml = highlighter.codeToHtml(content, { + highlightedHtml = highlighter.codeToHtml(dummyContent, { lang: language, theme: "github-dark-default", transformers: [ @@ -113,10 +120,10 @@ export const getBlobFn = createServerFn({ method: "GET" }) } return { - oid: blob.oid, + oid: "dummy-blob-oid-123", content: contentBase64, - size: blob.size, - isBinary: blob.isBinary, + size: contentBuffer.length, + isBinary: false, highlightedHtml, }; }); diff --git a/apps/web/src/do/cache.ts b/apps/web/src/do/cache.ts deleted file mode 100644 index c736537..0000000 --- a/apps/web/src/do/cache.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { env } from "cloudflare:workers"; -import { createLogger } from "./logger"; - -const logger = createLogger("Cache"); - -async function getJsonCache(): Promise { - const cache = await caches.open("gitflare:json"); - return cache; -} - -type Params = Record; - -type BuildCacheKeyArgs = { - key: string; - params: Params; -}; - -function buildCacheKey({ key, params }: BuildCacheKeyArgs) { - const path = key.startsWith("/") ? key : `/${key}`; - const url = new URL(`/__cache${path}`, env.SITE_URL); - // biome-ignore lint/suspicious/useGuardForIn: - for (const param in params) { - const value = params[param]; - if (value) url.searchParams.set(param, value); - } - return url; -} - -type PutJsonArgs = { - /** - * The base key for the cached item. - * If the key does not start with `/`, it will also be added automatically. - */ - key: string; - /** - * The data to be cached. - */ - data: T; - /** - * Optional parameters to include in the cache key. - */ - params?: Params; - options?: { - /** - * Time to live in seconds for the cached item. - * @default 1 year - */ - ttlSeconds?: number; - }; -}; - -async function putJson({ key, data, params, options }: PutJsonArgs) { - const ttl = options?.ttlSeconds ?? 60 * 60 * 24 * 365; // 1 year; - const cache = await getJsonCache(); - const headers = new Headers({ - "Content-Type": "application/json", - "Cache-Control": `public, max-age=${Math.floor(ttl)}`, - }); - const response = new Response(JSON.stringify(data), { headers }); - const finalKey = buildCacheKey({ - key, - params: params ?? {}, - }); - await cache.put(finalKey, response); - logger.debug(`Cached data for key: ${finalKey.toString()}`); -} - -type GetJsonArgs = { - key: string; - params?: Params; -}; - -async function getJson({ key, params }: GetJsonArgs): Promise { - const cache = await getJsonCache(); - const finalKey = buildCacheKey({ - key, - params: params ?? {}, - }); - const response = await cache.match(finalKey); - if (!response || !response.ok) return null; - const data = (await response.json()) as T; - logger.debug("Cache hit for key: ", finalKey.toString()); - return data; -} - -type GetOrSetJsonArgs = { - /** - * The base key for the cached item. - * If the key does not start with `/`, it will also be added automatically. - */ - key: string; - /** - * Function to fetch the data if it's not in the cache. - * Must resolve to the data to be cached. - */ - fetcher: () => Promise | T; - /** - * Optional parameters to include in the cache key. - */ - params?: Params; - options?: { - /** - * Time to live in seconds for the cached item. - * @default 1 year - */ - ttlSeconds?: number; - }; -}; - -async function getOrSetJson({ - key, - fetcher, - options, - params, -}: GetOrSetJsonArgs): Promise { - const cached = await getJson({ key, params }); - if (cached) return cached; - - const fresh = await fetcher(); - if (fresh === null || fresh === undefined) { - return fresh; - } - await putJson({ key, data: fresh, params, options }); - return fresh; -} - -export const cache = { - putJson, - getJson, - getOrSetJson, -}; diff --git a/apps/web/src/do/fs.ts b/apps/web/src/do/fs.ts deleted file mode 100644 index 082c79a..0000000 --- a/apps/web/src/do/fs.ts +++ /dev/null @@ -1,361 +0,0 @@ -/** - * Contains a custom FS adapter for isomorphic-git using dofs (Durable Object File System). - * @see https://isomorphic-git.org/docs/en/fs - * @see https://github.com/benallfree/dofs/ - */ - -import type { Stats as NodeStats } from "node:fs"; -import type { Fs } from "dofs"; - -/** - * Normalizes a given path, resolving '..' and '.' segments and ensuring a leading slash. - * @param p - The path to normalize. - * @returns The normalized path. - */ -function normalizePath(p: string): string { - let path = p; - if (!path) return "/"; - // Strip query/hash (shouldn't appear, but be safe) - path = path.split("?")[0].split("#")[0]; - // Replace backslashes and ensure leading slash so we treat all as absolute inside DO - path = path.replace(/\\/g, "/"); - if (!path.startsWith("/")) path = `/${path}`; - // Split and resolve '.' and '..' - const out: string[] = []; - for (const rawSeg of path.split("/")) { - const seg = rawSeg.trim(); - if (!seg || seg === ".") continue; // skip empty & current dir - if (seg === "..") { - if (out.length) out.pop(); - continue; - } - out.push(seg); - } - return `/${out.join("/")}`; -} - -/** - * Custom Error class that includes a `code` property, similar to Node.js system errors. - */ -class ErrorWithCode extends Error { - code?: string; - path?: string; - syscall?: string; - constructor(message: string, code?: string, path?: string, syscall?: string) { - super(message); - this.code = code; - this.path = path; - this.syscall = syscall; - } -} - -/** - * A file system abstraction layer that wraps a dofs (Durable Object File System) instance - * to provide a Node.js-like `fs.promises` API, suitable for use with isomorphic-git. - */ -export class IsoGitFs { - private readonly dofs: Fs; - private readonly KNOWN_CODES = new Set([ - "ENOENT", - "ENOTDIR", - "EISDIR", - "EEXIST", - "EPERM", - "EACCES", - "EINVAL", - "EBUSY", - "ENOSPC", - "ENOTEMPTY", - ]); - - /** - * Creates an instance of IsoGitFs. - * @param dofs - The dofs Fs instance to wrap. - */ - constructor(dofs: Fs) { - this.dofs = dofs; - } - - /** - * Returns an object that mimics the Node.js `fs.promises` API. - * @returns An object with promisified file system methods. - */ - getPromiseFsClient() { - const fs = { - promises: { - readFile: this.readFile.bind(this), - writeFile: this.writeFile.bind(this), - unlink: this.unlink.bind(this), - readdir: this.readdir.bind(this), - mkdir: this.mkdir.bind(this), - rmdir: this.rmdir.bind(this), - stat: this.stat.bind(this), - lstat: this.lstat.bind(this), - readlink: this.readlink.bind(this), - symlink: this.symlink.bind(this), - }, - }; - return fs; - } - - /** - * Ensures that an error object has a `code` property, attempting to infer it from the message if missing. - * @param error - The error object to process. - * @returns The error object with a `code` property, or undefined if no code could be determined. - */ - private ensureErrCode(error: Error) { - const e = new ErrorWithCode(error.message); - if (e.code) return e; - const msg = e.message.trim(); - if (this.KNOWN_CODES.has(msg)) { - e.code = msg; - return e; - } - const parts = msg.split(":").join(" ").split(" "); - for (const part of parts) { - if (this.KNOWN_CODES.has(part)) { - e.code = part; - return e; - } - } - } - - /** - * Annotates an error with syscall and path information and then throws it. - * @param error - The original error. - * @param syscall - The name of the system call that failed. - * @param path - The path involved in the system call. - * @throws The annotated error. - */ - private annotateAndThrow(error: unknown, syscall: string, path: string) { - if (error instanceof Error) { - const e = this.ensureErrCode(error); - if (!e) return; - e.path = path; - e.syscall = syscall; - throw e; - } - throw error; - } - - /** - * Reads the content of a file. - * @param path - The path to the file. - * @param options - Encoding options. - * @returns The content of the file as a Buffer or string. - */ - async readFile( - path: string, - options: { encoding?: BufferEncoding } | BufferEncoding - ) { - const encoding = typeof options === "string" ? options : options?.encoding; - const normalizedPath = normalizePath(path); - try { - const data = this.dofs.read(normalizedPath, { encoding }); - const dataBuff = Buffer.from(data); - if (!encoding || (encoding as string) === "buffer") return dataBuff; - return dataBuff.toString(encoding); - } catch (error) { - this.annotateAndThrow(error, "readFile", normalizedPath); - } - } - - /** - * Writes data to a file. - * @param filepath - The path to the file. - * @param data - The data to write. - * @param options - Encoding options. - */ - async writeFile( - filepath: string, - data: string | ArrayBufferView | ArrayBuffer, - options?: { encoding?: BufferEncoding } | BufferEncoding - ) { - const encoding = typeof options === "string" ? options : options?.encoding; - let arrayLike: ArrayBuffer | string; - if (typeof data === "string") { - arrayLike = data; - } else if (data instanceof ArrayBuffer) { - arrayLike = data; - } else { - const view = data as ArrayBufferView; - const copy = new Uint8Array(view.byteLength); - copy.set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); - arrayLike = copy.buffer; - } - const normalizedPath = normalizePath(filepath); - try { - await this.dofs.writeFile(normalizedPath, arrayLike, { encoding }); - } catch (error) { - this.annotateAndThrow(error, "writeFile", normalizedPath); - } - } - - /** - * Deletes a file. - * @param path - The path to the file to delete. - */ - async unlink(path: string) { - const normalizedPath = normalizePath(path); - try { - this.dofs.unlink(normalizedPath); - } catch (error) { - this.annotateAndThrow(error, "unlink", normalizedPath); - } - } - - /** - * Reads the contents of a directory. - * @param path - The path to the directory. - * @returns An array of the names of the files in the directory (excluding '.' and '..'). - */ - async readdir(path: string) { - const normalizedPath = normalizePath(path); - try { - const names = this.dofs.listDir(normalizedPath, {}); - return names.filter((n) => n !== "." && n !== ".."); - } catch (error) { - this.annotateAndThrow(error, "readdir", normalizedPath); - return []; // to satisfy TS - } - } - - /** - * Creates a directory. - * @param path - The path to the directory to create. - * @param options - Options for creating the directory, including `recursive` and `mode`. - */ - async mkdir(path: string, options?: { recursive?: boolean; mode?: number }) { - const normalizedPath = normalizePath(path); - try { - this.dofs.mkdir(normalizedPath, { recursive: true, ...options }); - } catch (error) { - this.annotateAndThrow(error, "mkdir", normalizedPath); - } - } - - /** - * Removes a directory. - * @param path - The path to the directory to remove. - * @param options - Options for removing the directory, including `recursive`. - */ - async rmdir(path: string, options?: { recursive?: boolean }) { - const normalizedPath = normalizePath(path); - try { - this.dofs.rmdir(normalizedPath, { recursive: true, ...options }); - } catch (error) { - this.annotateAndThrow(error, "rmdir", normalizedPath); - } - } - - /** - * Core stat function used by both `stat` and `lstat`. - * @param path - The path to the file or directory. - * @param detectSymlink - Whether to detect if the path is a symlink. - * @returns A Node.js-like `Stats` object. - */ - statCore(path: string, detectSymlink: boolean) { - let isSymlink = false; - const normalizedPath = normalizePath(path); - if (detectSymlink) { - try { - this.dofs.readlink(normalizedPath); - isSymlink = true; - } catch (error) { - if (error instanceof Error && error.message === "ENOENT") { - isSymlink = false; - } else { - this.annotateAndThrow(error, "lstat", normalizedPath); - } - } - } - - try { - const stat = this.dofs.stat(normalizedPath); - const atime = stat.atime ? new Date(stat.atime) : new Date(0); - const mtime = stat.mtime ? new Date(stat.mtime) : new Date(0); - const ctime = stat.ctime ? new Date(stat.ctime) : new Date(0); - const birthtime = stat.crtime ? new Date(stat.crtime) : new Date(0); - - const nodeStat: NodeStats = { - isFile: () => stat.isFile, - isDirectory: () => stat.isDirectory, - isBlockDevice: () => false, - isCharacterDevice: () => false, - isSymbolicLink: () => isSymlink, - isFIFO: () => false, - isSocket: () => false, - dev: 0, - ino: 0, - mode: stat.mode ?? 0, - nlink: stat.nlink ?? 1, - uid: stat.uid ?? 0, - gid: stat.gid ?? 0, - rdev: stat.rdev ?? 0, - size: stat.size ?? 0, - blksize: stat.blksize ?? 4096, - blocks: stat.blocks ?? 0, - atimeMs: atime.getTime(), - mtimeMs: mtime.getTime(), - ctimeMs: ctime.getTime(), - birthtimeMs: birthtime.getTime(), - atime, - mtime, - ctime, - birthtime, - }; - - return nodeStat; - } catch (error) { - this.annotateAndThrow( - error, - detectSymlink ? "lstat" : "stat", - normalizedPath - ); - } - } - - /** - * Returns information about a file or directory. If the path is a symbolic link, then the link itself is stat-ed, not the file that it refers to. - * @param path - The path to the file or directory. - * @returns A Node.js-like `Stats` object. - */ - async stat(path: string) { - return this.statCore(path, false); - } - - /** - * Returns information about a file or directory. If the path is a symbolic link, then the link itself is stat-ed, not the file that it refers to. - * @param path - The path to the file or directory. - * @returns A Node.js-like `Stats` object. - */ - async lstat(path: string) { - return this.statCore(path, true); - } - - /** - * Reads the value of a symbolic link. - * @param path - The path to the symbolic link. - * @returns The path to which the symbolic link points. - */ - async readlink(path: string) { - try { - return this.dofs.readlink(path); - } catch (error) { - this.annotateAndThrow(error, "readlink", path); - } - } - - /** - * Creates a symbolic link. - * @param target - The target path of the link. - * @param path - The path where the symbolic link will be created. - */ - async symlink(target: string, path: string) { - try { - return this.dofs.symlink(target, path); - } catch (error) { - this.annotateAndThrow(error, "symlink", path); - } - } -} diff --git a/apps/web/src/do/hybrid-repo.ts b/apps/web/src/do/hybrid-repo.ts index 9ea89e9..2eeabbb 100644 --- a/apps/web/src/do/hybrid-repo.ts +++ b/apps/web/src/do/hybrid-repo.ts @@ -1,38 +1,196 @@ import { DurableObject, env } from "cloudflare:workers"; +import { Result } from "better-result"; import { type DrizzleSqliteDODatabase, drizzle, } from "drizzle-orm/durable-sqlite"; import { migrate } from "drizzle-orm/durable-sqlite/migrator"; +import { handleReceivePack } from "@/git/handlers/receive-pack"; +import { advertiseReceivePackCapabilities } from "@/git/protocol/capabilities"; +import { parseReceivePackRequest } from "@/git/protocol/receive-pack"; +import { + gitErrorResponse, + gitResponse, + gitStreamResponse, +} from "@/git/response"; // @ts-expect-error - drizzle-orm script import migrations from "../../drizzle-do/migrations.js"; +import { ObjectStore } from "./object-store"; +import { PackIndexer } from "./pack-indexer"; +import { R2 } from "./r2"; +import { RefStore } from "./ref-store"; import * as gitSchema from "./schema/git"; export function getHybridRepoDOStub(fullRepoName: string) { - return (env.HYBRID_REPO as DurableObjectNamespace).getByName( - fullRepoName - ); + const stub = ( + env.HYBRID_REPO as DurableObjectNamespace + ).getByName(fullRepoName); + stub.setFullName(fullRepoName); + return stub; } +type DB = DrizzleSqliteDODatabase; + +type Storage = { + fullName: string; +}; + export class HybridRepo extends DurableObject { - private readonly db: DrizzleSqliteDODatabase; + private readonly db: DB; + private readonly r2: R2; + private readonly refs: RefStore; + private readonly objects: ObjectStore; + private readonly indexer: PackIndexer; + + private _fullName: string | undefined; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); this.db = drizzle(ctx.storage, { schema: gitSchema }); + this.r2 = new R2(env.BUCKET); + + this.refs = new RefStore(this.db); + this.objects = new ObjectStore(this.db, this.r2); + this.indexer = new PackIndexer({ + db: this.db, + existingObjects: this.objects, + }); ctx.blockConcurrencyWhile(async () => { - this._migrate(); + await this._migrate(); + const storedFullName = await this.typedStorage.get("fullName"); + if (storedFullName && !this._fullName) { + this._fullName = storedFullName; + } }); } - async _migrate() { - migrate(this.db, migrations); + _migrate() { + return migrate(this.db, migrations); + } + + get fullName() { + if (!this._fullName) { + throw new Error("Repository full name is not set"); + } + return this._fullName; + } + + async setFullName(fullName: string) { + if (this._fullName) return; + + this._fullName = fullName; + await this.typedStorage.put("fullName", fullName); + } + + get typedStorage() { + return { + get: async (key: K) => + this.ctx.storage.get(key), + put: async (key: K, value: Storage[K]) => + this.ctx.storage.put(key, value), + delete: async (key: K) => + this.ctx.storage.delete(key), + }; } - /** Health check */ async ping(): Promise { return "pong"; } + + async fetch(request: Request) { + const url = new URL(request.url); + + if (url.pathname === "/info/refs" && request.method === "GET") { + return this.routeInfoRefs(url); + } + + if (url.pathname === "/git-receive-pack" && request.method === "POST") { + return this.routeReceivePack(request); + } + + return new Response("Not Found", { status: 404 }); + } + + private async routeInfoRefs(url: URL): Promise { + const service = url.searchParams.get("service"); + + if (service === "git-receive-pack") { + const { refs, symbolicHead } = await this.listRefs(); + const capsRefs = refs.map((r) => ({ name: r.ref, oid: r.oid })); + const body = advertiseReceivePackCapabilities(capsRefs, symbolicHead); + return gitResponse(body, "receive-pack", "advertisement"); + } + + if (service === "git-upload-pack") { + return new Response("upload-pack not yet implemented", { status: 501 }); + } + + return new Response("Unknown service", { status: 400 }); + } + + private async routeReceivePack(request: Request): Promise { + const result = await Result.gen( + async function* (this: HybridRepo) { + if (!request.body) { + return Result.err({ message: "No request body" }); + } + + const parsed = yield* Result.await( + parseReceivePackRequest(request.body) + ); + const useSideband = parsed.capabilities.includes("side-band-64k"); + + const stream = yield* handleReceivePack( + { + r2: this.r2, + refs: this.refs, + indexer: this.indexer, + repoPath: this.fullName, + }, + parsed, + useSideband + ); + + return Result.ok(gitStreamResponse(stream, "receive-pack")); + }.bind(this) + ); + + if (result.isErr()) { + return gitErrorResponse(result.error.message, "receive-pack"); + } + + return result.value; + } + + async listRefs() { + const refsResult = await this.refs.list(); + if (refsResult.isErr()) { + return { refs: [], symbolicHead: null }; + } + + const allRefs = refsResult.value; + let symbolicHead: string | null = null; + + const refs: Array<{ ref: string; oid: string }> = []; + + for (const ref of allRefs) { + if (ref.name === "HEAD" && ref.isSymbolic) { + symbolicHead = ref.value; + const resolvedResult = await this.refs.resolve("HEAD"); + if (resolvedResult.isOk()) { + refs.push({ ref: "HEAD", oid: resolvedResult.value }); + } + } else if (!ref.isSymbolic) { + refs.push({ ref: ref.name, oid: ref.value }); + } + } + + return { refs, symbolicHead }; + } + + async initRepo(): Promise { + await this.refs.setSymbolic("HEAD", "refs/heads/main"); + } } diff --git a/apps/web/src/do/logger.ts b/apps/web/src/do/logger.ts deleted file mode 100644 index d95dfcc..0000000 --- a/apps/web/src/do/logger.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { env } from "cloudflare:workers"; - -type LogLevel = "debug" | "info" | "warn" | "error"; - -const LOG_LEVELS: Record = { - debug: 0, - info: 1, - warn: 2, - error: 3, -} as const; - -function isLogLevel(level: string): level is LogLevel { - return ( - level === "debug" || - level === "info" || - level === "warn" || - level === "error" - ); -} - -export function createLogger(namespace?: string, fullRepoName?: string) { - const configLevel = env.LOG_LEVEL ?? "info"; - const currentLevel = isLogLevel(configLevel) ? configLevel : "info"; - const minLevel = LOG_LEVELS[currentLevel]; - - const argsStr = [fullRepoName, namespace] - .filter(Boolean) - .map((s) => `[${s}]`) - .join(" "); - - return { - debug: (...args: unknown[]) => { - if (LOG_LEVELS.debug >= minLevel) { - console.debug(`[DEBUG] ${argsStr}`, ...args); - } - }, - info: (...args: unknown[]) => { - if (LOG_LEVELS.info >= minLevel) { - console.info(`[INFO] ${argsStr}`, ...args); - } - }, - warn: (...args: unknown[]) => { - if (LOG_LEVELS.warn >= minLevel) { - console.warn(`[WARN] ${argsStr}`, ...args); - } - }, - error: (...args: unknown[]) => { - if (LOG_LEVELS.error >= minLevel) { - console.error(`[ERROR] ${argsStr}`, ...args); - } - }, - }; -} diff --git a/apps/web/src/do/object-store.ts b/apps/web/src/do/object-store.ts new file mode 100644 index 0000000..ccedce6 --- /dev/null +++ b/apps/web/src/do/object-store.ts @@ -0,0 +1,427 @@ +/** + * ObjectStore: Read git objects from storage. + * + * Handles: + * - Looking up objects in git_object_index + * - Fetching compressed data from R2 + * - Decompressing objects + * - Delta resolution (using recursive CTE for efficient chain traversal) + */ + +import { inflateSync } from "node:zlib"; +import { Result, TaggedError } from "better-result"; +import { eq, sql } from "drizzle-orm"; +import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; +import { Delta } from "@/git/delta"; +import type { GitObjectType } from "@/git/object"; +import type { R2 } from "./r2"; +import * as schema from "./schema/git"; + +const MAX_DELTA_DEPTH = 50; + +/** + * Maximum gap between objects to coalesce into a single R2 range request. + * If two objects in the same pack are within this gap, fetch them together. + */ +const COALESCE_GAP_THRESHOLD = 4096; // 4KB + +export type ResolvedObject = { + oid: string; + type: GitObjectType; + data: Uint8Array; +}; + +export type ObjectStoreErrorCode = + | "NOT_FOUND" + | "R2_ERROR" + | "DECOMPRESS_ERROR" + | "DELTA_ERROR" + | "CYCLE_DETECTED" + | "DELTA_DEPTH_EXCEEDED" + | "DB_ERROR"; + +export class ObjectStoreError extends TaggedError("ObjectStoreError")<{ + code: ObjectStoreErrorCode; + message: string; + oid: string; +}>() {} + +type DB = DrizzleSqliteDODatabase; + +/** + * Row returned by the recursive CTE query for delta chain traversal. + * Ordered from target object (depth=0) to base object (highest depth). + */ +type DeltaChainRow = { + oid: string; + pack_id: string; + pack_offset: number; + compressed_size: number; + type: string; + is_delta: number; + base_oid: string | null; + r2_key: string; + depth: number; +}; + +/** + * A coalesced range request that fetches multiple objects in one R2 call. + */ +type CoalescedRange = { + r2Key: string; + offset: number; + length: number; + /** Objects contained in this range, with their position relative to range start */ + objects: Array<{ + oid: string; + relativeOffset: number; + compressedSize: number; + }>; +}; + +export class ObjectStore { + private readonly db: DB; + private readonly r2: R2; + + constructor(db: DB, r2: R2) { + this.db = db; + this.r2 = r2; + } + + async has(oid: string) { + const result = await this.db.query.gitObjectIndex.findFirst({ + where: eq(schema.gitObjectIndex.oid, oid), + columns: { oid: true }, + }); + return result !== undefined; + } + + /** + * Fetch the entire delta chain for an OID using a recursive CTE. + * Returns rows ordered from target (depth=0) to base (highest depth). + * + * The CTE: + * 1. Starts with the requested OID at depth 0 + * 2. Recursively joins on base_oid to traverse the chain + * 3. Stops when reaching a non-delta object or max depth + * 4. Detects cycles via the depth limit (SQLite doesn't have native cycle detection) + */ + private async fetchDeltaChain( + oid: string + ): Promise> { + const query = sql` + WITH RECURSIVE delta_chain(oid, pack_id, pack_offset, compressed_size, type, is_delta, base_oid, r2_key, depth) AS ( + -- Base case: start with the requested OID + SELECT + o.oid, + o.pack_id, + o.pack_offset, + o.compressed_size, + o.type, + o.is_delta, + o.base_oid, + p.r2_key, + 0 as depth + FROM git_object_index o + JOIN git_packs p ON o.pack_id = p.pack_id + WHERE o.oid = ${oid} + + UNION ALL + + -- Recursive case: follow base_oid links + SELECT + o.oid, + o.pack_id, + o.pack_offset, + o.compressed_size, + o.type, + o.is_delta, + o.base_oid, + p.r2_key, + dc.depth + 1 + FROM delta_chain dc + JOIN git_object_index o ON dc.base_oid = o.oid + JOIN git_packs p ON o.pack_id = p.pack_id + WHERE dc.is_delta = 1 + AND dc.base_oid IS NOT NULL + AND dc.depth < ${MAX_DELTA_DEPTH} + ) + SELECT * FROM delta_chain ORDER BY depth ASC + `; + + return Result.tryPromise({ + try: async () => { + const result = this.db.all(query); + return result; + }, + catch: (cause) => + new ObjectStoreError({ + code: "DB_ERROR", + message: `Database error: ${String(cause)}`, + oid, + }), + }); + } + + /** + * Build coalesced ranges from delta chain rows. + * + * Groups objects by R2 key, sorts by offset, and merges adjacent/nearby + * objects into single range requests to minimize R2 subrequests. + */ + private buildCoalescedRanges(chain: DeltaChainRow[]): CoalescedRange[] { + // Group by r2_key + const byR2Key = new Map(); + for (const row of chain) { + const existing = byR2Key.get(row.r2_key); + if (existing) { + existing.push(row); + } else { + byR2Key.set(row.r2_key, [row]); + } + } + + const ranges: CoalescedRange[] = []; + + for (const [r2Key, rows] of byR2Key) { + // Sort by pack_offset + rows.sort((a, b) => a.pack_offset - b.pack_offset); + + let currentRange: CoalescedRange | null = null; + + for (const row of rows) { + const rowEnd = row.pack_offset + row.compressed_size; + + if (currentRange) { + const currentEnd = currentRange.offset + currentRange.length; + const gap = row.pack_offset - currentEnd; + + if (gap <= COALESCE_GAP_THRESHOLD) { + // Coalesce: extend current range to include this object + currentRange.length = rowEnd - currentRange.offset; + currentRange.objects.push({ + oid: row.oid, + relativeOffset: row.pack_offset - currentRange.offset, + compressedSize: row.compressed_size, + }); + } else { + // Gap too large: finalize current range, start new one + ranges.push(currentRange); + currentRange = { + r2Key, + offset: row.pack_offset, + length: row.compressed_size, + objects: [ + { + oid: row.oid, + relativeOffset: 0, + compressedSize: row.compressed_size, + }, + ], + }; + } + } else { + // Start new range + currentRange = { + r2Key, + offset: row.pack_offset, + length: row.compressed_size, + objects: [ + { + oid: row.oid, + relativeOffset: 0, + compressedSize: row.compressed_size, + }, + ], + }; + } + } + + if (currentRange) { + ranges.push(currentRange); + } + } + + return ranges; + } + + /** + * Fetch and decompress all objects in a delta chain using coalesced parallel R2 requests. + */ + private async fetchChainObjects( + chain: DeltaChainRow[], + oid: string + ): Promise, ObjectStoreError>> { + const ranges = this.buildCoalescedRanges(chain); + + const fetchResults = await Promise.all( + ranges.map(async (range) => { + const r2Result = await this.r2.get(range.r2Key, { + range: { + offset: range.offset, + length: range.length, + }, + }); + + if (r2Result.isErr()) { + return Result.err( + new ObjectStoreError({ + code: "R2_ERROR", + message: `R2 error for range at ${range.r2Key}:${range.offset}: ${r2Result.error.message}`, + oid, + }) + ); + } + + const buffer = new Uint8Array(await r2Result.value.arrayBuffer()); + return Result.ok({ range, buffer }); + }) + ); + + return Result.gen(function* () { + const objectDataMap = new Map(); + + for (const result of fetchResults) { + const { range, buffer } = yield* result; + + for (const obj of range.objects) { + const compressedData = buffer.subarray( + obj.relativeOffset, + obj.relativeOffset + obj.compressedSize + ); + + const decompressed = yield* Result.try({ + try: () => inflateSync(compressedData), + catch: (cause) => + new ObjectStoreError({ + code: "DECOMPRESS_ERROR", + message: `Decompression error for "${obj.oid}": ${String(cause)}`, + oid, + }), + }); + + objectDataMap.set(obj.oid, decompressed); + } + } + + return Result.ok(objectDataMap); + }); + } + + /** + * Read and fully resolve an object by OID. + * + * Uses a recursive CTE to fetch the entire delta chain in a single query, + * then applies deltas from base to target. + */ + async read(oid: string): Promise> { + return Result.gen( + async function* (this: ObjectStore) { + const chain = yield* Result.await(this.fetchDeltaChain(oid)); + + if (chain.length === 0) { + return Result.err( + new ObjectStoreError({ + code: "NOT_FOUND", + message: `Object "${oid}" not found`, + oid, + }) + ); + } + + const baseRow = chain.at(-1); + if (!baseRow) { + return Result.err( + new ObjectStoreError({ + code: "NOT_FOUND", + message: `Object "${oid}" not found`, + oid, + }) + ); + } + + if (baseRow.is_delta && baseRow.base_oid) { + if (baseRow.depth >= MAX_DELTA_DEPTH - 1) { + return Result.err( + new ObjectStoreError({ + code: "DELTA_DEPTH_EXCEEDED", + message: `Delta chain too deep (max ${MAX_DELTA_DEPTH})`, + oid, + }) + ); + } + return Result.err( + new ObjectStoreError({ + code: "NOT_FOUND", + message: `Delta base "${baseRow.base_oid}" not found`, + oid, + }) + ); + } + + const seenOids = new Set(); + for (const row of chain) { + if (seenOids.has(row.oid)) { + return Result.err( + new ObjectStoreError({ + code: "CYCLE_DETECTED", + message: `Delta cycle detected at "${row.oid}"`, + oid, + }) + ); + } + seenOids.add(row.oid); + } + + const objectDataMap = yield* Result.await( + this.fetchChainObjects(chain, oid) + ); + + let currentData = objectDataMap.get(baseRow.oid); + if (!currentData) { + return Result.err( + new ObjectStoreError({ + code: "NOT_FOUND", + message: `Base object data missing for "${baseRow.oid}"`, + oid, + }) + ); + } + const baseType = baseRow.type as GitObjectType; + + for (let i = chain.length - 2; i >= 0; i -= 1) { + const deltaRow = chain[i]; + const deltaData = objectDataMap.get(deltaRow.oid); + if (!deltaData) { + return Result.err( + new ObjectStoreError({ + code: "NOT_FOUND", + message: `Delta object data missing for "${deltaRow.oid}"`, + oid, + }) + ); + } + + const deltaResult = Delta.apply(currentData, deltaData); + if (deltaResult.isErr()) { + return Result.err( + new ObjectStoreError({ + code: "DELTA_ERROR", + message: `Delta error at "${deltaRow.oid}": ${deltaResult.error.message}`, + oid, + }) + ); + } + + currentData = deltaResult.value; + } + + return Result.ok({ + oid, + type: baseType, + data: currentData, + }); + }.bind(this) + ); + } +} diff --git a/apps/web/src/do/pack-indexer.ts b/apps/web/src/do/pack-indexer.ts new file mode 100644 index 0000000..e8caaf4 --- /dev/null +++ b/apps/web/src/do/pack-indexer.ts @@ -0,0 +1,318 @@ +/** + * Pack Indexer: Index packfiles and store metadata in SQLite. + * + * Handles: + * - Parsing packfile stream via PackfileParser + * - Computing OIDs for base objects + * - Resolving REF_DELTA objects (in-memory + existing objects for thin packs) + * - Batch inserting index entries into SQLite + * - Progress reporting for streaming responses + */ + +import { Result, TaggedError } from "better-result"; +import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; +import { Delta } from "@/git/delta"; +import { GitObject, type GitObjectType } from "@/git/object"; +import { PackfileParser } from "@/git/pack"; +import type { ObjectStore } from "./object-store"; +import * as schema from "./schema/git"; + +type IndexedObject = { + oid: string; + type: GitObjectType; + offset: number; + compressedSize: number; + isDelta: boolean; + baseOid: string | undefined; +}; + +type IndexResult = { + packId: string; + objectCount: number; + deltaCount: number; + objects: IndexedObject[]; +}; + +export type ProgressEvent = + | { type: "unpacking"; current: number; total: number } + | { type: "resolving_deltas"; current: number; total: number } + | { type: "writing_index"; count: number }; + +export type PackIndexErrorCode = + | "PARSE_ERROR" + | "DELTA_BASE_NOT_FOUND" + | "DELTA_APPLY_ERROR" + | "OFS_DELTA_NOT_SUPPORTED" + | "DB_ERROR" + | "CHECKSUM_MISMATCH"; + +export class PackIndexError extends TaggedError("PackIndexError")<{ + code: PackIndexErrorCode; + message: string; + offset?: number; +}>() {} + +type ResolvedObjectData = { + type: GitObjectType; + data: Uint8Array; +}; + +type PendingDelta = { + offset: number; + compressedSize: number; + baseHash: string; + deltaData: Uint8Array; +}; + +type DB = DrizzleSqliteDODatabase; + +export type PackIndexerDeps = { + db: DB; + existingObjects: ObjectStore; +}; + +export class PackIndexer { + private readonly db: DB; + private readonly existingObjects: ObjectStore; + + constructor(deps: PackIndexerDeps) { + this.db = deps.db; + this.existingObjects = deps.existingObjects; + } + + /** + * Index a packfile stream. + * + * Algorithm: + * 1. Parse all objects, resolving base objects immediately + * 2. Iterate over pending deltas until all resolved (or error) + * 3. Batch write to SQLite + */ + async index( + packId: string, + r2Key: string, + packStream: ReadableStream, + onProgress?: (event: ProgressEvent) => void | Promise + ): Promise> { + const resolvedObjects = new Map(); + const pendingDeltas: PendingDelta[] = []; + const indexEntries: IndexedObject[] = []; + + let objectsTotal = 0; + + const parser = new PackfileParser(packStream); + + for await (const eventResult of parser.parse()) { + if (eventResult.isErr()) { + return Result.err( + new PackIndexError({ + code: "PARSE_ERROR", + message: eventResult.error.message, + offset: eventResult.error.offset, + }) + ); + } + + const event = eventResult.value; + + if (event.type === "header") { + objectsTotal = event.objectCount; + continue; + } + + if (event.type === "result") { + if (!event.valid) { + return Result.err( + new PackIndexError({ + code: "CHECKSUM_MISMATCH", + message: "Packfile checksum verification failed", + }) + ); + } + continue; + } + + const { objectType, data, offset } = event; + + // Compressed size = bytes consumed for this object + // offset is where object started, bytesRead is where parser is now + const compressedSize = parser.progress.bytesRead - offset; + + await onProgress?.({ + type: "unpacking", + current: parser.progress.objectsParsed, + total: objectsTotal, + }); + + if (objectType === "ofs_delta") { + return Result.err( + new PackIndexError({ + code: "OFS_DELTA_NOT_SUPPORTED", + message: `OFS_DELTA not supported at offset ${offset}`, + offset, + }) + ); + } + + if (objectType === "ref_delta") { + pendingDeltas.push({ + offset, + compressedSize, + baseHash: event.baseHash, + deltaData: data, + }); + continue; + } + + const oid = GitObject.computeOid(objectType, data); + resolvedObjects.set(oid, { type: objectType, data }); + indexEntries.push({ + oid, + type: objectType, + offset, + compressedSize, + isDelta: false, + baseOid: undefined, + }); + } + + // Now we resolve deltas + const totalDeltas = pendingDeltas.length; + let resolvedCount = 0; + + while (pendingDeltas.length > 0) { + let resolvedAny = false; + + for (let i = pendingDeltas.length - 1; i >= 0; i -= 1) { + const delta = pendingDeltas[i]; + + // Try to find base in resolved objects + let base = resolvedObjects.get(delta.baseHash); + + // If not found, try existing storage (thin pack support) + if (!base) { + const existingResult = await this.existingObjects.read( + delta.baseHash + ); + if (existingResult.isOk()) { + const existing = existingResult.value; + base = { type: existing.type, data: existing.data }; + // Cache for other deltas that might reference it + resolvedObjects.set(delta.baseHash, base); + } + } + + if (!base) { + // Base not yet available, skip for now + continue; + } + + // Apply delta + const applyResult = Delta.apply(base.data, delta.deltaData); + if (applyResult.isErr()) { + return Result.err( + new PackIndexError({ + code: "DELTA_APPLY_ERROR", + message: `Delta apply failed at offset ${delta.offset}: ${applyResult.error.message}`, + offset: delta.offset, + }) + ); + } + + const resolvedData = applyResult.value; + const oid = GitObject.computeOid(base.type, resolvedData); + + resolvedObjects.set(oid, { type: base.type, data: resolvedData }); + indexEntries.push({ + oid, + type: base.type, + offset: delta.offset, + compressedSize: delta.compressedSize, + isDelta: true, + baseOid: delta.baseHash, + }); + + // Remove from pending + pendingDeltas.splice(i, 1); + resolvedCount += 1; + resolvedAny = true; + + await onProgress?.({ + type: "resolving_deltas", + current: resolvedCount, + total: totalDeltas, + }); + } + + // If we made no progress and still have pending deltas, bases are missing + if (!resolvedAny && pendingDeltas.length > 0) { + const missingBases = [...new Set(pendingDeltas.map((d) => d.baseHash))]; + return Result.err( + new PackIndexError({ + code: "DELTA_BASE_NOT_FOUND", + message: `Missing delta base(s): ${missingBases.slice(0, 3).join(", ")}${missingBases.length > 3 ? ` and ${missingBases.length - 3} more` : ""}`, + }) + ); + } + } + + // Pass 3: Write to SQLite + await onProgress?.({ type: "writing_index", count: indexEntries.length }); + + const writeResult = await this.writeIndex(packId, r2Key, indexEntries); + if (writeResult.isErr()) { + return writeResult; + } + + return Result.ok({ + packId, + objectCount: indexEntries.length, + deltaCount: totalDeltas, + objects: indexEntries, + }); + } + + /** + * Write pack and object index entries to SQLite in a single transaction. + */ + private async writeIndex( + packId: string, + r2Key: string, + entries: IndexedObject[] + ): Promise> { + return Result.tryPromise({ + try: async () => { + await this.db.transaction(async (tx) => { + await tx.insert(schema.gitPacks).values({ + packId, + r2Key, + objectCount: entries.length, + }); + + // Batch insert object index entries + // SQLite has a limit on variables per statement, so batch in chunks + const BATCH_SIZE = 10; + for (let i = 0; i < entries.length; i += BATCH_SIZE) { + const batch = entries.slice(i, i + BATCH_SIZE); + await tx.insert(schema.gitObjectIndex).values( + batch.map((entry) => ({ + oid: entry.oid, + packId, + packOffset: entry.offset, + compressedSize: entry.compressedSize, + type: entry.type, + isDelta: entry.isDelta, + baseOid: entry.baseOid, + })) + ); + } + }); + }, + catch: (cause) => + new PackIndexError({ + code: "DB_ERROR", + message: `Database error: ${String(cause)}`, + }), + }); + } +} diff --git a/apps/web/src/do/r2.ts b/apps/web/src/do/r2.ts index 316bc39..ccd4384 100644 --- a/apps/web/src/do/r2.ts +++ b/apps/web/src/do/r2.ts @@ -14,26 +14,34 @@ export class R2Error extends TaggedError("R2Error")<{ message: string; }>() { constructor(args: { reason: R2Reason; cause: unknown }) { - const message = R2Error.#formatMessage(args.reason); + const message = R2Error.#formatMessage(args.reason, args.cause); super({ ...args, message }); } - static #formatMessage(reason: R2Reason): string { + static #formatMessage(reason: R2Reason, cause: unknown): string { + let causeStr = ""; + if (cause instanceof Error) { + causeStr = cause.message; + } else if (cause) { + causeStr = String(cause); + } + const suffix = causeStr ? `: ${causeStr}` : ""; + switch (reason.op) { case "head": - return `Failed to get metadata for "${reason.key}"`; + return `Failed to get metadata for "${reason.key}"${suffix}`; case "get": - return `Failed to get "${reason.key}"`; + return `Failed to get "${reason.key}"${suffix}`; case "put": - return `Failed to put "${reason.key}"`; + return `Failed to put "${reason.key}"${suffix}`; case "delete": { const keyStr = Array.isArray(reason.keys) ? reason.keys.join(", ") : reason.keys; - return `Failed to delete "${keyStr}"`; + return `Failed to delete "${keyStr}"${suffix}`; } case "list": - return "Failed to list objects"; + return `Failed to list objects${suffix}`; default: { const _exhaustive: never = reason; return _exhaustive; diff --git a/apps/web/src/do/ref-store.ts b/apps/web/src/do/ref-store.ts new file mode 100644 index 0000000..e516094 --- /dev/null +++ b/apps/web/src/do/ref-store.ts @@ -0,0 +1,304 @@ +/** + * RefStore: CRUD operations for git refs in SQLite. + * + * Handles direct refs (pointing to OIDs) and symbolic refs (pointing to other refs). + * Supports atomic batch updates with old-value verification for compare-and-swap semantics. + */ + +import { Result, TaggedError } from "better-result"; +import { eq } from "drizzle-orm"; +import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; +import * as schema from "./schema/git"; + +const ZERO_OID = "0".repeat(40); +const MAX_SYMBOLIC_DEPTH = 10; + +export type Ref = { + name: string; + value: string; + isSymbolic: boolean; +}; + +export type RefUpdate = { + name: string; + oldOid: string; + newOid: string; +}; + +export type RefUpdateResult = { + name: string; + ok: boolean; + error?: string; +}; + +export type RefErrorCode = + | "NOT_FOUND" + | "ALREADY_EXISTS" + | "OLD_OID_MISMATCH" + | "SYMBOLIC_LOOP" + | "DB_ERROR"; + +export class RefError extends TaggedError("RefError")<{ + code: RefErrorCode; + message: string; + ref: string; +}>() {} + +type DB = DrizzleSqliteDODatabase; + +export class RefStore { + private readonly db: DB; + + constructor(db: DB) { + this.db = db; + } + + async get(name: string): Promise> { + const result = await Result.tryPromise({ + try: () => + this.db.query.gitRefs.findFirst({ + where: eq(schema.gitRefs.name, name), + }), + catch: (cause) => + new RefError({ + code: "DB_ERROR", + message: `Database error: ${String(cause)}`, + ref: name, + }), + }); + + if (result.isErr()) return result; + + const row = result.value; + if (!row) { + return Result.err( + new RefError({ + code: "NOT_FOUND", + message: `Ref "${name}" not found`, + ref: name, + }) + ); + } + + return Result.ok({ + name: row.name, + value: row.value, + isSymbolic: row.isSymbolic, + }); + } + + async list(): Promise> { + return Result.tryPromise({ + try: async () => { + const rows = await this.db.query.gitRefs.findMany(); + return rows.map((row) => ({ + name: row.name, + value: row.value, + isSymbolic: row.isSymbolic, + })); + }, + catch: (cause) => + new RefError({ + code: "DB_ERROR", + message: `Database error: ${String(cause)}`, + ref: "*", + }), + }); + } + + async resolve(name: string): Promise> { + const seen = new Set(); + let currentName = name; + + for (let depth = 0; depth < MAX_SYMBOLIC_DEPTH; depth += 1) { + if (seen.has(currentName)) { + return Result.err( + new RefError({ + code: "SYMBOLIC_LOOP", + message: `Symbolic ref loop detected: ${Array.from(seen).join(" -> ")} -> ${currentName}`, + ref: name, + }) + ); + } + seen.add(currentName); + + const refResult = await this.get(currentName); + if (refResult.isErr()) { + // Propagate NOT_FOUND with original ref name + if (refResult.error.code === "NOT_FOUND") { + return Result.err( + new RefError({ + code: "NOT_FOUND", + message: `Ref "${name}" not found (failed at "${currentName}")`, + ref: name, + }) + ); + } + return refResult; + } + + const ref = refResult.value; + if (!ref.isSymbolic) { + return Result.ok(ref.value); + } + + currentName = ref.value; + } + + return Result.err( + new RefError({ + code: "SYMBOLIC_LOOP", + message: `Symbolic ref chain too deep (max ${MAX_SYMBOLIC_DEPTH})`, + ref: name, + }) + ); + } + + /** + * Apply a batch of ref updates atomically. + * + * Each update specifies: + * - oldOid: expected current value (ZERO_OID for create) + * - newOid: new value (ZERO_OID for delete) + * + * All updates are validated first, then applied in a transaction. + * If any validation fails, no updates are applied. + */ + async applyUpdates(updates: RefUpdate[]): Promise { + if (updates.length === 0) { + return []; + } + + const results: RefUpdateResult[] = []; + + // Pre-validate all updates + const validationErrors = await this.validateUpdates(updates); + const hasErrors = validationErrors.some((e) => e !== null); + + if (hasErrors) { + return updates.map((update, i) => ({ + name: update.name, + ok: validationErrors[i] === null, + error: validationErrors[i] ?? undefined, + })); + } + + // Apply all updates in a transaction + try { + await this.db.transaction(async (tx) => { + for (const update of updates) { + if (update.newOid === ZERO_OID) { + await tx + .delete(schema.gitRefs) + .where(eq(schema.gitRefs.name, update.name)); + } else if (update.oldOid === ZERO_OID) { + await tx.insert(schema.gitRefs).values({ + name: update.name, + value: update.newOid, + isSymbolic: false, + }); + } else { + await tx + .update(schema.gitRefs) + .set({ value: update.newOid }) + .where(eq(schema.gitRefs.name, update.name)); + } + + results.push({ name: update.name, ok: true }); + } + }); + } catch (cause) { + // Transaction failed - all updates fail + return updates.map((update) => ({ + name: update.name, + ok: false, + error: `Transaction failed: ${String(cause)}`, + })); + } + + return results; + } + + /** + * Create a symbolic ref. + * + * @param name - Name of the symbolic ref (e.g., "HEAD") + * @param target - Target ref name (e.g., "refs/heads/main") + */ + async setSymbolic( + name: string, + target: string + ): Promise> { + return Result.tryPromise({ + try: async () => { + await this.db + .insert(schema.gitRefs) + .values({ + name, + value: target, + isSymbolic: true, + }) + .onConflictDoUpdate({ + target: schema.gitRefs.name, + set: { value: target, isSymbolic: true }, + }); + }, + catch: (cause) => + new RefError({ + code: "DB_ERROR", + message: `Database error: ${String(cause)}`, + ref: name, + }), + }); + } + + private async validateUpdates( + updates: RefUpdate[] + ): Promise> { + const errors: Array = []; + + for (const update of updates) { + const currentRef = await this.db.query.gitRefs.findFirst({ + where: eq(schema.gitRefs.name, update.name), + }); + + const currentOid = currentRef?.value ?? ZERO_OID; + + if (update.oldOid === ZERO_OID) { + // Create: ref must not exist + if (currentRef) { + errors.push(`Ref "${update.name}" already exists`); + continue; + } + } else if (update.newOid === ZERO_OID) { + // Delete: ref must exist with matching OID + if (!currentRef) { + errors.push(`Ref "${update.name}" not found`); + continue; + } + if (currentOid !== update.oldOid) { + errors.push( + `Ref "${update.name}" old value mismatch: expected ${update.oldOid}, got ${currentOid}` + ); + continue; + } + } else { + // Update: ref must exist with matching OID + if (!currentRef) { + errors.push(`Ref "${update.name}" not found`); + continue; + } + if (currentOid !== update.oldOid) { + errors.push( + `Ref "${update.name}" old value mismatch: expected ${update.oldOid}, got ${currentOid}` + ); + continue; + } + } + + errors.push(null); + } + + return errors; + } +} diff --git a/apps/web/src/do/repo.ts b/apps/web/src/do/repo.ts deleted file mode 100644 index 2f07975..0000000 --- a/apps/web/src/do/repo.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { DurableObject, env } from "cloudflare:workers"; -import * as Sentry from "@sentry/cloudflare"; -import { Fs } from "dofs"; -import { - buildFetchResponse, - buildLsRefsResponse, - buildReportStatus, - parseCommand, - parseFetchRequest, - parseReceivePackRequest, -} from "@/git/protocol"; -import { GitService } from "@/git/service"; -import { cache } from "./cache"; -import { IsoGitFs } from "./fs"; -import { createLogger } from "./logger"; - -export function getRepoDOStub(fullRepoName: string) { - const stub = (env.REPO as DurableObjectNamespace).getByName( - fullRepoName - ); - stub.setFullName(fullRepoName); - return stub; -} - -const logger = createLogger("RepoDO"); - -type Storage = { - fullName: string; - testKey: number; - anotherKey: boolean; - yetAnotherKey: string; -}; - -/** - * Durable Object (DO) to manage a Git repository using isomorphic-git and DOFS. - * Each DO instance represents a single Git repository. - * All the data like objects, refs, packfiles and config are stored in DO SQLite storage via DOFS. - */ -class RepoBase extends DurableObject { - private readonly dofs: Fs; - private readonly isoGitFs: ReturnType; - private readonly git: GitService; - - private _fullName: string | undefined; - - constructor(ctx: DurableObjectState, env: Env) { - super(ctx, env); - - this.dofs = new Fs(ctx, env, { chunkSize: 512 * 1024 }); // 512KB chunks - - this.isoGitFs = new IsoGitFs(this.dofs).getPromiseFsClient(); - this.git = new GitService(this.isoGitFs, "/repo"); - - this.ctx.blockConcurrencyWhile(async () => { - this.dofs.setDeviceSize(5 * 1024 * 1024 * 1024); // 5GB device size to support large repos - await this.ensureRepoInitialized(); - const storedFullName = await this.typedStorage.get("fullName"); - if (storedFullName && !this._fullName) { - this._fullName = storedFullName; - } - }); - } - - get fullName() { - if (!this._fullName) { - throw new Error("Repository full name is not set"); - } - return this._fullName; - } - - async setFullName(fullName: string) { - if (this._fullName) return; - - this._fullName = fullName; - await this.typedStorage.put("fullName", fullName); - } - - get typedStorage() { - return { - get: async (key: K) => - this.ctx.storage.get(key), - put: async (key: K, value: Storage[K]) => - this.ctx.storage.put(key, value), - delete: async (key: K) => - this.ctx.storage.delete(key), - }; - } - - async fetch(request: Request) { - const url = new URL(request.url); - const pathname = url.pathname; - - const data = new Uint8Array(await request.arrayBuffer()); - - if (pathname === "/git-receive-pack" && request.method === "POST") { - const result = await this.receivePack(data); - return result; - } - - if (pathname === "/git-upload-pack" && request.method === "POST") { - const result = await this.uploadPack(data); - return result; - } - - return new Response("Not Found", { status: 404 }); - } - - getDeviceStats() { - return this.dofs.getDeviceStats(); - } - - async initRepo() { - await this.git.initRepo(); - } - - async ensureRepoInitialized() { - try { - await this.isoGitFs.promises.stat("/repo/HEAD"); - } catch { - await this.initRepo(); - } - } - - async listRefs() { - return this.git.listRefs(); - } - - async receivePack(data: Uint8Array) { - const { commands, packfile, capabilities } = parseReceivePackRequest(data); - - const packFilePath = `/repo/objects/pack/pack-${Date.now()}.pack`; - await this.isoGitFs.promises.writeFile(packFilePath, packfile); - - try { - await this.git.indexPack(packFilePath.replace("/repo/", "")); - } catch (error) { - // TODO: report status back to client - logger.error("(receive-pack) Failed to index packfile: ", error); - return buildReportStatus( - [ - { - ref: "*", - ok: false, - error: `unpack failed: ${(error as Error).message}`, - }, - ], - false - ); - } - - const atomic = capabilities.includes("atomic"); - const results = await this.git.applyRefUpdates(commands, atomic); - - return buildReportStatus(results, true); - } - - async uploadPack(data: Uint8Array) { - const { command, args } = parseCommand(data); - - if (command === "ls-refs") { - const { refs, symbolicHead } = await this.git.listRefs(); - - const response = await buildLsRefsResponse( - refs, - args, - symbolicHead, - async (oid: string) => this.git.readObjectForLsRefs(oid) - ); - - return response; - } - - if (command === "fetch") { - const fetchRequest = parseFetchRequest(data, args); - - // Find common commits between client and server - const commonCommits = await this.git.findCommonCommits( - fetchRequest.haves - ); - - // If client sent "done", we need to generate and send packfile - let packfileData: Uint8Array | undefined | null = null; - - if (fetchRequest.done && fetchRequest.wants.length > 0) { - // Walk the object graph to find all objects reachable from wants but not from haves - try { - const objectsToPack = await this.git.collectObjectsForPack( - fetchRequest.wants, - fetchRequest.haves - ); - - logger.info( - `(upload-pack-fetch) Packing ${objectsToPack.length} objects for wants: ${fetchRequest.wants.join(", ")}` - ); - - packfileData = await this.git.packObjects(objectsToPack); - } catch (error) { - logger.error("(upload-pack-fetch) Failed to pack objects: ", error); - return new Response( - `ERR pack-objects failed: ${(error as Error).message}`, - { status: 500 } - ); - } - } - - const response = await buildFetchResponse({ - commonCommits, - packfileData, - noProgress: fetchRequest.capabilities.noProgress, - done: fetchRequest.done, - }); - - return response; - } - - return new Response("Unsupported command", { status: 400 }); - } - - async getLatestCommit(branch = "HEAD") { - const commit = await this.git.getLastCommit(branch); - return commit; - } - - async getCommits(args: { ref?: string; depth?: number; filepath?: string }) { - const { ref, depth, filepath } = args; - - const latestCommit = await this.getLatestCommit(ref); - if (!latestCommit) { - return []; - } - - const commits = await cache.getOrSetJson({ - key: `${this.fullName}/commits`, - fetcher: async () => await this.git.getLog(args), - params: { - ref, - depth: depth?.toString(), - filepath, - latestCommitOid: latestCommit.oid, - }, - }); - return commits; - } - - async getBranches() { - const branches = await this.git.listBranches(); - const currentBranch = await this.git.currentBranch(); - return { branches, currentBranch }; - } - - async getTree(args: { ref?: string; path?: string }) { - const { ref, path } = args; - - const resolvedRef = await this.git.resolveRef(ref); - if (!resolvedRef) { - return []; - } - - const treeWithLastCommit = await cache.getOrSetJson({ - key: `${this.fullName}/treeWithLastCommit`, - fetcher: async () => { - const tree = await this.git.getTree(resolvedRef, path); - - const data = await Promise.all( - tree.map(async (item) => { - const lastCommit = await this.git.getLog({ - ref, - depth: 1, - filepath: path ? `${path}/${item.path}` : item.path, - }); - return { ...item, lastCommit: lastCommit[0] || null }; - }) - ); - return data; - }, - params: { - resolvedRef, - path: path || "/", - }, - }); - - return treeWithLastCommit; - } - - async getBlob(args: { ref?: string; filepath: string }) { - const { ref, filepath } = args; - - const resolvedRef = await this.git.resolveRef(ref); - if (!resolvedRef) { - return null; - } - - const blob = await cache.getOrSetJson({ - key: `${this.fullName}/blob`, - fetcher: async () => await this.git.getBlob(resolvedRef, filepath), - params: { - resolvedRef, - filepath, - }, - }); - return blob; - } - - async getCommit(commitOid: string) { - const result = await cache.getOrSetJson({ - key: `${this.fullName}/commit/${commitOid}`, - fetcher: async () => await this.git.getCommit(commitOid), - }); - - return result; - } -} - -// Export your named class as defined in your wrangler config -export const Repo = Sentry.instrumentDurableObjectWithSentry( - (_env: Env) => ({ - dsn: "https://412acc40471763ed76cfbd92c70a80e4@o4510288569106432.ingest.us.sentry.io/4510318411579392", - tracesSampleRate: 1.0, - enableLogs: true, - integrations: [Sentry.consoleLoggingIntegration()], - }), - RepoBase -); diff --git a/apps/web/src/git/delta.ts b/apps/web/src/git/delta.ts new file mode 100644 index 0000000..0a933de --- /dev/null +++ b/apps/web/src/git/delta.ts @@ -0,0 +1,207 @@ +/** + * Git delta application. + * + * Delta format (REF_DELTA): + * - Varint: base object size + * - Varint: result object size + * - Instructions: sequence of copy/insert operations + * + * Copy instruction (MSB set): + * 1oooosss [offset bytes] [size bytes] + * - o bits: which offset bytes follow (1-4) + * - s bits: which size bytes follow (1-3) + * + * Insert instruction (MSB clear): + * 0sssssss [data bytes] + * - s bits: number of literal bytes to insert (1-127) + * + * @see https://git-scm.com/docs/pack-format#_deltified_representation + */ + +import { Result, TaggedError } from "better-result"; + +type DeltaHeader = { + baseSize: number; + resultSize: number; + headerLength: number; +}; + +export type DeltaErrorCode = + | "INVALID_HEADER" + | "BASE_SIZE_MISMATCH" + | "RESULT_SIZE_MISMATCH" + | "INVALID_OPCODE" + | "COPY_OUT_OF_BOUNDS"; + +export class DeltaError extends TaggedError("DeltaError")<{ + code: DeltaErrorCode; + message: string; +}>() {} + +/** + * Parse a varint from buffer at given offset. + * Returns [value, bytesConsumed]. + * + * Varint encoding: 7 bits per byte, MSB indicates continuation. + */ +function parseVarint( + data: Uint8Array, + offset: number +): Result<[number, number], DeltaError> { + let value = 0; + let shift = 0; + let bytesConsumed = 0; + + while (offset + bytesConsumed < data.length) { + const byte = data[offset + bytesConsumed]; + value |= (byte & 0x7f) << shift; + bytesConsumed += 1; + if ((byte & 0x80) === 0) { + return Result.ok([value, bytesConsumed]); + } + shift += 7; + } + + return Result.err( + new DeltaError({ + code: "INVALID_HEADER", + message: "Unexpected end of data while parsing varint", + }) + ); +} + +function parseDeltaHeader(delta: Uint8Array): Result { + return Result.gen(function* () { + const [baseSize, baseSizeLen] = yield* parseVarint(delta, 0); + const [resultSize, resultSizeLen] = yield* parseVarint(delta, baseSizeLen); + + return Result.ok({ + baseSize, + resultSize, + headerLength: baseSizeLen + resultSizeLen, + }); + }); +} + +function apply( + base: Uint8Array, + delta: Uint8Array +): Result { + const headerResult = parseDeltaHeader(delta); + if (headerResult.isErr()) return headerResult; + + const { baseSize, resultSize, headerLength } = headerResult.value; + + if (base.length !== baseSize) { + return Result.err( + new DeltaError({ + code: "BASE_SIZE_MISMATCH", + message: `Base size mismatch: expected ${baseSize}, got ${base.length}`, + }) + ); + } + + const result = new Uint8Array(resultSize); + let resultOffset = 0; + let deltaOffset = headerLength; + + while (deltaOffset < delta.length) { + const opcode = delta[deltaOffset]; + deltaOffset += 1; + + if (opcode === 0) { + return Result.err( + new DeltaError({ + code: "INVALID_OPCODE", + message: `Invalid opcode 0 at offset ${deltaOffset - 1}`, + }) + ); + } + + if ((opcode & 0x80) !== 0) { + // COPY instruction: copy from base + // Bits 0-3: which offset bytes are present + // Bits 4-6: which size bytes are present + let copyOffset = 0; + let copySize = 0; + + // Read offset bytes (little-endian) + if ((opcode & 0x01) !== 0) { + copyOffset |= delta[deltaOffset] << 0; + deltaOffset += 1; + } + if ((opcode & 0x02) !== 0) { + copyOffset |= delta[deltaOffset] << 8; + deltaOffset += 1; + } + if ((opcode & 0x04) !== 0) { + copyOffset |= delta[deltaOffset] << 16; + deltaOffset += 1; + } + if ((opcode & 0x08) !== 0) { + copyOffset |= delta[deltaOffset] << 24; + deltaOffset += 1; + } + + // Read size bytes (little-endian) + if ((opcode & 0x10) !== 0) { + copySize |= delta[deltaOffset] << 0; + deltaOffset += 1; + } + if ((opcode & 0x20) !== 0) { + copySize |= delta[deltaOffset] << 8; + deltaOffset += 1; + } + if ((opcode & 0x40) !== 0) { + copySize |= delta[deltaOffset] << 16; + deltaOffset += 1; + } + + // Size of 0 means 0x10000 + if (copySize === 0) { + copySize = 0x1_00_00; + } + + // Validate bounds + if (copyOffset + copySize > base.length) { + return Result.err( + new DeltaError({ + code: "COPY_OUT_OF_BOUNDS", + message: `Copy out of bounds: offset=${copyOffset}, size=${copySize}, base.length=${base.length}`, + }) + ); + } + + // Copy from base to result + result.set( + base.subarray(copyOffset, copyOffset + copySize), + resultOffset + ); + resultOffset += copySize; + } else { + // INSERT instruction: copy literal bytes from delta + const insertSize = opcode; // opcode itself is the size (1-127) + + result.set( + delta.subarray(deltaOffset, deltaOffset + insertSize), + resultOffset + ); + deltaOffset += insertSize; + resultOffset += insertSize; + } + } + + // Validate result size + if (resultOffset !== resultSize) { + return Result.err( + new DeltaError({ + code: "RESULT_SIZE_MISMATCH", + message: `Result size mismatch: expected ${resultSize}, produced ${resultOffset}`, + }) + ); + } + + return Result.ok(result); +} + +export const Delta = { apply }; diff --git a/apps/web/src/git/errors.ts b/apps/web/src/git/errors.ts new file mode 100644 index 0000000..6d857a3 --- /dev/null +++ b/apps/web/src/git/errors.ts @@ -0,0 +1,15 @@ +import { TaggedError } from "better-result"; + +export class ProtocolError extends TaggedError("ProtocolError")<{ + code: + | "INVALID_PKT_LINE" + | "MALFORMED_COMMAND" + | "MISSING_FLUSH" + | "INVALID_PACKFILE"; + detail?: string; +}>() {} + +export class ReceivePackError extends TaggedError("ReceivePackError")<{ + code: "PARSE_FAILED" | "INDEX_FAILED" | "UPLOAD_FAILED" | "REF_UPDATE_FAILED"; + cause?: Error; +}>() {} diff --git a/apps/web/src/git/handlers/receive-pack.ts b/apps/web/src/git/handlers/receive-pack.ts new file mode 100644 index 0000000..bfb797a --- /dev/null +++ b/apps/web/src/git/handlers/receive-pack.ts @@ -0,0 +1,174 @@ +import { Result } from "better-result"; +import { Keys } from "@/do/keys"; +import type { PackIndexer } from "@/do/pack-indexer"; +import type { R2 } from "@/do/r2"; +import type { RefStore, RefUpdate } from "@/do/ref-store"; +import type { ReceivePackError } from "@/git/errors"; +import { PktLine } from "@/git/pkt"; +import { + encodeRefStatus, + encodeUnpackStatus, + type ReceivePackRequest, +} from "@/git/protocol/receive-pack"; +import { ProgressReporter, SidebandWriter } from "@/git/sideband-writer"; +import { concatUint8Arrays } from "@/lib/bytes"; + +export type ReceivePackContext = { + r2: R2; + refs: RefStore; + indexer: PackIndexer; + repoPath: string; +}; + +/** + * Handle a receive-pack request: index packfile, upload to R2, update refs. + * Returns a streaming response with progress and status. + */ +export function handleReceivePack( + ctx: ReceivePackContext, + request: ReceivePackRequest, + useSideband: boolean +): Result, ReceivePackError> { + const { commands, packfileStream } = request; + + const refUpdates: RefUpdate[] = commands.map((cmd) => ({ + name: cmd.ref, + oldOid: cmd.oldOid, + newOid: cmd.newOid, + })); + + const atomic = request.capabilities.includes("atomic"); + + if (!packfileStream) { + return Result.ok(createRefOnlyResponse(ctx.refs, refUpdates, atomic)); + } + + const { readable, writable } = new TransformStream(); + + processReceivePack({ + ctx, + writable, + packfileStream, + refUpdates, + atomic, + useSideband, + }); + + return Result.ok(readable); +} + +function createRefOnlyResponse( + refs: RefStore, + refUpdates: RefUpdate[], + atomic: boolean +): ReadableStream { + return new ReadableStream({ + async start(controller) { + const results = await refs.applyUpdates(refUpdates); + const lines: Uint8Array[] = []; + + const allOk = results.every((r) => r.ok); + if (atomic && !allOk) { + lines.push(encodeUnpackStatus(false, "atomic transaction failed")); + } else { + lines.push(encodeUnpackStatus(true)); + } + + for (const result of results) { + lines.push(encodeRefStatus(result.name, result.ok, result.error)); + } + + lines.push(PktLine.encodeFlush()); + controller.enqueue(PktLine.mergeLines(lines)); + controller.close(); + }, + }); +} + +type ProcessReceivePackOpts = { + ctx: ReceivePackContext; + writable: WritableStream; + packfileStream: ReadableStream; + refUpdates: RefUpdate[]; + atomic: boolean; + useSideband: boolean; +}; + +async function processReceivePack(opts: ProcessReceivePackOpts): Promise { + const { ctx, writable, packfileStream, refUpdates, atomic, useSideband } = + opts; + const writer = writable.getWriter(); + const sideband = new SidebandWriter(writer, useSideband); + const progress = new ProgressReporter(sideband); + + const packId = `pack-${Date.now()}.pack`; + const r2Key = Keys.pack(ctx.repoPath, packId); + + const packChunks: Uint8Array[] = []; + let packSize = 0; + + const collectingStream = new TransformStream({ + transform(chunk, controller) { + packChunks.push(chunk); + packSize += chunk.length; + controller.enqueue(chunk); + }, + }); + + const indexerStream = packfileStream.pipeThrough(collectingStream); + + const result = await Result.gen(async function* () { + yield* Result.await( + ctx.indexer.index(packId, r2Key, indexerStream, async (event) => { + await progress.report(event); + }) + ); + + const packData = concatUint8Arrays(packChunks, packSize); + yield* Result.await(ctx.r2.put(r2Key, packData)); + + return Result.ok(undefined); + }); + + if (result.isErr()) { + await writeErrorResponse(sideband, result.error.message, useSideband); + await writer.close(); + return; + } + + const refResults = await ctx.refs.applyUpdates(refUpdates); + const allOk = refResults.every((r) => r.ok); + + if (atomic && !allOk) { + await sideband.writeProgress("error: atomic transaction failed\n"); + } else { + await sideband.writeProgress( + `Processing changes: refs: ${refUpdates.length}, done.\n` + ); + } + + await sideband.writeData(encodeUnpackStatus(allOk)); + + for (const res of refResults) { + await sideband.writeData(encodeRefStatus(res.name, res.ok, res.error)); + } + + if (useSideband) { + await sideband.writeData(PktLine.encodeFlush()); + } + await sideband.flush(); + await writer.close(); +} + +async function writeErrorResponse( + sideband: SidebandWriter, + message: string, + useSideband: boolean +): Promise { + await sideband.writeProgress(`error: ${message}\n`); + await sideband.writeData(encodeUnpackStatus(false, message)); + if (useSideband) { + await sideband.writeData(PktLine.encodeFlush()); + } + await sideband.flush(); +} diff --git a/apps/web/src/git/object.ts b/apps/web/src/git/object.ts new file mode 100644 index 0000000..ec2b76f --- /dev/null +++ b/apps/web/src/git/object.ts @@ -0,0 +1,120 @@ +/** + * Git object hashing and framing. + * + * Git objects are stored with a header: "type size\0data" + * The SHA-1 hash is computed over the entire wrapped object. + * + * @see https://git-scm.com/book/en/v2/Git-Internals-Git-Objects + */ + +import { createHash } from "node:crypto"; +import { Result, TaggedError } from "better-result"; + +export type GitObjectType = "commit" | "tree" | "blob" | "tag"; + +export type ObjectErrorCode = + | "INVALID_HEADER" + | "INVALID_TYPE" + | "SIZE_MISMATCH"; + +export class ObjectError extends TaggedError("ObjectError")<{ + code: ObjectErrorCode; + message: string; +}>() {} + +const VALID_TYPES = new Set(["commit", "tree", "blob", "tag"]); +const TEXT_ENCODER = new TextEncoder(); +const TEXT_DECODER = new TextDecoder(); + +/** + * Compute SHA-1 OID for a git object. + * Hash is computed over: "type size\0" + data + */ +function computeOid(type: GitObjectType, data: Uint8Array): string { + const wrapped = wrap(type, data); + const hash = createHash("sha1"); + hash.update(wrapped); + return hash.digest("hex"); +} + +/** + * Wrap raw data with git object header. + * Returns: "type size\0" + data as Uint8Array + */ +function wrap(type: GitObjectType, data: Uint8Array): Uint8Array { + const header = TEXT_ENCODER.encode(`${type} ${data.length}\0`); + const result = new Uint8Array(header.length + data.length); + result.set(header, 0); + result.set(data, header.length); + return result; +} + +/** + * Unwrap a git object to extract type and data. + * Parses: "type size\0data" + */ +function unwrap( + wrapped: Uint8Array +): Result<{ type: GitObjectType; data: Uint8Array }, ObjectError> { + const nullIndex = wrapped.indexOf(0); + if (nullIndex === -1) { + return Result.err( + new ObjectError({ + code: "INVALID_HEADER", + message: "Missing null byte in object header", + }) + ); + } + + // Parse header: "type size" + const headerBytes = wrapped.subarray(0, nullIndex); + const header = TEXT_DECODER.decode(headerBytes); + const spaceIndex = header.indexOf(" "); + + if (spaceIndex === -1) { + return Result.err( + new ObjectError({ + code: "INVALID_HEADER", + message: "Missing space in object header", + }) + ); + } + + const typeStr = header.slice(0, spaceIndex); + const sizeStr = header.slice(spaceIndex + 1); + + if (!VALID_TYPES.has(typeStr)) { + return Result.err( + new ObjectError({ + code: "INVALID_TYPE", + message: `Invalid object type: "${typeStr}"`, + }) + ); + } + const type = typeStr as GitObjectType; + + const size = Number.parseInt(sizeStr, 10); + if (Number.isNaN(size) || size < 0) { + return Result.err( + new ObjectError({ + code: "INVALID_HEADER", + message: `Invalid size in object header: "${sizeStr}"`, + }) + ); + } + + const data = wrapped.subarray(nullIndex + 1); + + if (data.length !== size) { + return Result.err( + new ObjectError({ + code: "SIZE_MISMATCH", + message: `Size mismatch: header says ${size}, actual data is ${data.length}`, + }) + ); + } + + return Result.ok({ type, data }); +} + +export const GitObject = { computeOid, wrap, unwrap }; diff --git a/apps/web/src/git/protocol.ts b/apps/web/src/git/protocol.ts deleted file mode 100644 index c481415..0000000 --- a/apps/web/src/git/protocol.ts +++ /dev/null @@ -1,538 +0,0 @@ -import { getRepoDOStub } from "@/do/repo"; -import { PktLine } from "./pkt"; -import type { RefUpdateResult } from "./service"; - -export async function advertiseCapabilities( - service: "git-upload-pack" | "git-receive-pack", - fullRepoName: string -) { - if (service === "git-upload-pack") { - const lines = [ - PktLine.encode("version 2\n"), - PktLine.encode("agent=gitflare/0.0.1\n"), - - PktLine.encode("ls-refs\n"), - PktLine.encode("fetch\n"), - PktLine.encode("side-band-64k\n"), - PktLine.encode("object-format=sha1\n"), - PktLine.encodeFlush(), - ]; - - const response = PktLine.decodeText(PktLine.mergeLines(lines)); - return new Response(response, { - status: 200, - headers: { - "Content-Type": "application/x-git-upload-pack-advertisement", - "Cache-Control": "no-cache", - }, - }); - } - - // TODO: Upgrade to use v2 protocol (Keeping this on old protocol for now because of time constraints, I have exams to study for T_T) - if (service === "git-receive-pack") { - const capabilities = [ - "report-status", - "delete-refs", - "atomic", - "no-thin", - "agent=gitflare/0.0.1", - ]; - - const stub = getRepoDOStub(fullRepoName); - const { refs, symbolicHead } = await stub.listRefs(); - - if (symbolicHead) { - capabilities.push(`symref=HEAD:${symbolicHead}`); - } - - const capabilitiesStr = capabilities.join(" "); - - const lines = [ - PktLine.encode("# service=git-receive-pack\n"), - PktLine.encodeFlush(), - ]; - - if (refs.length > 0) { - const first = refs[0]; - lines.push( - PktLine.encode(`${first.oid} ${first.ref}\0${capabilitiesStr}\n`) - ); - for (let i = 1; i < refs.length; i += 1) { - lines.push(PktLine.encode(`${refs[i].oid} ${refs[i].ref}\n`)); - } - } else { - // Empty repository - advertise capabilities with zero OID - const zeroOid = "0".repeat(40); - lines.push( - PktLine.encode(`${zeroOid} capabilities^{}\0${capabilitiesStr}\n`) - ); - } - - lines.push(PktLine.encodeFlush()); - - const response = PktLine.decodeText(PktLine.mergeLines(lines)); - return new Response(response, { - status: 200, - headers: { - "Content-Type": "application/x-git-receive-pack-advertisement", - "Cache-Control": "no-cache", - }, - }); - } -} - -export type Command = { - oldOid: string; - newOid: string; - ref: string; -}; - -export function parseReceivePackRequest(data: Uint8Array) { - const commands: Command[] = []; - let capabilities: string[] = []; - - let offset = 0; - while (offset < data.length) { - const packet = PktLine.decode(data.subarray(offset)); - - const lengthHex = PktLine.decodeText(data.slice(offset, offset + 4)); - const specialPackets = [PktLine.DELIM, PktLine.FLUSH, PktLine.RESPONSE_END]; - const packetLength = specialPackets.includes(lengthHex) - ? 4 - : Number.parseInt(lengthHex, 16); - - offset += packetLength; - - if (packet.type === "flush") { - // End of commands, packfile follows - break; - } - - if (packet.type === "data") { - const line = PktLine.decodeText(packet.data).trim(); - - // Parse: \0 - const nullIdx = line.indexOf("\0"); - const refLine = nullIdx >= 0 ? line.substring(0, nullIdx) : line; - const caps = nullIdx >= 0 ? line.substring(nullIdx + 1).split(" ") : []; - - const parts = refLine.split(" "); - if (parts.length >= 3) { - commands.push({ - oldOid: parts[0], - newOid: parts[1], - ref: parts[2], - }); - } - - if (caps.length > 0 && capabilities.length === 0) { - capabilities = caps; - } - } - } - - const packfile = data.subarray(offset); - - return { commands, capabilities, packfile }; -} - -export async function buildReportStatus( - results: RefUpdateResult[], - unpackOk: boolean -) { - const lines: Uint8Array[] = []; - - if (unpackOk) { - lines.push(PktLine.encode("unpack ok\n")); - } else { - const error = results.find((r) => r.ref === "*")?.error ?? "unknown error"; - lines.push(PktLine.encode(`unpack ${error}\n`)); - } - - for (const result of results) { - if (result.ref === "*") continue; // Skip unpack status - - if (result.ok) { - lines.push(PktLine.encode(`ok ${result.ref}\n`)); - } else { - lines.push(PktLine.encode(`ng ${result.ref} ${result.error}\n`)); - } - } - - lines.push(PktLine.encodeFlush()); - - return new Response(PktLine.decodeText(PktLine.mergeLines(lines)), { - status: 200, - headers: { - "Content-Type": "application/x-git-receive-pack-result", - "Cache-Control": "no-cache", - }, - }); -} - -export function parseCommand(data: Uint8Array) { - let command = ""; - const args: string[] = []; - let beforeDelim = true; - - let offset = 0; - while (offset < data.length) { - const packet = PktLine.decode(data.subarray(offset)); - - const lengthHex = PktLine.decodeText(data.slice(offset, offset + 4)); - const specialPackets = [PktLine.DELIM, PktLine.FLUSH, PktLine.RESPONSE_END]; - const packetLength = specialPackets.includes(lengthHex) - ? 4 - : Number.parseInt(lengthHex, 16); - - offset += packetLength; - - if (packet.type === "delim") { - beforeDelim = false; - continue; - } - - if (packet.type === "flush" || packet.type === "response-end") { - break; - } - - if (packet.type === "data") { - const line = PktLine.decodeText(packet.data).replace(/\r?\n$/, ""); - - if (beforeDelim) { - if (line.startsWith("command=")) { - command = line.replace("command=", ""); - } - } else { - args.push(line); - } - } - } - - // Fallback: try to extract command from raw text if not found - if (!command) { - const text = PktLine.decodeText(data); - const match = text.match(/command=([a-z-]+)/); - command = match ? match[1] : ""; - } - - return { command, args }; -} - -export type FetchRequest = { - wants: string[]; - haves: string[]; - done: boolean; - capabilities: { - thinPack: boolean; - noProgress: boolean; - includeTag: boolean; - ofsDelta: boolean; - sidebandAll: boolean; - }; - shallowOptions?: { - shallow: string[]; - deepen?: number; - deepenRelative?: boolean; - deepenSince?: number; - deepenNot?: string[]; - }; - filterSpec?: string; -}; - -export function parseFetchRequest( - _data: Uint8Array, - args: string[] -): FetchRequest { - const wants: string[] = []; - const haves: string[] = []; - let done = false; - const capabilities = { - thinPack: false, - noProgress: false, - includeTag: false, - ofsDelta: false, - sidebandAll: false, - }; - const shallow: string[] = []; - let deepen: number | undefined; - let deepenRelative = false; - let deepenSince: number | undefined; - const deepenNot: string[] = []; - let filterSpec: string | undefined; - - // Parse arguments from the command section - for (const arg of args) { - if (arg.startsWith("want ")) { - wants.push(arg.slice("want ".length)); - } else if (arg.startsWith("have ")) { - haves.push(arg.slice("have ".length)); - } else if (arg === "done") { - done = true; - } else if (arg === "thin-pack") { - capabilities.thinPack = true; - } else if (arg === "no-progress") { - capabilities.noProgress = true; - } else if (arg === "include-tag") { - capabilities.includeTag = true; - } else if (arg === "ofs-delta") { - capabilities.ofsDelta = true; - } else if (arg === "sideband-all") { - capabilities.sidebandAll = true; - } else if (arg.startsWith("shallow ")) { - shallow.push(arg.slice("shallow ".length)); - } else if (arg.startsWith("deepen ")) { - deepen = Number.parseInt(arg.slice("deepen ".length), 10); - } else if (arg === "deepen-relative") { - deepenRelative = true; - } else if (arg.startsWith("deepen-since ")) { - deepenSince = Number.parseInt(arg.slice("deepen-since ".length), 10); - } else if (arg.startsWith("deepen-not ")) { - deepenNot.push(arg.slice("deepen-not ".length)); - } else if (arg.startsWith("filter ")) { - filterSpec = arg.slice("filter ".length); - } - } - - const shallowOptions = - shallow.length > 0 || - deepen || - deepenRelative || - deepenSince || - deepenNot.length > 0 - ? { - shallow, - deepen, - deepenRelative, - deepenSince, - deepenNot, - } - : undefined; - - return { - wants, - haves, - done, - capabilities, - shallowOptions, - filterSpec, - }; -} - -export async function buildLsRefsResponse( - refs: Array<{ ref: string; oid: string }>, - args: string[], - symbolicHead: string | null, - readObject: ( - oid: string - ) => Promise<{ type: string; object: Uint8Array | string } | null> -) { - const lines: Uint8Array[] = []; - - // Parse arguments - const refPrefixes: string[] = []; - let includePeel = false; - let includeSymrefs = false; - - for (const arg of args) { - if (arg === "peel") { - includePeel = true; - } else if (arg === "symrefs") { - includeSymrefs = true; - } else if (arg.startsWith("ref-prefix ")) { - refPrefixes.push(arg.slice("ref-prefix ".length)); - } - } - - // Filter refs by prefix if specified - let filteredRefs = refs; - if (refPrefixes.length > 0) { - filteredRefs = refs.filter((ref) => - refPrefixes.some((prefix) => ref.ref.startsWith(prefix)) - ); - } - - for (const { ref, oid } of filteredRefs) { - let line = `${oid} ${ref}`; - - // Add symref attribute if requested and this is HEAD - if (includeSymrefs && ref === "HEAD" && symbolicHead) { - line += ` symref-target:${symbolicHead}`; - } - - lines.push(PktLine.encode(`${line}\n`)); - - // Add peeled reference for annotated tags if requested - if (includePeel && ref.startsWith("refs/tags/")) { - const obj = await readObject(oid); - if (obj && obj.type === "tag") { - // Parse the tag object to get the target commit - const tagContent = - typeof obj.object === "string" - ? obj.object - : new TextDecoder().decode(obj.object); - const objectMatch = tagContent.match(/^object ([0-9a-f]{40})/m); - if (objectMatch) { - const peeledOid = objectMatch[1]; - lines.push(PktLine.encode(`${peeledOid} ${ref}^{}\n`)); - } - } - } - } - - lines.push(PktLine.encodeFlush()); - - // @ts-expect-error ts is complaining that Uint8Array is not assignable to BodyInit - return new Response(PktLine.mergeLines(lines), { - status: 200, - headers: { - "Content-Type": "application/x-git-upload-pack-result", - "Cache-Control": "no-cache", - }, - }); -} - -/** - * Parse packfile header to extract object count. - * Packfile format: 'PACK' + version (4 bytes) + object count (4 bytes) - * - * @param packfile - The packfile data - * @returns Object count, or null if header is invalid - */ -function parsePackfileObjectCount(packfile: Uint8Array): number | null { - // Check minimum size: 'PACK' (4) + version (4) + count (4) = 12 bytes - if (packfile.length < 12) { - return null; - } - - // Verify 'PACK' signature - const signature = new TextDecoder().decode(packfile.slice(0, 4)); - if (signature !== "PACK") { - return null; - } - - // Read object count (big-endian 32-bit integer at offset 8) - // Using DataView for cleaner binary parsing - const view = new DataView( - packfile.buffer, - packfile.byteOffset, - packfile.byteLength - ); - const count = view.getUint32(8, false); // false = big-endian - - return count; -} - -export type FetchResponseOptions = { - commonCommits: string[]; - packfileData: Uint8Array | null | undefined; - noProgress: boolean; - done: boolean; - objectCount?: number; -}; - -export async function buildFetchResponse(options: FetchResponseOptions) { - const lines: Uint8Array[] = []; - const { commonCommits, packfileData, noProgress, done } = options; - - // Protocol v2 spec: If client sent "done", acknowledgments section MUST be omitted - if (!done) { - // Acknowledgments section (only sent during negotiation, not when done=true) - lines.push(PktLine.encode("acknowledgments\n")); - - if (commonCommits.length === 0) { - lines.push(PktLine.encode("NAK\n")); - } else { - for (const oid of commonCommits) { - lines.push(PktLine.encode(`ACK ${oid}\n`)); - } - } - - // Send "ready" to indicate server is ready to send packfile - lines.push(PktLine.encode("ready\n")); - - // Delimiter separates acknowledgments section from packfile section - lines.push(PktLine.encodeDelim()); - } - - // Packfile section - if (packfileData && packfileData.length > 0) { - // Packfile section header - required by protocol v2 - lines.push(PktLine.encode("packfile\n")); - - // Parse object count from packfile header - const objectCount = - options.objectCount ?? parsePackfileObjectCount(packfileData); - - // Send progress messages if not suppressed - if (!noProgress && objectCount !== null) { - lines.push( - PktLine.encodeProgress( - `remote: Counting objects: ${objectCount}, done.\r\n` - ) - ); - lines.push( - PktLine.encodeProgress( - `remote: Compressing objects: 100% (${objectCount}/${objectCount}), done.\r\n` - ) - ); - } - - // Split packfile into chunks and multiplex with side-band - // Side-band format: pkt-line(stream-code + data) - // Stream code: 1 = pack data, 2 = progress, 3 = error - for ( - let offset = 0; - offset < packfileData.length; - offset += PktLine.MAX_SIDEBAND_PAYLOAD - ) { - const end = Math.min( - offset + PktLine.MAX_SIDEBAND_PAYLOAD, - packfileData.length - ); - const chunk = packfileData.subarray(offset, end); - - lines.push( - PktLine.encodeSideband(PktLine.SIDEBAND_CHANNEL_PACKFILE, chunk) - ); - } - - // Send final progress message if not suppressed - if (!noProgress && objectCount !== null) { - lines.push( - PktLine.encodeProgress( - `remote: Total ${objectCount} (delta 0), reused ${objectCount} (delta 0), pack-reused 0 \r\n` - ) - ); - } - - lines.push(PktLine.encodeFlush()); - } - - // @ts-expect-error ts is complaining that Uint8Array is not assignable to BodyInit - return new Response(PktLine.mergeLines(lines), { - status: 200, - headers: { - "Content-Type": "application/x-git-upload-pack-result", - "Cache-Control": "no-cache", - }, - }); -} - -export function getBasicCredentials( - req: Request -): { username: string; password: string } | null { - const header = req.headers.get("Authorization") || ""; - const match = /^Basic\s+(.+)$/i.exec(header); - if (!match) return null; - try { - const decoded = atob(match[1]); - const idx = decoded.indexOf(":"); - if (idx === -1) return { username: decoded, password: "" }; - const username = decoded.slice(0, idx); - const password = decoded.slice(idx + 1); - return { username, password }; - } catch { - return null; - } -} diff --git a/apps/web/src/git/protocol/capabilities.ts b/apps/web/src/git/protocol/capabilities.ts new file mode 100644 index 0000000..2f26e62 --- /dev/null +++ b/apps/web/src/git/protocol/capabilities.ts @@ -0,0 +1,57 @@ +import { PktLine } from "@/git/pkt"; + +export type Ref = { + name: string; + oid: string; +}; + +const RECEIVE_PACK_CAPABILITIES = [ + "report-status", + "delete-refs", + "atomic", + "side-band-64k", + "agent=gitflare/0.0.1", +]; + +const ZERO_OID = "0".repeat(40); + +/** + * Build capability advertisement for receive-pack service. + * Used in response to /info/refs?service=git-receive-pack. + */ +export function advertiseReceivePackCapabilities( + refs: Ref[], + symbolicHead: string | null +): Uint8Array { + const capabilities = [...RECEIVE_PACK_CAPABILITIES]; + + if (symbolicHead) { + capabilities.push(`symref=HEAD:${symbolicHead}`); + } + + const capabilitiesStr = capabilities.join(" "); + + const lines: Uint8Array[] = [ + PktLine.encode("# service=git-receive-pack\n"), + PktLine.encodeFlush(), + ]; + + if (refs.length > 0) { + const first = refs[0]; + lines.push( + PktLine.encode(`${first.oid} ${first.name}\0${capabilitiesStr}\n`) + ); + + for (let i = 1; i < refs.length; i += 1) { + lines.push(PktLine.encode(`${refs[i].oid} ${refs[i].name}\n`)); + } + } else { + lines.push( + PktLine.encode(`${ZERO_OID} capabilities^{}\0${capabilitiesStr}\n`) + ); + } + + lines.push(PktLine.encodeFlush()); + + return PktLine.mergeLines(lines); +} diff --git a/apps/web/src/git/protocol/receive-pack.ts b/apps/web/src/git/protocol/receive-pack.ts new file mode 100644 index 0000000..666184c --- /dev/null +++ b/apps/web/src/git/protocol/receive-pack.ts @@ -0,0 +1,247 @@ +import { Result } from "better-result"; +import { ProtocolError } from "@/git/errors"; +import { PktLine } from "@/git/pkt"; +import { concatUint8Arrays } from "@/lib/bytes"; + +export type Command = { + oldOid: string; + newOid: string; + ref: string; +}; + +export type ReceivePackRequest = { + commands: Command[]; + capabilities: string[]; + packfileStream: ReadableStream | null; +}; + +const ZERO_OID = "0000000000000000000000000000000000000000"; + +export async function parseReceivePackRequest( + stream: ReadableStream +): Promise> { + const reader = stream.getReader(); + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + let flushEndOffset = -1; + + while (flushEndOffset === -1) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + chunks.push(value); + totalBytes += value.length; + + flushEndOffset = findFlushOffset(chunks, totalBytes); + } + + const allData = concatUint8Arrays(chunks, totalBytes); + + if (flushEndOffset === -1) { + const parseResult = parseCommandsFromBuffer(allData); + if (parseResult.isErr()) { + reader.releaseLock(); + return Result.err(parseResult.error); + } + reader.releaseLock(); + return Result.ok({ + commands: parseResult.value.commands, + capabilities: parseResult.value.capabilities, + packfileStream: null, + }); + } + + const commandData = allData.subarray(0, flushEndOffset); + const remainder = allData.subarray(flushEndOffset); + + const parseResult = parseCommandsFromBuffer(commandData); + if (parseResult.isErr()) { + reader.releaseLock(); + return Result.err(parseResult.error); + } + + const { commands, capabilities } = parseResult.value; + const hasNonDeleteCommand = commands.some((cmd) => cmd.newOid !== ZERO_OID); + + if (!hasNonDeleteCommand && remainder.length === 0) { + reader.releaseLock(); + return Result.ok({ commands, capabilities, packfileStream: null }); + } + + const packfileStream = createPackfileStream(reader, remainder); + return Result.ok({ commands, capabilities, packfileStream }); +} + +function findFlushOffset(chunks: Uint8Array[], totalLength: number): number { + const data = concatUint8Arrays(chunks, totalLength); + let offset = 0; + + while (offset + 4 <= data.length) { + const lengthHex = String.fromCharCode( + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3] + ); + + if (lengthHex === PktLine.FLUSH) { + return offset + 4; + } + + if (lengthHex === PktLine.DELIM || lengthHex === PktLine.RESPONSE_END) { + offset += 4; + continue; + } + + const packetLen = Number.parseInt(lengthHex, 16); + if (Number.isNaN(packetLen) || packetLen < 4) { + offset += 4; + continue; + } + + if (offset + packetLen > data.length) { + return -1; + } + + offset += packetLen; + } + + return -1; +} + +type ParsedCommands = { + commands: Command[]; + capabilities: string[]; +}; + +function parseCommandsFromBuffer( + data: Uint8Array +): Result { + const commands: Command[] = []; + let capabilities: string[] = []; + + let offset = 0; + while (offset + 4 <= data.length) { + const lengthHex = String.fromCharCode( + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3] + ); + + if (lengthHex === PktLine.FLUSH) { + break; + } + + if (lengthHex === PktLine.DELIM || lengthHex === PktLine.RESPONSE_END) { + offset += 4; + continue; + } + + const packetLen = Number.parseInt(lengthHex, 16); + if (Number.isNaN(packetLen)) { + return Result.err( + new ProtocolError({ + code: "INVALID_PKT_LINE", + detail: `invalid hex length: ${lengthHex}`, + }) + ); + } + + if (packetLen < 4) { + return Result.err( + new ProtocolError({ + code: "INVALID_PKT_LINE", + detail: `packet length too small: ${packetLen}`, + }) + ); + } + + if (offset + packetLen > data.length) { + break; + } + + const payload = data.subarray(offset + 4, offset + packetLen); + offset += packetLen; + + const line = PktLine.decodeText(payload).trim(); + + const nullIdx = line.indexOf("\0"); + const refLine = nullIdx >= 0 ? line.substring(0, nullIdx) : line; + const caps = nullIdx >= 0 ? line.substring(nullIdx + 1).split(" ") : []; + + const parts = refLine.split(" "); + if (parts.length < 3) { + return Result.err( + new ProtocolError({ + code: "MALFORMED_COMMAND", + detail: `expected 3 parts, got: ${parts.length}`, + }) + ); + } + + commands.push({ + oldOid: parts[0], + newOid: parts[1], + ref: parts[2], + }); + + if (caps.length > 0 && capabilities.length === 0) { + capabilities = caps; + } + } + + return Result.ok({ commands, capabilities }); +} + +function createPackfileStream( + reader: ReadableStreamDefaultReader, + initialChunk: Uint8Array +): ReadableStream { + let initialChunkSent = initialChunk.length === 0; + + return new ReadableStream({ + async pull(controller) { + if (!initialChunkSent) { + controller.enqueue(initialChunk); + initialChunkSent = true; + return; + } + + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + controller.enqueue(value); + }, + cancel() { + reader.cancel(); + }, + }); +} + +export function encodeUnpackStatus( + success: boolean, + error?: string +): Uint8Array { + if (success) { + return PktLine.encode("unpack ok\n"); + } + return PktLine.encode(`unpack ${error ?? "unknown error"}\n`); +} + +export function encodeRefStatus( + ref: string, + success: boolean, + error?: string +): Uint8Array { + if (success) { + return PktLine.encode(`ok ${ref}\n`); + } + return PktLine.encode(`ng ${ref} ${error ?? "failed"}\n`); +} diff --git a/apps/web/src/git/response.ts b/apps/web/src/git/response.ts new file mode 100644 index 0000000..57b7bb9 --- /dev/null +++ b/apps/web/src/git/response.ts @@ -0,0 +1,50 @@ +import { PktLine } from "./pkt"; + +export type GitService = "upload-pack" | "receive-pack"; + +const CONTENT_TYPES = { + advertisement: (service: GitService) => + `application/x-git-${service}-advertisement`, + result: (service: GitService) => `application/x-git-${service}-result`, +} as const; + +export function gitResponse( + body: Uint8Array, + service: GitService, + type: "advertisement" | "result" +) { + return new Response(body as unknown as BodyInit, { + status: 200, + headers: { + "Content-Type": CONTENT_TYPES[type](service), + "Cache-Control": "no-cache", + }, + }); +} + +export function gitStreamResponse( + body: ReadableStream, + service: GitService +) { + return new Response(body, { + status: 200, + headers: { + "Content-Type": CONTENT_TYPES.result(service), + "Cache-Control": "no-cache", + }, + }); +} +export function gitErrorResponse( + message: string, + service: GitService +): Response { + const body = PktLine.encode(`ERR ${message}\n`); + + return new Response(body as unknown as BodyInit, { + status: 200, // Git protocol errors use 200 with ERR pkt-line + headers: { + "Content-Type": CONTENT_TYPES.result(service), + "Cache-Control": "no-cache", + }, + }); +} diff --git a/apps/web/src/git/service.ts b/apps/web/src/git/service.ts deleted file mode 100644 index e91344f..0000000 --- a/apps/web/src/git/service.ts +++ /dev/null @@ -1,739 +0,0 @@ -import * as git from "isomorphic-git"; -import type { IsoGitFs } from "@/do/fs"; -import { createLogger } from "@/do/logger"; - -const logger = createLogger("GitService"); - -export type RefUpdateResult = { - ref: string; - ok: boolean; - error?: string; -}; - -export class GitService { - private readonly fs: ReturnType; - private readonly gitdir: string; - - private readonly cache: object = {}; - - constructor(fs: ReturnType, gitdir: string) { - this.fs = fs; - this.gitdir = gitdir; - } - - async initRepo() { - await git.init({ - fs: this.fs, - dir: this.gitdir, - bare: true, - defaultBranch: "main", - }); - } - - async listRefs() { - let symbolicHead: string | null = null; - const refs: Array<{ ref: string; oid: string }> = []; - - try { - const headContent = await this.fs.promises.readFile("/repo/HEAD", { - encoding: "utf-8", - }); - const headStr = - typeof headContent === "string" - ? headContent - : new TextDecoder().decode(headContent); - const match = headStr.trim().match(/^ref:\s*(.+)$/); - if (match) { - symbolicHead = match[1]; // e.g., "refs/heads/main" - } - } catch { - logger.warn("(read-head-file) No HEAD found in repository."); - } - - try { - const headOid = await git.resolveRef({ - fs: this.fs, - gitdir: this.gitdir, - ref: "HEAD", - }); - refs.push({ ref: "HEAD", oid: headOid }); - } catch { - logger.warn("(resolve-head-ref) No HEAD ref found in repository."); - } - - const [branches, tags] = await Promise.all([ - this.listBranchesWithOid(), - this.listTags(), - ]); - - refs.push(...branches, ...tags); - - return { refs, symbolicHead }; - } - - async listBranchesWithOid(): Promise> { - try { - const branchRefs = await git.listBranches({ - fs: this.fs, - gitdir: this.gitdir, - }); - const branches = await Promise.all( - branchRefs.map(async (branch) => { - const oid = await git.resolveRef({ - fs: this.fs, - gitdir: this.gitdir, - ref: `refs/heads/${branch}`, - }); - return { ref: `refs/heads/${branch}`, oid }; - }) - ); - return branches; - } catch { - return []; - } - } - - async listBranches() { - try { - const branchRefs = await git.listBranches({ - fs: this.fs, - gitdir: this.gitdir, - }); - return branchRefs; - } catch (error) { - logger.warn("(list-branches) Failed to list branches: ", error); - return []; - } - } - - async currentBranch() { - try { - const branch = await git.currentBranch({ - fs: this.fs, - gitdir: this.gitdir, - fullname: false, - }); - return branch ?? null; - } catch (error) { - logger.warn("(current-branch) Failed to get current branch: ", error); - return null; - } - } - - async listTags(): Promise> { - try { - const tagRefs = await git.listTags({ - fs: this.fs, - gitdir: this.gitdir, - }); - const tags = await Promise.all( - tagRefs.map(async (tag) => { - const oid = await git.resolveRef({ - fs: this.fs, - gitdir: this.gitdir, - ref: `refs/tags/${tag}`, - }); - return { ref: `refs/tags/${tag}`, oid }; - }) - ); - return tags; - } catch { - return []; - } - } - - async readObject(oid: string) { - try { - return await git.readObject({ - fs: this.fs, - gitdir: this.gitdir, - oid, - cache: this.cache, - }); - } catch (error) { - logger.warn(`(read-object) Failed to read object ${oid}: ${error}`); - return null; - } - } - - async readObjectForLsRefs(oid: string) { - try { - const result = await git.readObject({ - fs: this.fs, - gitdir: this.gitdir, - oid, - format: "content", - cache: this.cache, - }); - - // Ensure we return string or Uint8Array - const object = result.object; - if (typeof object === "string" || object instanceof Uint8Array) { - return { - type: result.type, - object, - }; - } - - // If it's a parsed object, we need to serialize it - // This shouldn't happen with format: "content", but handle it just in case - return { - type: result.type, - object: new TextEncoder().encode(JSON.stringify(object)), - }; - } catch (error) { - logger.warn( - `(read-object-ls-refs) Failed to read object ${oid}: ${error}` - ); - return null; - } - } - - async expandRef(ref: string) { - try { - return await git.expandRef({ - fs: this.fs, - gitdir: this.gitdir, - ref, - }); - } catch { - return null; - } - } - - async indexPack(filePath: string) { - await git.indexPack({ - fs: this.fs, - dir: this.gitdir, - gitdir: this.gitdir, - filepath: filePath, - cache: this.cache, - }); - } - - async collectObjectsForPack( - wants: string[], - haves: string[] - ): Promise { - const objectsToSend = new Set(); - const visited = new Set(); - const haveSet = new Set(haves); - - // BFS queue to traverse the commit graph - const queue: string[] = [...wants]; - - while (queue.length > 0) { - const oid = queue.shift(); - if (!oid || visited.has(oid)) continue; - - visited.add(oid); - - // If the client already has this object, don't include it or traverse further - if (haveSet.has(oid)) continue; - - // Add this object to the set of objects to send - objectsToSend.add(oid); - - try { - const { type } = await git.readObject({ - fs: this.fs, - gitdir: this.gitdir, - oid, - cache: this.cache, - }); - - if (type === "commit") { - // Parse commit to get tree and parent OIDs - const commit = await git.readCommit({ - fs: this.fs, - gitdir: this.gitdir, - oid, - cache: this.cache, - }); - - // Add tree to queue - queue.push(commit.commit.tree); - - // Add parent commits to queue - for (const parent of commit.commit.parent) { - queue.push(parent); - } - } else if (type === "tree") { - // Parse tree to get all entries (blobs and subtrees) - const tree = await git.readTree({ - fs: this.fs, - gitdir: this.gitdir, - oid, - cache: this.cache, - }); - - // Add all tree entries to queue - for (const entry of tree.tree) { - queue.push(entry.oid); - } - } else if (type === "tag") { - // Parse tag to get the object it points to - const tag = await git.readTag({ - fs: this.fs, - gitdir: this.gitdir, - oid, - cache: this.cache, - }); - - queue.push(tag.tag.object); - } - // For blobs, we just add them to the set (no traversal needed) - } catch (error) { - logger.error( - `(collect-objects) Failed to read object ${oid}: ${error}` - ); - // Continue processing other objects even if one fails - } - } - - return Array.from(objectsToSend); - } - - async packObjects(oids: string[]) { - const result = await git.packObjects({ - fs: this.fs, - dir: this.gitdir, - gitdir: this.gitdir, - oids, - write: false, - cache: this.cache, - }); - - return result.packfile; - } - - async hasObject(oid: string): Promise { - try { - await git.readObject({ - fs: this.fs, - gitdir: this.gitdir, - oid, - cache: this.cache, - }); - return true; - } catch { - return false; - } - } - - async findCommonCommits(haves: string[]): Promise { - const common: string[] = []; - - for (const oid of haves) { - const hasObject = await this.hasObject(oid); - if (hasObject) { - common.push(oid); - } - } - - return common; - } - - async getLastCommit( - branch: string - ): Promise { - try { - const [commit] = await git.log({ - fs: this.fs, - gitdir: this.gitdir, - ref: branch, - depth: 1, - cache: this.cache, - }); - - return commit ?? undefined; - } catch (error) { - logger.warn( - `(get-last-commit) Failed to get last commit for branch ${branch}: ${error}` - ); - return undefined; - } - } - - async getLog({ - ref, - depth, - filepath, - }: { - ref?: string; - depth?: number; - filepath?: string; - }) { - try { - const commits = await git.log({ - fs: this.fs, - gitdir: this.gitdir, - cache: this.cache, - ref, - depth, - filepath, - }); - - return commits; - } catch (error) { - logger.warn(`(get-log) Failed to get log for ref ${ref}: ${error}`); - return []; - } - } - - async resolveRef(ref = "HEAD") { - try { - const oid = await git.resolveRef({ - fs: this.fs, - gitdir: this.gitdir, - ref, - }); - return oid; - } catch (error) { - logger.warn(`(resolve-ref) Failed to resolve ref ${ref}: ${error}`); - return null; - } - } - - async getTree(resolvedRef: string, path = "") { - try { - const { tree } = await git.readTree({ - fs: this.fs, - gitdir: this.gitdir, - oid: resolvedRef, - filepath: path, - cache: this.cache, - }); - - return tree; - } catch (error) { - logger.error( - `(get-tree) Failed to get tree for ${resolvedRef}:${path}: ${error}` - ); - return []; - } - } - - async getBlob(resolvedRef: string, filepath: string) { - try { - const { blob, oid } = await git.readBlob({ - fs: this.fs, - gitdir: this.gitdir, - oid: resolvedRef, - filepath, - cache: this.cache, - }); - const isBinary = this.detectBinary(blob); - - return { - oid, - content: blob, - size: blob.length, - isBinary, - }; - } catch (error) { - logger.error( - `(get-blob) Failed to get blob for ${resolvedRef}:${filepath}: ${error}` - ); - return null; - } - } - - getBlobSize(content: Uint8Array): number { - return content.length; - } - - detectBinary(content: Uint8Array): boolean { - // Check first 8000 bytes for null bytes (common binary file indicator) - const bytesToCheck = Math.min(8000, content.length); - for (let i = 0; i < bytesToCheck; i += 1) { - if (content[i] === 0) { - return true; - } - } - return false; - } - - async getFileStateChanges( - oldCommit: string | undefined, - newCommit: string | undefined - ) { - type File = - | { - isBinary: false; - content: string; - } - | { - isBinary: true; - content: null; - }; - - type Change = { - type: "add" | "modify" | "remove"; - path: string; - old: File | null; - new: File | null; - }; - - // Use git.walk to compare the trees of the two commits - const data = await git.walk({ - fs: this.fs, - gitdir: this.gitdir, - trees: [git.TREE({ ref: oldCommit }), git.TREE({ ref: newCommit })], - map: async (filepath, [A, B]): Promise => { - // ignore directories - if (filepath === ".") { - return; - } - - // A and B are "TreeEntry" objects. - // We need to check their type (blob or tree) - const Atype = A ? await A.type() : null; - const Btype = B ? await B.type() : null; - - if (Atype === "tree" || Btype === "tree") { - return; - } - - // generate oids (object IDs) - const Aoid = A ? await A.oid() : null; - const Boid = B ? await B.oid() : null; - - // determine modification type - let type: "equal" | "modify" | "add" | "remove" = "equal"; - if (Aoid !== Boid) { - type = "modify"; - } - if (Aoid === null && Boid !== null) { - type = "add"; - } - if (Boid === null && Aoid !== null) { - type = "remove"; - } - if (Aoid === null && Boid === null) { - logger.warn( - `(get-file-state-changes): Both A and B are null for path ${filepath}` - ); - return; // Should not happen - } - - // Don't return 'equal' files - if (type === "equal") { - return; - } - - const oldContent = await A?.content(); - const newContent = await B?.content(); - - const isOldBinary = oldContent && this.detectBinary(oldContent); - const isNewBinary = newContent && this.detectBinary(newContent); - - let oldFile: File | null = null; - - if (isOldBinary) { - oldFile = { isBinary: true, content: null }; - } else if (oldContent) { - oldFile = { - isBinary: false, - content: new TextDecoder().decode(oldContent), - }; - } - - let newFile: File | null = null; - - if (isNewBinary) { - newFile = { isBinary: true, content: null }; - } else if (newContent) { - newFile = { - isBinary: false, - content: new TextDecoder().decode(newContent), - }; - } - - return { - type, - path: filepath, - old: oldFile, - new: newFile, - }; - }, - }); - return data as Change[]; - } - - async getCommit(commitOid: string) { - try { - const commit = await git.readCommit({ - fs: this.fs, - gitdir: this.gitdir, - cache: this.cache, - oid: commitOid, - }); - if (!commit.commit.parent || commit.commit.parent.length === 0) { - logger.info( - `(get-commit): Commit ${commitOid} is a root commit. Comparing with empty tree.` - ); - return { - commit, - changes: await this.getFileStateChanges(undefined, commitOid), - }; - } - - const parentOid = commit.commit.parent[0]; - return { - commit, - changes: await this.getFileStateChanges(parentOid, commitOid), - }; - } catch (error) { - logger.error( - `(get-commit) Failed to get commit changes for ${commitOid}: ${error}` - ); - return { - commit: null, - changes: [], - }; - } - } - - // TODO: simplify this and some docs - async applyRefUpdates( - commands: Array<{ oldOid: string; newOid: string; ref: string }>, - atomic: boolean - ): Promise { - const results: RefUpdateResult[] = []; - const ZERO_OID = "0".repeat(40); - - // Validate all commands first - for (const cmd of commands) { - const isDelete = cmd.newOid === ZERO_OID; - const isCreate = cmd.oldOid === ZERO_OID; - - try { - let currentOid: string | null = null; - try { - currentOid = await git.resolveRef({ - fs: this.fs, - gitdir: "/repo", - ref: cmd.ref, - }); - } catch { - logger.info(`(apply-ref-updates): Ref ${cmd.ref} does not exist.`); - } - - // Validate old OID matches current - if ( - currentOid && - cmd.oldOid !== ZERO_OID && - currentOid !== cmd.oldOid - ) { - results.push({ - ref: cmd.ref, - ok: false, - error: "ref update rejected: old OID mismatch", - }); - continue; - } - - if (isDelete) { - // Delete ref - if (currentOid) { - results.push({ ref: cmd.ref, ok: true }); - } else { - results.push({ - ref: cmd.ref, - ok: false, - error: "ref doesn't exist", - }); - } - } else if (isCreate) { - // Create new ref - if (currentOid) { - results.push({ - ref: cmd.ref, - ok: false, - error: "ref already exists", - }); - } else { - results.push({ ref: cmd.ref, ok: true }); - } - } else { - // Update existing ref - check fast-forward - if (!currentOid) { - results.push({ - ref: cmd.ref, - ok: false, - error: "ref doesn't exist", - }); - continue; - } - - const isFF = await git.isDescendent({ - fs: this.fs, - gitdir: "/repo", - oid: cmd.newOid, - ancestor: currentOid, - }); - - if (isFF) { - results.push({ ref: cmd.ref, ok: true }); - } else { - results.push({ - ref: cmd.ref, - ok: false, - error: "non-fast-forward update rejected", - }); - } - } - } catch (error) { - results.push({ - ref: cmd.ref, - ok: false, - error: (error as Error).message, - }); - } - } - - // If atomic and any failed, fail all - if (atomic && results.some((r) => !r.ok)) { - return results.map((r) => ({ - ...r, - ok: false, - error: r.error || "atomic transaction failed", - })); - } - - // Apply successful updates - for (let i = 0; i < commands.length; i += 1) { - if (!results[i].ok) continue; - - const cmd = commands[i]; - const isDelete = cmd.newOid === ZERO_OID; - - try { - if (isDelete) { - await git.deleteRef({ - fs: this.fs, - gitdir: "/repo", - ref: cmd.ref, - }); - } else { - await git.writeRef({ - fs: this.fs, - gitdir: "/repo", - ref: cmd.ref, - value: cmd.newOid, - force: true, - }); - } - } catch (error) { - results[i] = { - ref: cmd.ref, - ok: false, - error: `failed to update: ${(error as Error).message}`, - }; - } - } - - return results; - } -} diff --git a/apps/web/src/git/sideband-writer.ts b/apps/web/src/git/sideband-writer.ts new file mode 100644 index 0000000..d61a282 --- /dev/null +++ b/apps/web/src/git/sideband-writer.ts @@ -0,0 +1,97 @@ +import type { ProgressEvent } from "@/do/pack-indexer"; +import { PktLine } from "./pkt"; + +/** + * Git sideband protocol writer. + * Handles multiplexed output for packfile data, progress messages, and errors. + * + * When enabled, wraps all output in sideband-64k format (channel byte + pkt-line). + * When disabled, passes data through unchanged (for clients without sideband support). + */ +export class SidebandWriter { + private readonly writer: WritableStreamDefaultWriter; + private readonly enabled: boolean; + + constructor( + writer: WritableStreamDefaultWriter, + enabled: boolean + ) { + this.writer = writer; + this.enabled = enabled; + } + + async writeData(data: Uint8Array): Promise { + if (this.enabled) { + await this.writer.write( + PktLine.encodeSideband(PktLine.SIDEBAND_CHANNEL_PACKFILE, data) + ); + } else { + await this.writer.write(data); + } + } + + async writeProgress(msg: string): Promise { + if (this.enabled) { + await this.writer.write(PktLine.encodeProgress(msg)); + } + } + + async writeError(msg: string): Promise { + if (this.enabled) { + await this.writer.write(PktLine.encodeSidebandError(msg)); + } + } + + async flush(): Promise { + if (this.enabled) { + await this.writeData(PktLine.encodeFlush()); + } + await this.writer.write(PktLine.encodeFlush()); + } + + async close(): Promise { + await this.writer.close(); + } +} + +/** + * Progress reporter with throttling. + * Prevents excessive writes during long operations by enforcing minimum intervals. + */ +export class ProgressReporter { + private readonly writer: SidebandWriter; + private readonly throttleMs: number; + private lastUpdate = 0; + + constructor(writer: SidebandWriter, throttleMs = 100) { + this.writer = writer; + this.throttleMs = throttleMs; + } + + async report(event: ProgressEvent): Promise { + const now = Date.now(); + + if (event.type === "writing_index") { + await this.writer.writeProgress( + `Writing index: ${event.count} objects, done.\n` + ); + return; + } + + const { current, total } = event; + const isComplete = current === total; + const shouldUpdate = isComplete || now - this.lastUpdate >= this.throttleMs; + + if (!shouldUpdate) return; + + this.lastUpdate = now; + const pct = total === 0 ? 100 : Math.floor((current / total) * 100); + const suffix = isComplete ? ", done.\n" : "\r"; + const phase = + event.type === "unpacking" ? "Unpacking objects" : "Resolving deltas"; + + await this.writer.writeProgress( + `${phase}: ${pct.toString().padStart(3)}% (${current}/${total})${suffix}` + ); + } +} diff --git a/apps/web/src/lib/bytes.ts b/apps/web/src/lib/bytes.ts new file mode 100644 index 0000000..4b76150 --- /dev/null +++ b/apps/web/src/lib/bytes.ts @@ -0,0 +1,12 @@ +export function concatUint8Arrays( + arrays: Uint8Array[], + totalLength: number +): Uint8Array { + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} diff --git a/apps/web/src/lib/stream-buffer.ts b/apps/web/src/lib/stream-buffer.ts index d9bf0d8..a0a3782 100644 --- a/apps/web/src/lib/stream-buffer.ts +++ b/apps/web/src/lib/stream-buffer.ts @@ -14,6 +14,7 @@ export class StreamBuffer { private readonly chunks: Uint8Array[] = []; private totalLength = 0; private position = 0; + private absolutePosition = 0; private readonly reader: ReadableStreamDefaultReader; private done = false; private readonly onData: ((chunk: Uint8Array) => void) | null; @@ -26,9 +27,9 @@ export class StreamBuffer { this.onData = options?.onData ?? null; } - /** Total bytes consumed so far */ + /** Total bytes consumed so far (monotonically increasing) */ get bytesRead(): number { - return this.position; + return this.absolutePosition; } /** @@ -115,7 +116,9 @@ export class StreamBuffer { } if (advance) { + const bytesConsumed = localPosition - this.position; this.position = localPosition; + this.absolutePosition += bytesConsumed; this.compactIfNeeded(); } diff --git a/apps/web/src/routes/$owner/$repo/_layout/_viewer/blob.tsx b/apps/web/src/routes/$owner/$repo/_layout/_viewer/blob.tsx index 407c2ff..13d36ad 100644 --- a/apps/web/src/routes/$owner/$repo/_layout/_viewer/blob.tsx +++ b/apps/web/src/routes/$owner/$repo/_layout/_viewer/blob.tsx @@ -101,6 +101,7 @@ function RouteComponent() { } catch (error) { // If UTF-8 decoding fails, fall back to treating as binary console.error("Failed to decode file content as UTF-8:", error); + // @ts-expect-error dummy data blob.isBinary = true; } } diff --git a/apps/web/src/routes/$owner/$repo/git-receive-pack.ts b/apps/web/src/routes/$owner/$repo/git-receive-pack.ts index 1c945e0..4c6f928 100644 --- a/apps/web/src/routes/$owner/$repo/git-receive-pack.ts +++ b/apps/web/src/routes/$owner/$repo/git-receive-pack.ts @@ -1,6 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { getRepoDOStub } from "@/do/repo"; -import { verifyAuth } from "@/lib/git-auth"; +import { getHybridRepoDOStub } from "@/do/hybrid-repo"; export const Route = createFileRoute("/$owner/$repo/git-receive-pack")({ server: { @@ -9,24 +8,24 @@ export const Route = createFileRoute("/$owner/$repo/git-receive-pack")({ const { owner, repo } = params; const repoName = repo.endsWith(".git") ? repo.slice(0, -4) : repo; - // Verify authentication for write access (always requires auth) - const isAuthorized = await verifyAuth({ - owner, - repo: repoName, - req: request, - service: "receive-pack", - }); + // // Verify authentication for write access (always requires auth) + // const isAuthorized = await verifyAuth({ + // owner, + // repo: repoName, + // req: request, + // service: "receive-pack", + // }); - if (!isAuthorized) { - return new Response("Unauthorized", { - status: 401, - headers: { - "WWW-Authenticate": 'Basic realm="Git"', - }, - }); - } + // if (!isAuthorized) { + // return new Response("Unauthorized", { + // status: 401, + // headers: { + // "WWW-Authenticate": 'Basic realm="Git"', + // }, + // }); + // } - const stub = getRepoDOStub(`${owner}/${repoName}`); + const stub = getHybridRepoDOStub(`${owner}/${repoName}`); const contentType = request.headers.get("Content-Type") ?? "application/x-git-receive-pack-request"; diff --git a/apps/web/src/routes/$owner/$repo/git-upload-pack.ts b/apps/web/src/routes/$owner/$repo/git-upload-pack.ts index c820655..40de73f 100644 --- a/apps/web/src/routes/$owner/$repo/git-upload-pack.ts +++ b/apps/web/src/routes/$owner/$repo/git-upload-pack.ts @@ -1,44 +1,11 @@ import { createFileRoute } from "@tanstack/react-router"; -import { getRepoDOStub } from "@/do/repo"; -import { verifyAuth } from "@/lib/git-auth"; export const Route = createFileRoute("/$owner/$repo/git-upload-pack")({ server: { handlers: { - POST: async ({ request, params }) => { - const { owner, repo } = params; - const repoName = repo.endsWith(".git") ? repo.slice(0, -4) : repo; - - // Verify authentication for read access (allows anonymous for public repos) - const isAuthorized = await verifyAuth({ - owner, - repo: repoName, - req: request, - service: "upload-pack", - }); - - if (!isAuthorized) { - return new Response("Unauthorized", { - status: 401, - headers: { - "WWW-Authenticate": 'Basic realm="Git"', - }, - }); - } - - const stub = getRepoDOStub(`${owner}/${repoName}`); - const contentType = - request.headers.get("Content-Type") ?? - "application/x-git-upload-pack-request"; - - return stub.fetch("https://do/git-upload-pack", { - method: "POST", - body: request.body, - signal: request.signal, - headers: { - "Content-Type": contentType, - }, - }); + POST: async () => { + // TODO: Implement upload-pack (git fetch/clone) in HybridRepo DO + return new Response("upload-pack not yet implemented", { status: 501 }); }, }, }, diff --git a/apps/web/src/routes/$owner/$repo/info/refs.ts b/apps/web/src/routes/$owner/$repo/info/refs.ts index def69f6..cf3a10a 100644 --- a/apps/web/src/routes/$owner/$repo/info/refs.ts +++ b/apps/web/src/routes/$owner/$repo/info/refs.ts @@ -1,6 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { advertiseCapabilities } from "@/git/protocol"; -import { verifyAuth } from "@/lib/git-auth"; +import { getHybridRepoDOStub } from "@/do/hybrid-repo"; export const Route = createFileRoute("/$owner/$repo/info/refs")({ server: { @@ -16,26 +15,26 @@ export const Route = createFileRoute("/$owner/$repo/info/refs")({ const { repo, owner } = params; const repoName = repo.endsWith(".git") ? repo.slice(0, -4) : repo; - // Verify authentication (allows anonymous for public repos on read) - const isAuthorized = await verifyAuth({ - owner, - repo: repoName, - req: request, - service: - service === "git-upload-pack" ? "upload-pack" : "receive-pack", - }); + // // Verify authentication (allows anonymous for public repos on read) + // const isAuthorized = await verifyAuth({ + // owner, + // repo: repoName, + // req: request, + // service: + // service === "git-upload-pack" ? "upload-pack" : "receive-pack", + // }); - if (!isAuthorized) { - return new Response("Unauthorized", { - status: 401, - headers: { - "WWW-Authenticate": 'Basic realm="Git"', - }, - }); - } + // if (!isAuthorized) { + // return new Response("Unauthorized", { + // status: 401, + // headers: { + // "WWW-Authenticate": 'Basic realm="Git"', + // }, + // }); + // } - const fullRepoName = `${owner}/${repoName}`; - return advertiseCapabilities(service, fullRepoName); + const stub = getHybridRepoDOStub(`${owner}/${repoName}`); + return stub.fetch(`https://do/info/refs?service=${service}`); }, }, }, diff --git a/apps/web/src/server.ts b/apps/web/src/server.ts index 9e03ed0..583eb32 100644 --- a/apps/web/src/server.ts +++ b/apps/web/src/server.ts @@ -26,4 +26,3 @@ export default Sentry.withSentry( // biome-ignore lint/performance/noBarrelFile: export { HybridRepo } from "./do/hybrid-repo"; -export { Repo } from "./do/repo"; From 715e2b983e4c21facc288d5faf126bad5d0b1990 Mon Sep 17 00:00:00 2001 From: mdhruvil Date: Mon, 2 Feb 2026 14:18:19 +0530 Subject: [PATCH 12/12] wip: fucked up ai impl of pull --- AGENTS.md | 4 +- apps/web/src/do/hybrid-repo.ts | 67 +- apps/web/src/do/object-store.ts | 359 +++++- apps/web/src/do/pack-indexer.ts | 51 +- apps/web/src/do/r2.ts | 3 + apps/web/src/do/schema/git.ts | 6 + apps/web/src/git/errors.ts | 7 +- apps/web/src/git/handlers/receive-pack.ts | 5 +- apps/web/src/git/handlers/upload-pack.ts | 374 ++++++ apps/web/src/git/object.ts | 55 +- apps/web/src/git/protocol/upload-pack-v2.ts | 421 +++++++ apps/web/src/git/types.ts | 13 + apps/web/src/git/upload-pack/object-walk.ts | 522 ++++++++ apps/web/src/git/upload-pack/pack-stream.ts | 302 +++++ .../routes/$owner/$repo/git-upload-pack.ts | 24 +- docs/cleanup.md | 406 ++++++ docs/important-notes-always-read.md | 9 + docs/pull.md | 1090 +++++++++++++++++ docs/push.md | 786 ++++++++++++ 19 files changed, 4487 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/git/handlers/upload-pack.ts create mode 100644 apps/web/src/git/protocol/upload-pack-v2.ts create mode 100644 apps/web/src/git/types.ts create mode 100644 apps/web/src/git/upload-pack/object-walk.ts create mode 100644 apps/web/src/git/upload-pack/pack-stream.ts create mode 100644 docs/cleanup.md create mode 100644 docs/important-notes-always-read.md create mode 100644 docs/pull.md create mode 100644 docs/push.md diff --git a/AGENTS.md b/AGENTS.md index bdc99df..c017f51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,7 @@ Gitflare is a selfhosted GitHub alternative which can be hosted on serverless pl - Never run `dev` or `build` commands, unless explicitly instructed. - After making code changes, always run the linter with `pnpm check:fix` to ensure code quality and adherence to guidelines. -- Always fetch latest docs for the library or tool you are working with. - - For shadcn/ui components, refer to https://ui.shadcn.com/llms.txt for full docs list. You can add `.md` after any docs page URL to get the markdown version. ALWAYS prefer markdown docs. ONLY use `pnpm` to install new components. - - For other libraries or tools, use context7 mcp server to fetch latest docs. +- Do not create useless comments that simply describe what the code already says. Comments are for adding context around the code when that context is not immediately obvious. ## Build/Lint/Test Commands diff --git a/apps/web/src/do/hybrid-repo.ts b/apps/web/src/do/hybrid-repo.ts index 2eeabbb..9b0c8e1 100644 --- a/apps/web/src/do/hybrid-repo.ts +++ b/apps/web/src/do/hybrid-repo.ts @@ -6,8 +6,10 @@ import { } from "drizzle-orm/durable-sqlite"; import { migrate } from "drizzle-orm/durable-sqlite/migrator"; import { handleReceivePack } from "@/git/handlers/receive-pack"; +import { handleUploadPackV2 } from "@/git/handlers/upload-pack"; import { advertiseReceivePackCapabilities } from "@/git/protocol/capabilities"; import { parseReceivePackRequest } from "@/git/protocol/receive-pack"; +import { advertiseV2Capabilities } from "@/git/protocol/upload-pack-v2"; import { gitErrorResponse, gitResponse, @@ -33,6 +35,8 @@ type DB = DrizzleSqliteDODatabase; type Storage = { fullName: string; + objectCount: number; + lastPushAt: number; }; export class HybridRepo extends DurableObject { @@ -103,17 +107,21 @@ export class HybridRepo extends DurableObject { const url = new URL(request.url); if (url.pathname === "/info/refs" && request.method === "GET") { - return this.routeInfoRefs(url); + return this.routeInfoRefs(request, url); } if (url.pathname === "/git-receive-pack" && request.method === "POST") { return this.routeReceivePack(request); } + if (url.pathname === "/git-upload-pack" && request.method === "POST") { + return this.routeUploadPack(request); + } + return new Response("Not Found", { status: 404 }); } - private async routeInfoRefs(url: URL): Promise { + private async routeInfoRefs(_request: Request, url: URL): Promise { const service = url.searchParams.get("service"); if (service === "git-receive-pack") { @@ -124,7 +132,11 @@ export class HybridRepo extends DurableObject { } if (service === "git-upload-pack") { - return new Response("upload-pack not yet implemented", { status: 501 }); + return gitResponse( + advertiseV2Capabilities(), + "upload-pack", + "advertisement" + ); } return new Response("Unknown service", { status: 400 }); @@ -148,6 +160,15 @@ export class HybridRepo extends DurableObject { refs: this.refs, indexer: this.indexer, repoPath: this.fullName, + onIndexed: async (objectCount) => { + const prevCount = + (await this.typedStorage.get("objectCount")) ?? 0; + await this.typedStorage.put( + "objectCount", + prevCount + objectCount + ); + await this.typedStorage.put("lastPushAt", Date.now()); + }, }, parsed, useSideband @@ -193,4 +214,44 @@ export class HybridRepo extends DurableObject { async initRepo(): Promise { await this.refs.setSymbolic("HEAD", "refs/heads/main"); } + + /** + * Detect protocol version from Git-Protocol header. + */ + private getProtocolVersion(request: Request): 1 | 2 { + const proto = request.headers.get("Git-Protocol"); + console.log("Git-Protocol header:", proto); + if (proto?.includes("version=2")) return 2; + return 1; + } + + /** + * Route POST /git-upload-pack requests (protocol v2 only). + */ + private async routeUploadPack(request: Request): Promise { + const version = this.getProtocolVersion(request); + if (version !== 2) { + return new Response("Protocol version 2 required", { status: 400 }); + } + + if (!request.body) { + return gitErrorResponse("No request body", "upload-pack"); + } + + const result = await handleUploadPackV2( + { + db: this.db, + refs: this.refs, + objects: this.objects, + waitUntil: (promise: Promise) => this.ctx.waitUntil(promise), + }, + request.body + ); + + if (result.isErr()) { + return gitErrorResponse(result.error.message, "upload-pack"); + } + + return result.value; + } } diff --git a/apps/web/src/do/object-store.ts b/apps/web/src/do/object-store.ts index ccedce6..3edfedc 100644 --- a/apps/web/src/do/object-store.ts +++ b/apps/web/src/do/object-store.ts @@ -19,6 +19,20 @@ import * as schema from "./schema/git"; const MAX_DELTA_DEPTH = 50; +/** + * Get the length of a packfile object header. + * + * Header format: first byte has type in bits 4-6, size bits 0-3 in bits 0-3. + * If bit 7 is set, more bytes follow (each contributing 7 bits to size). + */ +function getObjectHeaderLength(data: Uint8Array): number { + let len = 1; + while (len < data.length && (data[len - 1] & 0x80) !== 0) { + len += 1; + } + return len; +} + /** * Maximum gap between objects to coalesce into a single R2 range request. * If two objects in the same pack are within this gap, fetch them together. @@ -38,7 +52,8 @@ export type ObjectStoreErrorCode = | "DELTA_ERROR" | "CYCLE_DETECTED" | "DELTA_DEPTH_EXCEEDED" - | "DB_ERROR"; + | "DB_ERROR" + | "BATCH_ERROR"; export class ObjectStoreError extends TaggedError("ObjectStoreError")<{ code: ObjectStoreErrorCode; @@ -76,6 +91,50 @@ type CoalescedRange = { oid: string; relativeOffset: number; compressedSize: number; + isDelta: boolean; + }>; +}; + +/** + * Object metadata from git_object_index with pack location info. + */ +export type ObjectIndexEntry = { + oid: string; + type: GitObjectType; + packId: string; + packOffset: number; + compressedSize: number; + isDelta: boolean; + baseOid: string | null; +}; + +/** + * Batch read entry with pack and R2 info for coalescing. + * Includes R2 key so we can group by packfile without additional DB lookups. + */ +export type BatchEntry = { + oid: string; + type: GitObjectType; + packId: string; + packOffset: number; + compressedSize: number; + isDelta: boolean; + baseOid: string | null; + r2Key: string; +}; + +/** + * Coalesced range for batch reading (includes type info for each object). + */ +type BatchCoalescedRange = { + r2Key: string; + offset: number; + length: number; + objects: Array<{ + oid: string; + type: GitObjectType; + relativeOffset: number; + compressedSize: number; }>; }; @@ -96,6 +155,73 @@ export class ObjectStore { return result !== undefined; } + /** + * Get batch entries for a list of OIDs with R2 keys. + * Used by upload-pack to prepare objects for readBatch(). + * + * Objects are returned ordered by packId, packOffset for optimal R2 access. + * Batches queries to avoid SQLite statement length limits. + */ + getBatchEntries(oids: string[]): BatchEntry[] { + if (oids.length === 0) { + return []; + } + + type Row = { + oid: string; + type: string; + pack_id: string; + pack_offset: number; + compressed_size: number; + is_delta: number; + base_oid: string | null; + r2_key: string; + }; + + const allRows: Row[] = []; + const BATCH_SIZE = 100; // SQLite limits bound parameters to 999 + + // Query in batches to avoid SQLite statement length limits + for (let i = 0; i < oids.length; i += BATCH_SIZE) { + const batch = oids.slice(i, i + BATCH_SIZE); + const rows = this.db.all(sql` + SELECT + o.oid, + o.type, + o.pack_id, + o.pack_offset, + o.compressed_size, + o.is_delta, + o.base_oid, + p.r2_key + FROM git_object_index o + JOIN git_packs p ON o.pack_id = p.pack_id + WHERE o.oid IN (${sql.join( + batch.map((oid) => sql`${oid}`), + sql`, ` + )}) + `); + allRows.push(...rows); + } + + // Sort by pack_id, pack_offset for optimal R2 access + allRows.sort((a, b) => { + if (a.pack_id !== b.pack_id) return a.pack_id.localeCompare(b.pack_id); + return a.pack_offset - b.pack_offset; + }); + + return allRows.map((row) => ({ + oid: row.oid, + type: row.type as GitObjectType, + packId: row.pack_id, + packOffset: row.pack_offset, + compressedSize: row.compressed_size, + isDelta: row.is_delta === 1, + baseOid: row.base_oid, + r2Key: row.r2_key, + })); + } + /** * Fetch the entire delta chain for an OID using a recursive CTE. * Returns rows ordered from target (depth=0) to base (highest depth). @@ -203,6 +329,7 @@ export class ObjectStore { oid: row.oid, relativeOffset: row.pack_offset - currentRange.offset, compressedSize: row.compressed_size, + isDelta: row.is_delta === 1, }); } else { // Gap too large: finalize current range, start new one @@ -216,6 +343,7 @@ export class ObjectStore { oid: row.oid, relativeOffset: 0, compressedSize: row.compressed_size, + isDelta: row.is_delta === 1, }, ], }; @@ -231,6 +359,7 @@ export class ObjectStore { oid: row.oid, relativeOffset: 0, compressedSize: row.compressed_size, + isDelta: row.is_delta === 1, }, ], }; @@ -285,11 +414,19 @@ export class ObjectStore { const { range, buffer } = yield* result; for (const obj of range.objects) { - const compressedData = buffer.subarray( + const entryData = buffer.subarray( obj.relativeOffset, obj.relativeOffset + obj.compressedSize ); + // Skip the variable-length type+size header to get to zlib data + // For ref_delta objects, also skip the 20-byte base OID + let headerLen = getObjectHeaderLength(entryData); + if (obj.isDelta) { + headerLen += 20; // ref_delta has 20-byte base hash after header + } + const compressedData = entryData.subarray(headerLen); + const decompressed = yield* Result.try({ try: () => inflateSync(compressedData), catch: (cause) => @@ -424,4 +561,222 @@ export class ObjectStore { }.bind(this) ); } + + /** + * Read multiple objects in batch with packfile-aware coalescing. + * + * Objects are grouped by packfile and sorted by offset to minimize R2 requests. + * Nearby objects within COALESCE_GAP_THRESHOLD are fetched in single range requests. + * + * Calls onObject for each resolved object (allows streaming without holding all in memory). + * Objects with delta chains are fully resolved before being emitted. + * + * @param entries - Object metadata with pack location info (from object-walk) + * @param onObject - Callback for each resolved object + * @returns Count of successfully processed objects + */ + async readBatch( + entries: BatchEntry[], + onObject: (obj: ResolvedObject) => Promise + ): Promise> { + if (entries.length === 0) { + return Result.ok(0); + } + + // Separate delta and non-delta objects + const nonDeltas: BatchEntry[] = []; + const deltas: BatchEntry[] = []; + + for (const entry of entries) { + if (entry.isDelta) { + deltas.push(entry); + } else { + nonDeltas.push(entry); + } + } + + let processed = 0; + + // Process non-delta objects in coalesced batches + const nonDeltaResult = await this.readBatchNonDelta(nonDeltas, onObject); + if (nonDeltaResult.isErr()) { + return nonDeltaResult; + } + processed += nonDeltaResult.value; + + // Process delta objects individually (need full chain resolution) + for (const entry of deltas) { + const result = await this.read(entry.oid); + if (result.isErr()) { + return Result.err( + new ObjectStoreError({ + code: "BATCH_ERROR", + message: `Failed to resolve delta object: ${result.error.message}`, + oid: entry.oid, + }) + ); + } + await onObject(result.value); + processed += 1; + } + + return Result.ok(processed); + } + + /** + * Read non-delta objects in coalesced batches. + */ + private async readBatchNonDelta( + entries: BatchEntry[], + onObject: (obj: ResolvedObject) => Promise + ): Promise> { + if (entries.length === 0) { + return Result.ok(0); + } + + // Build coalesced ranges grouped by packfile + const ranges = this.buildBatchCoalescedRanges(entries); + let processed = 0; + + // Process each range + for (const range of ranges) { + const r2Result = await this.r2.get(range.r2Key, { + range: { + offset: range.offset, + length: range.length, + }, + }); + + if (r2Result.isErr()) { + return Result.err( + new ObjectStoreError({ + code: "R2_ERROR", + message: `R2 error for range at ${range.r2Key}:${range.offset}: ${r2Result.error.message}`, + oid: range.objects[0]?.oid ?? "unknown", + }) + ); + } + + const buffer = new Uint8Array(await r2Result.value.arrayBuffer()); + + // Extract and decompress each object in the range + for (const obj of range.objects) { + const entryData = buffer.subarray( + obj.relativeOffset, + obj.relativeOffset + obj.compressedSize + ); + + // Skip the variable-length type+size header to get to zlib data + const headerLen = getObjectHeaderLength(entryData); + const compressedData = entryData.subarray(headerLen); + + let decompressed: Uint8Array; + try { + decompressed = inflateSync(compressedData); + } catch (cause) { + return Result.err( + new ObjectStoreError({ + code: "DECOMPRESS_ERROR", + message: `Decompression error for "${obj.oid}": ${String(cause)}`, + oid: obj.oid, + }) + ); + } + + await onObject({ + oid: obj.oid, + type: obj.type, + data: decompressed, + }); + processed += 1; + } + } + + return Result.ok(processed); + } + + /** + * Build coalesced ranges for batch reading. + * Groups entries by R2 key and merges nearby objects into single range requests. + */ + private buildBatchCoalescedRanges( + entries: BatchEntry[] + ): BatchCoalescedRange[] { + // Group by r2Key (packfile) + const byR2Key = new Map(); + for (const entry of entries) { + const existing = byR2Key.get(entry.r2Key); + if (existing) { + existing.push(entry); + } else { + byR2Key.set(entry.r2Key, [entry]); + } + } + + const ranges: BatchCoalescedRange[] = []; + + for (const [r2Key, packEntries] of byR2Key) { + // Sort by pack_offset for sequential access + packEntries.sort((a, b) => a.packOffset - b.packOffset); + + let currentRange: BatchCoalescedRange | null = null; + + for (const entry of packEntries) { + const entryEnd = entry.packOffset + entry.compressedSize; + + if (currentRange) { + const currentEnd = currentRange.offset + currentRange.length; + const gap = entry.packOffset - currentEnd; + + if (gap <= COALESCE_GAP_THRESHOLD) { + // Coalesce: extend current range + currentRange.length = entryEnd - currentRange.offset; + currentRange.objects.push({ + oid: entry.oid, + type: entry.type, + relativeOffset: entry.packOffset - currentRange.offset, + compressedSize: entry.compressedSize, + }); + } else { + // Gap too large: finalize current range, start new one + ranges.push(currentRange); + currentRange = { + r2Key, + offset: entry.packOffset, + length: entry.compressedSize, + objects: [ + { + oid: entry.oid, + type: entry.type, + relativeOffset: 0, + compressedSize: entry.compressedSize, + }, + ], + }; + } + } else { + // Start new range + currentRange = { + r2Key, + offset: entry.packOffset, + length: entry.compressedSize, + objects: [ + { + oid: entry.oid, + type: entry.type, + relativeOffset: 0, + compressedSize: entry.compressedSize, + }, + ], + }; + } + } + + if (currentRange) { + ranges.push(currentRange); + } + } + + return ranges; + } } diff --git a/apps/web/src/do/pack-indexer.ts b/apps/web/src/do/pack-indexer.ts index e8caaf4..c51df53 100644 --- a/apps/web/src/do/pack-indexer.ts +++ b/apps/web/src/do/pack-indexer.ts @@ -6,6 +6,7 @@ * - Computing OIDs for base objects * - Resolving REF_DELTA objects (in-memory + existing objects for thin packs) * - Batch inserting index entries into SQLite + * - Populating commit_graph cache for efficient ancestry traversal * - Progress reporting for streaming responses */ @@ -26,6 +27,12 @@ type IndexedObject = { baseOid: string | undefined; }; +type CommitGraphEntry = { + oid: string; + treeOid: string; + parentOids: string[]; +}; + type IndexResult = { packId: string; objectCount: number; @@ -97,6 +104,7 @@ export class PackIndexer { const resolvedObjects = new Map(); const pendingDeltas: PendingDelta[] = []; const indexEntries: IndexedObject[] = []; + const commitGraphEntries: CommitGraphEntry[] = []; let objectsTotal = 0; @@ -174,6 +182,15 @@ export class PackIndexer { isDelta: false, baseOid: undefined, }); + + // Extract commit graph data for ancestry traversal cache + if (objectType === "commit") { + const commitResult = GitObject.parseCommitForWalk(data); + if (commitResult.isOk()) { + const { tree, parents } = commitResult.value; + commitGraphEntries.push({ oid, treeOid: tree, parentOids: parents }); + } + } } // Now we resolve deltas @@ -232,6 +249,19 @@ export class PackIndexer { baseOid: delta.baseHash, }); + // Extract commit graph data for delta-resolved commits + if (base.type === "commit") { + const commitResult = GitObject.parseCommitForWalk(resolvedData); + if (commitResult.isOk()) { + const { tree, parents } = commitResult.value; + commitGraphEntries.push({ + oid, + treeOid: tree, + parentOids: parents, + }); + } + } + // Remove from pending pendingDeltas.splice(i, 1); resolvedCount += 1; @@ -259,7 +289,12 @@ export class PackIndexer { // Pass 3: Write to SQLite await onProgress?.({ type: "writing_index", count: indexEntries.length }); - const writeResult = await this.writeIndex(packId, r2Key, indexEntries); + const writeResult = await this.writeIndex( + packId, + r2Key, + indexEntries, + commitGraphEntries + ); if (writeResult.isErr()) { return writeResult; } @@ -273,12 +308,13 @@ export class PackIndexer { } /** - * Write pack and object index entries to SQLite in a single transaction. + * Write pack, object index, and commit graph entries to SQLite in a single transaction. */ private async writeIndex( packId: string, r2Key: string, - entries: IndexedObject[] + entries: IndexedObject[], + commitGraphEntries: CommitGraphEntry[] ): Promise> { return Result.tryPromise({ try: async () => { @@ -306,6 +342,15 @@ export class PackIndexer { })) ); } + + // Insert commit graph entries for ancestry traversal cache + for (let i = 0; i < commitGraphEntries.length; i += BATCH_SIZE) { + const batch = commitGraphEntries.slice(i, i + BATCH_SIZE); + await tx + .insert(schema.commitGraph) + .values(batch) + .onConflictDoNothing(); + } }); }, catch: (cause) => diff --git a/apps/web/src/do/r2.ts b/apps/web/src/do/r2.ts index ccd4384..c5a35aa 100644 --- a/apps/web/src/do/r2.ts +++ b/apps/web/src/do/r2.ts @@ -60,6 +60,7 @@ type R2PutValue = export class R2 { readonly #bucket: R2Bucket; + private req = 0; constructor(bucket: R2Bucket) { this.#bucket = bucket; @@ -89,6 +90,8 @@ export class R2 { ): Promise>; async get(key: string, options?: R2GetOptions) { + this.req += 1; + console.log(`[R2][${this.req}] GET ${key}`); return ( await Result.tryPromise({ try: () => diff --git a/apps/web/src/do/schema/git.ts b/apps/web/src/do/schema/git.ts index afe7f7f..f414ecd 100644 --- a/apps/web/src/do/schema/git.ts +++ b/apps/web/src/do/schema/git.ts @@ -56,3 +56,9 @@ export const gitRefs = sqliteTable("git_refs", { .$onUpdate(() => new Date()) .notNull(), }); + +export const commitGraph = sqliteTable("commit_graph", { + oid: text("oid").primaryKey(), + treeOid: text("tree_oid").notNull(), + parentOids: text("parent_oids", { mode: "json" }).$type().notNull(), +}); diff --git a/apps/web/src/git/errors.ts b/apps/web/src/git/errors.ts index 6d857a3..318c7ba 100644 --- a/apps/web/src/git/errors.ts +++ b/apps/web/src/git/errors.ts @@ -5,7 +5,12 @@ export class ProtocolError extends TaggedError("ProtocolError")<{ | "INVALID_PKT_LINE" | "MALFORMED_COMMAND" | "MISSING_FLUSH" - | "INVALID_PACKFILE"; + | "INVALID_PACKFILE" + // upload-pack specific + | "INVALID_WANT" + | "TRAVERSAL_ERROR" + | "PACK_WRITE_ERROR" + | "REPO_TOO_LARGE"; detail?: string; }>() {} diff --git a/apps/web/src/git/handlers/receive-pack.ts b/apps/web/src/git/handlers/receive-pack.ts index bfb797a..426020c 100644 --- a/apps/web/src/git/handlers/receive-pack.ts +++ b/apps/web/src/git/handlers/receive-pack.ts @@ -18,6 +18,7 @@ export type ReceivePackContext = { refs: RefStore; indexer: PackIndexer; repoPath: string; + onIndexed?: (objectCount: number) => Promise; }; /** @@ -118,7 +119,7 @@ async function processReceivePack(opts: ProcessReceivePackOpts): Promise { const indexerStream = packfileStream.pipeThrough(collectingStream); const result = await Result.gen(async function* () { - yield* Result.await( + const indexResult = yield* Result.await( ctx.indexer.index(packId, r2Key, indexerStream, async (event) => { await progress.report(event); }) @@ -127,6 +128,8 @@ async function processReceivePack(opts: ProcessReceivePackOpts): Promise { const packData = concatUint8Arrays(packChunks, packSize); yield* Result.await(ctx.r2.put(r2Key, packData)); + await ctx.onIndexed?.(indexResult.objectCount); + return Result.ok(undefined); }); diff --git a/apps/web/src/git/handlers/upload-pack.ts b/apps/web/src/git/handlers/upload-pack.ts new file mode 100644 index 0000000..2a84e17 --- /dev/null +++ b/apps/web/src/git/handlers/upload-pack.ts @@ -0,0 +1,374 @@ +/** + * Upload-pack handler for git fetch/clone operations. + * + * Implements Git protocol v2 commands: + * - ls-refs: List available refs with optional filtering + * - fetch: Negotiate and send packfile + */ + +import { Result, TaggedError } from "better-result"; +import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; +import type { ObjectStore } from "@/do/object-store"; +import type { RefStore, Ref as StoredRef } from "@/do/ref-store"; +import type * as schema from "@/do/schema/git"; +import { + encodeAcknowledgments, + encodeLsRefsResponse, + encodePackfileSection, + type FetchRequest, + type LsRefsRequest, + type Ref as ProtocolRef, + parseCommand, +} from "@/git/protocol/upload-pack-v2"; +import { gitResponse, gitStreamResponse } from "@/git/response"; +import { + filterExistingOids, + getAllObjectsForClone, + type WalkOptions, + walkObjectsIncremental, +} from "@/git/upload-pack/object-walk"; +import { + createSidebandStream, + encodeSidebandError, + encodeSidebandProgress, + PackStreamWriter, +} from "@/git/upload-pack/pack-stream"; + +type DB = DrizzleSqliteDODatabase; + +export class UploadPackError extends TaggedError("UploadPackError")<{ + code: + | "PROTOCOL_ERROR" + | "INVALID_WANT" + | "TRAVERSAL_ERROR" + | "PACK_ERROR" + | "INTERNAL_ERROR"; + message: string; +}>() {} + +export type UploadPackContext = { + db: DB; + refs: RefStore; + objects: ObjectStore; + waitUntil: (promise: Promise) => void; +}; + +/** + * Handle an upload-pack v2 request. + * Routes to appropriate command handler based on parsed command. + */ +export async function handleUploadPackV2( + ctx: UploadPackContext, + requestBody: ReadableStream +): Promise> { + const commandResult = await parseCommand(requestBody); + if (commandResult.isErr()) { + return Result.err( + new UploadPackError({ + code: "PROTOCOL_ERROR", + message: commandResult.error.message, + }) + ); + } + + const command = commandResult.value; + + switch (command.type) { + case "ls-refs": + return handleLsRefs(ctx, command.args); + case "fetch": + return handleFetch(ctx, command.args); + default: { + const exhaustive: never = command; + return Result.err( + new UploadPackError({ + code: "PROTOCOL_ERROR", + message: `Unknown command: ${(exhaustive as { type: string }).type}`, + }) + ); + } + } +} + +/** + * Handle ls-refs command. + * Returns list of refs with optional symref targets and peeled OIDs. + */ +async function handleLsRefs( + ctx: UploadPackContext, + args: LsRefsRequest +): Promise> { + const refsResult = await ctx.refs.list(); + if (refsResult.isErr()) { + return Result.err( + new UploadPackError({ + code: "INTERNAL_ERROR", + message: refsResult.error.message, + }) + ); + } + + const allRefs = refsResult.value; + + // Filter by prefixes if specified + let filtered: StoredRef[] = allRefs; + if (args.refPrefixes.length > 0) { + filtered = allRefs.filter((r) => + args.refPrefixes.some((p) => r.name.startsWith(p)) + ); + } + + // Convert to protocol format + // For symbolic refs, value contains the target ref name + const refs: ProtocolRef[] = filtered.map((r) => ({ + name: r.name, + oid: r.value, + symrefTarget: r.isSymbolic ? r.value : undefined, + })); + + const response = encodeLsRefsResponse(refs, { + peel: args.peel, + symrefs: args.symrefs, + }); + + return Result.ok(gitResponse(response, "upload-pack", "result")); +} + +/** + * Handle fetch command. + * Two modes: + * - Negotiation (no 'done'): Return acknowledgments + * - Final (with 'done'): Send packfile + */ +async function handleFetch( + ctx: UploadPackContext, + req: FetchRequest +): Promise> { + // Validate wants against advertised refs + const refsResult = await ctx.refs.list(); + if (refsResult.isErr()) { + return Result.err( + new UploadPackError({ + code: "INTERNAL_ERROR", + message: refsResult.error.message, + }) + ); + } + + const advertisedOids = new Set(refsResult.value.map((r) => r.value)); + + for (const want of req.wants) { + if (!advertisedOids.has(want)) { + return Result.err( + new UploadPackError({ + code: "INVALID_WANT", + message: `Object ${want} was not advertised`, + }) + ); + } + } + + // If no 'done', return acknowledgments only (negotiation phase) + if (!req.done) { + return handleFetchNegotiation(ctx, req); + } + + // Client sent 'done' - send packfile + return handleFetchWithPack(ctx, req); +} + +/** + * Handle fetch negotiation phase. + * Returns acknowledgments for common objects. + */ +async function handleFetchNegotiation( + ctx: UploadPackContext, + req: FetchRequest +): Promise> { + // Check which haves we have in common + const validHaves = await filterExistingOids(ctx.objects, req.haves); + + // ready=true if we have common objects (can send minimal pack) + const ready = validHaves.length > 0; + + const response = encodeAcknowledgments(validHaves, ready); + + return Result.ok(gitResponse(response, "upload-pack", "result")); +} + +/** + * Handle fetch with packfile generation. + * Streams response with acknowledgments + packfile. + */ +function handleFetchWithPack( + ctx: UploadPackContext, + req: FetchRequest +): Result { + const { readable, writable } = new TransformStream(); + + // Process in background, stream response immediately + ctx.waitUntil(processFetchWithPack(ctx, req, writable)); + + return Result.ok(gitStreamResponse(readable, "upload-pack")); +} + +/** + * Process fetch request and write packfile to stream. + */ +async function processFetchWithPack( + ctx: UploadPackContext, + req: FetchRequest, + writable: WritableStream +): Promise { + const writer = writable.getWriter(); + const showProgress = !req.capabilities.noProgress; + + try { + // Determine if this is a clone (no haves) or incremental fetch + const isClone = req.haves.length === 0; + + // For incremental fetch, write acknowledgments first + // For fresh clone (no haves), skip directly to packfile + if (!isClone) { + const validHaves = await filterExistingOids(ctx.objects, req.haves); + const acks = encodeAcknowledgments(validHaves, true, true); // moreFollows=true + await writer.write(acks); + } + + // Write packfile section header + await writer.write(encodePackfileSection()); + + // Create sideband transform for pack data + const sidebandTransform = createSidebandStream(); + const packWritable = sidebandTransform.writable; + const sidebandReadable = sidebandTransform.readable; + + // Pipe sideband output to response + const pipePromise = sidebandReadable.pipeTo( + new WritableStream({ + write: (chunk) => writer.write(chunk), + }) + ); + + // Walk objects and generate pack + const walkResult = isClone + ? await getAllObjectsForClone(ctx.db) + : await walkObjectsIncremental(req.wants, req.haves, { + db: ctx.db, + objectStore: ctx.objects, + } satisfies WalkOptions); + + if (walkResult.isErr()) { + if (showProgress) { + await writer.write( + encodeSidebandError(`Traversal error: ${walkResult.error.message}\n`) + ); + } + await packWritable.abort(); + await writer.close(); + return; + } + + const { objects } = walkResult.value; + + if (showProgress) { + await writer.write( + encodeSidebandProgress( + `Enumerating objects: ${objects.length}, done.\n` + ) + ); + } + + // Get batch entries with R2 keys + const batchEntries = ctx.objects.getBatchEntries(objects.map((o) => o.oid)); + + // Create pack writer + const packWriter = new PackStreamWriter(packWritable, batchEntries.length); + + const headerResult = await packWriter.writeHeader(); + if (headerResult.isErr()) { + if (showProgress) { + await writer.write( + encodeSidebandError(`Pack error: ${headerResult.error.message}\n`) + ); + } + await packWriter.abort(); + await writer.close(); + return; + } + + // Write objects via batch reader + let written = 0; + const batchResult = await ctx.objects.readBatch( + batchEntries, + async (obj) => { + const writeResult = await packWriter.writeObject(obj.type, obj.data); + if (writeResult.isErr()) { + throw new Error(writeResult.error.message); + } + + written += 1; + if (showProgress && written % 100 === 0) { + await writer.write( + encodeSidebandProgress( + `Counting objects: ${written}/${batchEntries.length}\r` + ) + ); + } + } + ); + + if (batchResult.isErr()) { + if (showProgress) { + await writer.write( + encodeSidebandError( + `Batch read error: ${batchResult.error.message}\n` + ) + ); + } + await packWriter.abort(); + await writer.close(); + return; + } + + if (showProgress) { + await writer.write( + encodeSidebandProgress( + `Total ${batchEntries.length} (delta 0), reused ${batchEntries.length} (delta 0)\n` + ) + ); + } + + // Finalize pack (writes SHA1 checksum) + const finalizeResult = await packWriter.finalize(); + if (finalizeResult.isErr()) { + if (showProgress) { + await writer.write( + encodeSidebandError( + `Finalize error: ${finalizeResult.error.message}\n` + ) + ); + } + await writer.close(); + return; + } + + // Wait for sideband pipe to complete + await pipePromise; + + // Write flush packet to end response + await writer.write(new Uint8Array([0x30, 0x30, 0x30, 0x30])); // "0000" + await writer.close(); + } catch (err) { + if (showProgress) { + await writer.write( + encodeSidebandError(`Internal error: ${String(err)}\n`) + ); + } + try { + await writer.close(); + } catch { + // Ignore close errors + } + } +} diff --git a/apps/web/src/git/object.ts b/apps/web/src/git/object.ts index ec2b76f..d92535c 100644 --- a/apps/web/src/git/object.ts +++ b/apps/web/src/git/object.ts @@ -117,4 +117,57 @@ function unwrap( return Result.ok({ type, data }); } -export const GitObject = { computeOid, wrap, unwrap }; +export type CommitWalkData = { + tree: string; + parents: string[]; +}; + +/** + * Parse a commit object to extract tree and parent OIDs for graph traversal. + * This is a minimal parser focused only on what's needed for commit graph caching. + * + * Commit format: + * ``` + * tree \n + * parent \n (zero or more) + * author ...\n + * committer ...\n + * \n + * + * ``` + */ +function parseCommitForWalk( + data: Uint8Array +): Result { + const text = TEXT_DECODER.decode(data); + const lines = text.split("\n"); + + let tree: string | undefined; + const parents: string[] = []; + + for (const line of lines) { + // Empty line marks end of headers + if (line === "") { + break; + } + + if (line.startsWith("tree ")) { + tree = line.slice(5); + } else if (line.startsWith("parent ")) { + parents.push(line.slice(7)); + } + } + + if (!tree) { + return Result.err( + new ObjectError({ + code: "INVALID_HEADER", + message: "Commit missing tree field", + }) + ); + } + + return Result.ok({ tree, parents }); +} + +export const GitObject = { computeOid, wrap, unwrap, parseCommitForWalk }; diff --git a/apps/web/src/git/protocol/upload-pack-v2.ts b/apps/web/src/git/protocol/upload-pack-v2.ts new file mode 100644 index 0000000..ebd6922 --- /dev/null +++ b/apps/web/src/git/protocol/upload-pack-v2.ts @@ -0,0 +1,421 @@ +/** + * Git protocol v2 upload-pack implementation. + * Handles capability advertisement, ls-refs, and fetch commands. + * + * @see https://git-scm.com/docs/protocol-v2 + */ + +import { Result } from "better-result"; +import { ProtocolError } from "@/git/errors"; +import { PktLine } from "@/git/pkt"; +import { concatUint8Arrays } from "@/lib/bytes"; + +// Server capabilities advertised during info/refs +const V2_CAPABILITIES = [ + "version 2", + "ls-refs", + "fetch", + "server-option", +] as const; + +// Fetch command capabilities (subset we support for MVP) +const FETCH_CAPABILITIES = ["no-progress"] as const; + +export type Ref = { + name: string; + oid: string; + symrefTarget?: string; + peeled?: string; +}; + +export type LsRefsRequest = { + peel: boolean; + symrefs: boolean; + refPrefixes: string[]; +}; + +export type FetchRequest = { + wants: string[]; + haves: string[]; + done: boolean; + capabilities: { + noProgress: boolean; + }; +}; + +export type Command = + | { type: "ls-refs"; args: LsRefsRequest } + | { type: "fetch"; args: FetchRequest }; + +/** + * Advertise v2 capabilities for info/refs response. + * Called when client sends Git-Protocol: version=2 header. + * + * Output format: + * ``` + * PKT-LINE("version 2\n") + * PKT-LINE("ls-refs\n") + * PKT-LINE("fetch\n") + * PKT-LINE("server-option\n") + * flush-pkt (0000) + * ``` + */ +export function advertiseV2Capabilities(): Uint8Array { + const lines: Uint8Array[] = []; + + for (const cap of V2_CAPABILITIES) { + lines.push(PktLine.encode(`${cap}\n`)); + } + + lines.push(PktLine.encodeFlush()); + return PktLine.mergeLines(lines); +} + +/** + * Advertise fetch capabilities for fetch command response. + */ +export function advertiseFetchCapabilities(): Uint8Array { + const lines: Uint8Array[] = []; + + for (const cap of FETCH_CAPABILITIES) { + lines.push(PktLine.encode(`${cap}\n`)); + } + + return PktLine.mergeLines(lines); +} + +/** + * Parse a command from the request body. + * Expects: `command=` followed by delim-pkt and arguments. + */ +export async function parseCommand( + stream: ReadableStream +): Promise> { + const reader = stream.getReader(); + + try { + // Accumulate chunks until we have the full request (ends with flush-pkt) + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + totalBytes += value.length; + + // Check if we've seen the final flush + if (hasFlushPacket(chunks, totalBytes)) { + break; + } + } + + const data = concatUint8Arrays(chunks, totalBytes); + return parseCommandFromBuffer(data); + } finally { + reader.releaseLock(); + } +} + +/** + * Parse ls-refs command arguments from buffer. + */ +export function parseLsRefsRequest( + packets: string[] +): Result { + const request: LsRefsRequest = { + peel: false, + symrefs: false, + refPrefixes: [], + }; + + for (const line of packets) { + const trimmed = line.trim(); + + if (trimmed === "peel") { + request.peel = true; + } else if (trimmed === "symrefs") { + request.symrefs = true; + } else if (trimmed.startsWith("ref-prefix ")) { + const prefix = trimmed.slice("ref-prefix ".length); + request.refPrefixes.push(prefix); + } + // Ignore unknown arguments (forward compatibility) + } + + return Result.ok(request); +} + +/** + * Parse fetch command arguments from buffer. + */ +export function parseFetchRequest( + packets: string[] +): Result { + const request: FetchRequest = { + wants: [], + haves: [], + done: false, + capabilities: { + noProgress: false, + }, + }; + + for (const line of packets) { + const trimmed = line.trim(); + + if (trimmed === "done") { + request.done = true; + } else if (trimmed === "no-progress") { + request.capabilities.noProgress = true; + } else if (trimmed.startsWith("want ")) { + const oid = trimmed.slice("want ".length).trim(); + if (oid.length === 40) { + request.wants.push(oid); + } + } else if (trimmed.startsWith("have ")) { + const oid = trimmed.slice("have ".length).trim(); + if (oid.length === 40) { + request.haves.push(oid); + } + } + // Ignore: thin-pack, ofs-delta, include-tag, etc. (not supported in MVP) + // TODO: ^^ Handle these capabilities in future iterations + } + + if (request.wants.length === 0) { + return Result.err( + new ProtocolError({ + code: "MALFORMED_COMMAND", + detail: "fetch command requires at least one want", + }) + ); + } + + return Result.ok(request); +} + +/** + * Encode ls-refs response. + * + * Output format per ref: + * ``` + * SP [SP symref-target:] [SP peeled:] LF + * ``` + * Ends with flush-pkt. + */ +export function encodeLsRefsResponse( + refs: Ref[], + options: { peel: boolean; symrefs: boolean } +): Uint8Array { + const lines: Uint8Array[] = []; + + for (const ref of refs) { + let line = `${ref.oid} ${ref.name}`; + + if (options.symrefs && ref.symrefTarget) { + line += ` symref-target:${ref.symrefTarget}`; + } + + if (options.peel && ref.peeled) { + line += ` peeled:${ref.peeled}`; + } + + lines.push(PktLine.encode(`${line}\n`)); + } + + lines.push(PktLine.encodeFlush()); + return PktLine.mergeLines(lines); +} + +/** + * Encode acknowledgments section for fetch negotiation. + * + * Output format: + * ``` + * PKT-LINE("acknowledgments\n") + * PKT-LINE("ACK \n") for each acknowledged object + * PKT-LINE("ready\n") if ready to send pack, else PKT-LINE("NAK\n") + * delim-pkt (0001) if more sections follow, else flush-pkt (0000) + * ``` + */ +export function encodeAcknowledgments( + acks: string[], + ready: boolean, + moreFollows = false +): Uint8Array { + const lines: Uint8Array[] = []; + + lines.push(PktLine.encode("acknowledgments\n")); + + for (const oid of acks) { + lines.push(PktLine.encode(`ACK ${oid}\n`)); + } + + if (ready) { + lines.push(PktLine.encode("ready\n")); + } else { + lines.push(PktLine.encode("NAK\n")); + } + + lines.push(moreFollows ? PktLine.encodeDelim() : PktLine.encodeFlush()); + return PktLine.mergeLines(lines); +} + +/** + * Encode packfile section header. + * Following data should be sideband-multiplexed. + * + * Output: `PKT-LINE("packfile\n")` + */ +export function encodePackfileSection(): Uint8Array { + return PktLine.encode("packfile\n"); +} + +/** + * Encode response-end packet for stateless protocol. + */ +export function encodeResponseEnd(): Uint8Array { + return PktLine.encodeResponseEnd(); +} + +// Internal helpers + +/** + * Check if buffer ends with a flush packet (0000). + */ +function hasFlushPacket(chunks: Uint8Array[], totalLength: number): boolean { + const data = concatUint8Arrays(chunks, totalLength); + let offset = 0; + + while (offset + 4 <= data.length) { + const lengthHex = String.fromCharCode( + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3] + ); + + if (lengthHex === PktLine.FLUSH) { + // Found flush - check if it's at the end + return offset + 4 === data.length; + } + + if (lengthHex === PktLine.DELIM || lengthHex === PktLine.RESPONSE_END) { + offset += 4; + continue; + } + + const packetLen = Number.parseInt(lengthHex, 16); + if (Number.isNaN(packetLen) || packetLen < 4) { + return false; + } + + if (offset + packetLen > data.length) { + return false; + } + + offset += packetLen; + } + + return false; +} + +/** + * Parse command from complete buffer. + */ +function parseCommandFromBuffer( + data: Uint8Array +): Result { + const packets: string[] = []; + let commandName: string | undefined; + let afterDelim = false; + let offset = 0; + + while (offset + 4 <= data.length) { + const lengthHex = String.fromCharCode( + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3] + ); + + if (lengthHex === PktLine.FLUSH) { + break; + } + + if (lengthHex === PktLine.DELIM) { + afterDelim = true; + offset += 4; + continue; + } + + if (lengthHex === PktLine.RESPONSE_END) { + break; + } + + const packetLen = Number.parseInt(lengthHex, 16); + if (Number.isNaN(packetLen) || packetLen < 4) { + return Result.err( + new ProtocolError({ + code: "INVALID_PKT_LINE", + detail: `invalid hex length: ${lengthHex}`, + }) + ); + } + + if (offset + packetLen > data.length) { + return Result.err( + new ProtocolError({ + code: "INVALID_PKT_LINE", + detail: "truncated packet", + }) + ); + } + + const payload = data.subarray(offset + 4, offset + packetLen); + const line = PktLine.decodeText(payload).trim(); + offset += packetLen; + + // First packet before delim should be command= + if (!afterDelim) { + if (line.startsWith("command=")) { + commandName = line.slice("command=".length); + } + continue; + } + + // After delim, collect arguments + packets.push(line); + } + + if (!commandName) { + return Result.err( + new ProtocolError({ + code: "MALFORMED_COMMAND", + detail: "missing command= packet", + }) + ); + } + + switch (commandName) { + case "ls-refs": { + const argsResult = parseLsRefsRequest(packets); + if (argsResult.isErr()) return argsResult; + return Result.ok({ type: "ls-refs", args: argsResult.value }); + } + case "fetch": { + const argsResult = parseFetchRequest(packets); + if (argsResult.isErr()) return argsResult; + return Result.ok({ type: "fetch", args: argsResult.value }); + } + default: + return Result.err( + new ProtocolError({ + code: "MALFORMED_COMMAND", + detail: `unknown command: ${commandName}`, + }) + ); + } +} diff --git a/apps/web/src/git/types.ts b/apps/web/src/git/types.ts new file mode 100644 index 0000000..8a56107 --- /dev/null +++ b/apps/web/src/git/types.ts @@ -0,0 +1,13 @@ +import type { GitObjectType } from "./object"; + +/** + * Object metadata for batch fetching during upload-pack. + * Contains all info needed to locate and read an object from R2 storage. + */ +export type ObjectMetadata = { + oid: string; + type: GitObjectType; + packfileId: string; + offset: number; + size: number; +}; diff --git a/apps/web/src/git/upload-pack/object-walk.ts b/apps/web/src/git/upload-pack/object-walk.ts new file mode 100644 index 0000000..c63a822 --- /dev/null +++ b/apps/web/src/git/upload-pack/object-walk.ts @@ -0,0 +1,522 @@ +/** + * Object traversal for upload-pack. + * + * Provides two paths: + * 1. Fresh clone: Query all objects directly (no graph walking) + * 2. Incremental fetch: Walk from wants to haves using commit_graph cache + * + * Uses recursive CTEs for efficient ancestor traversal in SQLite. + */ + +import { Result, TaggedError } from "better-result"; +import { inArray, sql } from "drizzle-orm"; +import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; +import type { ObjectStore } from "@/do/object-store"; +import type * as schema from "@/do/schema/git"; +import { commitGraph, gitObjectIndex } from "@/do/schema/git"; +import type { GitObjectType } from "@/git/object"; + +type DB = DrizzleSqliteDODatabase; + +export type ObjectMetadata = { + oid: string; + type: GitObjectType; + packId: string; + packOffset: number; + compressedSize: number; +}; + +export type WalkResult = { + objects: ObjectMetadata[]; + commits: number; + trees: number; + blobs: number; + tags: number; +}; + +export type WalkErrorCode = + | "INVALID_WANT" + | "DB_ERROR" + | "OBJECT_NOT_FOUND" + | "TRAVERSAL_ERROR"; + +export class WalkError extends TaggedError("WalkError")<{ + code: WalkErrorCode; + message: string; +}>() {} + +type TreeEntry = { + mode: string; + name: string; + oid: string; +}; + +/** + * Get all objects for a fresh clone. + * No graph walking needed - just query all objects from SQLite. + * + * Objects are ordered by pack_id, pack_offset for optimal sequential R2 reads. + */ +export async function getAllObjectsForClone( + db: DB +): Promise> { + return Result.tryPromise({ + try: async () => { + const rows = db + .select({ + oid: gitObjectIndex.oid, + type: gitObjectIndex.type, + packId: gitObjectIndex.packId, + packOffset: gitObjectIndex.packOffset, + compressedSize: gitObjectIndex.compressedSize, + }) + .from(gitObjectIndex) + .orderBy(gitObjectIndex.packId, gitObjectIndex.packOffset) + .all(); + + const objects: ObjectMetadata[] = []; + let commits = 0; + let trees = 0; + let blobs = 0; + let tags = 0; + + for (const row of rows) { + objects.push({ + oid: row.oid, + type: row.type, + packId: row.packId, + packOffset: row.packOffset, + compressedSize: row.compressedSize, + }); + + switch (row.type) { + case "commit": + commits += 1; + break; + case "tree": + trees += 1; + break; + case "blob": + blobs += 1; + break; + case "tag": + tags += 1; + break; + default: + break; + } + } + + return { objects, commits, trees, blobs, tags }; + }, + catch: (cause) => + new WalkError({ + code: "DB_ERROR", + message: `Failed to query objects: ${String(cause)}`, + }), + }); +} + +export type WalkOptions = { + db: DB; + objectStore: ObjectStore; + onProgress?: (count: number) => void; +}; + +/** + * Walk objects for an incremental fetch. + * Uses commit_graph cache to avoid R2 reads for commits. + * + * Algorithm: + * 1. Get ancestor closure from haves (single recursive CTE) + * 2. Walk from wants, stopping at complete set (single recursive CTE) + * 3. Expand trees to discover blobs (batched R2 reads) + * 4. Return all objects with packfile metadata + */ +export async function walkObjectsIncremental( + wants: string[], + haves: string[], + options: WalkOptions +): Promise> { + const { db, objectStore, onProgress } = options; + + return Result.gen(async function* () { + // Step 1: Mark all ancestors of haves as complete + const complete = + haves.length > 0 + ? yield* Result.await(getAncestorClosure(db, haves)) + : new Set(); + + onProgress?.(complete.size); + + // Step 2: Walk from wants, collecting commits and their trees + const walkResult = yield* Result.await(walkFromWants(db, wants, complete)); + + onProgress?.(complete.size + walkResult.commits.length); + + // Step 3: Expand trees to get all tree and blob OIDs + const treeBlobs = yield* Result.await( + expandTrees(objectStore, walkResult.trees, onProgress) + ); + + // Step 4: Get metadata for all objects + const allOids = new Set([ + ...walkResult.commits, + ...treeBlobs.trees, + ...treeBlobs.blobs, + ]); + + const objects = yield* Result.await(getObjectsMetadata(db, [...allOids])); + + return Result.ok({ + objects, + commits: walkResult.commits.length, + trees: treeBlobs.trees.length, + blobs: treeBlobs.blobs.length, + tags: 0, + }); + }); +} + +/** + * Get all ancestors of the given OIDs using a recursive CTE. + * This marks the "complete" set for incremental fetches. + * + * Note: Uses raw SQL because Drizzle doesn't support recursive CTEs with json_each. + */ +async function getAncestorClosure( + db: DB, + roots: string[] +): Promise, WalkError>> { + if (roots.length === 0) { + return Result.ok(new Set()); + } + + return Result.tryPromise({ + try: async () => { + // First verify which roots exist in commit_graph + const existingRoots = db + .select({ oid: commitGraph.oid }) + .from(commitGraph) + .where(inArray(commitGraph.oid, roots)) + .all(); + + if (existingRoots.length === 0) { + return new Set(); + } + + // Build IN clause with proper escaping for recursive CTE + const quotedRoots = existingRoots.map((r) => `'${r.oid}'`).join(", "); + + const rows = db.all<{ oid: string }>( + sql.raw(` + WITH RECURSIVE ancestors(oid) AS ( + SELECT oid FROM commit_graph WHERE oid IN (${quotedRoots}) + UNION + SELECT j.value + FROM ancestors a + JOIN commit_graph cg ON cg.oid = a.oid + JOIN json_each(cg.parent_oids) j + ) + SELECT oid FROM ancestors + `) + ); + + return new Set(rows.map((r) => r.oid)); + }, + catch: (cause) => + new WalkError({ + code: "DB_ERROR", + message: `Failed to get ancestor closure: ${String(cause)}`, + }), + }); +} + +type WalkFromWantsResult = { + commits: string[]; + trees: string[]; +}; + +/** + * Walk from wants, stopping when hitting the complete set. + * Returns commit OIDs and their associated tree OIDs. + * + * Note: Uses raw SQL because Drizzle doesn't support recursive CTEs with json_each. + */ +async function walkFromWants( + db: DB, + wants: string[], + complete: Set +): Promise> { + if (wants.length === 0) { + return Result.ok({ commits: [], trees: [] }); + } + + return Result.tryPromise({ + try: async () => { + // First verify which wants exist in commit_graph + const existingWants = db + .select({ oid: commitGraph.oid }) + .from(commitGraph) + .where(inArray(commitGraph.oid, wants)) + .all(); + + if (existingWants.length === 0) { + return { commits: [], trees: [] }; + } + + // Build IN clause with proper escaping for recursive CTE + const quotedWants = existingWants.map((w) => `'${w.oid}'`).join(", "); + + const rows = db.all<{ oid: string; tree_oid: string }>( + sql.raw(` + WITH RECURSIVE walk(oid, tree_oid) AS ( + SELECT oid, tree_oid FROM commit_graph WHERE oid IN (${quotedWants}) + UNION + SELECT cg.oid, cg.tree_oid + FROM walk w + JOIN json_each((SELECT parent_oids FROM commit_graph WHERE oid = w.oid)) j + JOIN commit_graph cg ON cg.oid = j.value + ) + SELECT DISTINCT oid, tree_oid FROM walk + `) + ); + + const commits: string[] = []; + const trees: string[] = []; + const seenTrees = new Set(); + + for (const row of rows) { + if (!complete.has(row.oid)) { + commits.push(row.oid); + + if (!seenTrees.has(row.tree_oid)) { + seenTrees.add(row.tree_oid); + trees.push(row.tree_oid); + } + } + } + + return { commits, trees }; + }, + catch: (cause) => + new WalkError({ + code: "DB_ERROR", + message: `Failed to walk from wants: ${String(cause)}`, + }), + }); +} + +type ExpandTreesResult = { + trees: string[]; + blobs: string[]; +}; + +/** + * Expand trees to discover all tree and blob OIDs. + * Requires R2 reads to parse tree content (batched by packfile). + */ +async function expandTrees( + objectStore: ObjectStore, + rootTrees: string[], + onProgress?: (count: number) => void +): Promise> { + if (rootTrees.length === 0) { + return Result.ok({ trees: [], blobs: [] }); + } + + const allTrees = new Set(rootTrees); + const allBlobs = new Set(); + const pending = [...rootTrees]; + let processed = 0; + + while (pending.length > 0) { + const batch = pending.splice(0, 100); + + for (const treeOid of batch) { + const objectResult = await objectStore.read(treeOid); + if (objectResult.isErr()) { + return Result.err( + new WalkError({ + code: "OBJECT_NOT_FOUND", + message: `Failed to read tree ${treeOid}: ${objectResult.error.message}`, + }) + ); + } + + const entries = parseTreeEntries(objectResult.value.data); + if (entries.isErr()) { + return Result.err( + new WalkError({ + code: "TRAVERSAL_ERROR", + message: `Failed to parse tree ${treeOid}: ${entries.error.message}`, + }) + ); + } + + for (const entry of entries.value) { + if (entry.mode === "40000" || entry.mode === "040000") { + if (!allTrees.has(entry.oid)) { + allTrees.add(entry.oid); + pending.push(entry.oid); + } + } else { + allBlobs.add(entry.oid); + } + } + + processed += 1; + onProgress?.(processed); + } + } + + return Result.ok({ + trees: [...allTrees], + blobs: [...allBlobs], + }); +} + +/** + * Get object metadata for a list of OIDs. + * Orders by pack_id, pack_offset for optimal R2 reads. + */ +async function getObjectsMetadata( + db: DB, + oids: string[] +): Promise> { + if (oids.length === 0) { + return Result.ok([]); + } + + return Result.tryPromise({ + try: async () => { + const rows = db + .select({ + oid: gitObjectIndex.oid, + type: gitObjectIndex.type, + packId: gitObjectIndex.packId, + packOffset: gitObjectIndex.packOffset, + compressedSize: gitObjectIndex.compressedSize, + }) + .from(gitObjectIndex) + .where(inArray(gitObjectIndex.oid, oids)) + .orderBy(gitObjectIndex.packId, gitObjectIndex.packOffset) + .all(); + + return rows.map((row) => ({ + oid: row.oid, + type: row.type, + packId: row.packId, + packOffset: row.packOffset, + compressedSize: row.compressedSize, + })); + }, + catch: (cause) => + new WalkError({ + code: "DB_ERROR", + message: `Failed to get object metadata: ${String(cause)}`, + }), + }); +} + +/** + * Parse tree entries from raw tree object data. + * + * Tree format: + * ``` + * SP NUL <20-byte-oid> + * ``` + * Repeated for each entry. + */ +export function parseTreeEntries( + data: Uint8Array +): Result { + const entries: TreeEntry[] = []; + let offset = 0; + + while (offset < data.length) { + const spaceIdx = data.indexOf(0x20, offset); + if (spaceIdx === -1) { + return Result.err( + new WalkError({ + code: "TRAVERSAL_ERROR", + message: "Invalid tree entry: missing space", + }) + ); + } + + const mode = new TextDecoder().decode(data.subarray(offset, spaceIdx)); + + const nullIdx = data.indexOf(0x00, spaceIdx + 1); + if (nullIdx === -1) { + return Result.err( + new WalkError({ + code: "TRAVERSAL_ERROR", + message: "Invalid tree entry: missing null byte", + }) + ); + } + + const name = new TextDecoder().decode(data.subarray(spaceIdx + 1, nullIdx)); + + const oidStart = nullIdx + 1; + const oidEnd = oidStart + 20; + if (oidEnd > data.length) { + return Result.err( + new WalkError({ + code: "TRAVERSAL_ERROR", + message: "Invalid tree entry: truncated OID", + }) + ); + } + + const oidBytes = data.subarray(oidStart, oidEnd); + const oid = Array.from(oidBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + entries.push({ mode, name, oid }); + offset = oidEnd; + } + + return Result.ok(entries); +} + +/** + * Validate that all wanted OIDs exist as advertised refs. + */ +export function validateWants( + wants: string[], + advertisedOids: Set +): Result { + for (const want of wants) { + if (!advertisedOids.has(want)) { + return Result.err( + new WalkError({ + code: "INVALID_WANT", + message: `Object ${want} was not advertised`, + }) + ); + } + } + + return Result.ok(undefined); +} + +/** + * Filter OIDs to only those that exist in the object store. + */ +export async function filterExistingOids( + objectStore: ObjectStore, + oids: string[] +): Promise { + const existing: string[] = []; + + for (const oid of oids) { + if (await objectStore.has(oid)) { + existing.push(oid); + } + } + + return existing; +} diff --git a/apps/web/src/git/upload-pack/pack-stream.ts b/apps/web/src/git/upload-pack/pack-stream.ts new file mode 100644 index 0000000..957c26c --- /dev/null +++ b/apps/web/src/git/upload-pack/pack-stream.ts @@ -0,0 +1,302 @@ +/** + * Streaming pack file generation for upload-pack. + * + * Designed for CF Workers constraints: + * - 128MB memory limit → cannot buffer full pack + * - Must stream → write header, then objects one-by-one + * - SHA1 checksum → incremental hash as we write + * + * Pack format: + * - 4 bytes: "PACK" magic + * - 4 bytes: version (2) big-endian + * - 4 bytes: object count big-endian + * - [object entries...] variable + * - 20 bytes: SHA1 checksum of all preceding bytes + */ + +import { createHash, type Hash } from "node:crypto"; +import { deflateSync } from "node:zlib"; +import { Result, TaggedError } from "better-result"; +import type { GitObjectType } from "@/git/object"; + +const PACK_MAGIC = new Uint8Array([0x50, 0x41, 0x43, 0x4b]); // "PACK" +const PACK_VERSION = 2; + +const TYPE_TO_NUM: Record = { + commit: 1, + tree: 2, + blob: 3, + tag: 4, +}; + +export class PackWriteError extends TaggedError("PackWriteError")<{ + message: string; +}>() {} + +/** + * Incremental SHA1 hasher for pack checksum. + * Wraps node:crypto's createHash with a simpler interface. + */ +class IncrementalSha1 { + private readonly hasher: Hash; + + constructor() { + this.hasher = createHash("sha1"); + } + + update(chunk: Uint8Array): void { + this.hasher.update(chunk); + } + + digest(): Uint8Array { + return new Uint8Array(this.hasher.digest()); + } +} + +/** + * Streaming pack file writer. + * + * Usage: + * ```typescript + * const writer = new PackStreamWriter(writable, objectCount); + * await writer.writeHeader(); + * for (const obj of objects) { + * await writer.writeObject(obj.type, obj.data); + * } + * await writer.finalize(); + * ``` + */ +export class PackStreamWriter { + private readonly sha1: IncrementalSha1; + private readonly writer: WritableStreamDefaultWriter; + private readonly objectCount: number; + private written = 0; + + constructor(writable: WritableStream, objectCount: number) { + this.sha1 = new IncrementalSha1(); + this.writer = writable.getWriter(); + this.objectCount = objectCount; + } + + /** + * Write pack header (magic + version + object count). + * Must be called before any writeObject calls. + */ + async writeHeader(): Promise> { + return Result.tryPromise({ + try: async () => { + const header = new Uint8Array(12); + + // Magic "PACK" + header.set(PACK_MAGIC, 0); + + // Version 2 (big-endian) + header[4] = 0; + header[5] = 0; + header[6] = 0; + header[7] = PACK_VERSION; + + // Object count (big-endian) + header[8] = (this.objectCount >>> 24) & 0xff; + header[9] = (this.objectCount >>> 16) & 0xff; + header[10] = (this.objectCount >>> 8) & 0xff; + header[11] = this.objectCount & 0xff; + + await this.write(header); + }, + catch: (cause) => + new PackWriteError({ + message: `Failed to write pack header: ${String(cause)}`, + }), + }); + } + + /** + * Write a single object entry. + * + * Object entry format: + * - Variable-length type+size header + * - zlib-compressed data + */ + async writeObject( + type: GitObjectType, + data: Uint8Array + ): Promise> { + return Result.tryPromise({ + try: async () => { + const typeNum = TYPE_TO_NUM[type]; + const size = data.length; + + // Encode type+size header + const header = encodeTypeSize(typeNum, size); + + // Compress data + const compressed = deflateSync(data); + + // Write header + compressed data + await this.write(header); + await this.write(compressed); + + this.written += 1; + }, + catch: (cause) => + new PackWriteError({ + message: `Failed to write object: ${String(cause)}`, + }), + }); + } + + /** + * Finalize the pack by writing the SHA1 checksum. + * Must be called after all objects are written. + */ + async finalize(): Promise> { + if (this.written !== this.objectCount) { + return Result.err( + new PackWriteError({ + message: `Object count mismatch: expected ${this.objectCount}, wrote ${this.written}`, + }) + ); + } + + return Result.tryPromise({ + try: async () => { + const checksum = this.sha1.digest(); + await this.writer.write(checksum); + await this.writer.close(); + }, + catch: (cause) => + new PackWriteError({ + message: `Failed to finalize pack: ${String(cause)}`, + }), + }); + } + + /** + * Close the writer without finalizing (for error cleanup). + */ + async abort(): Promise { + try { + await this.writer.abort(); + } catch { + // Ignore abort errors + } + } + + /** + * Get the number of objects written so far. + */ + get writtenCount(): number { + return this.written; + } + + /** + * Write data to the stream and update the SHA1 hash. + */ + private async write(chunk: Uint8Array): Promise { + this.sha1.update(chunk); + await this.writer.write(chunk); + } +} + +/** + * Encode type and size into variable-length header. + * + * Format: + * - First byte: bits 4-6 = type, bits 0-3 = size bits 0-3, bit 7 = continuation + * - Subsequent bytes: bits 0-6 = size bits, bit 7 = continuation + */ +function encodeTypeSize(type: number, size: number): Uint8Array { + const bytes: number[] = []; + + // First byte: type in bits 4-6, low 4 bits of size in bits 0-3 + let byte = (type << 4) | (size & 0x0f); + let remaining = size >>> 4; + + while (remaining > 0) { + bytes.push(byte | 0x80); // Set continuation bit + byte = remaining & 0x7f; + remaining >>>= 7; + } + + bytes.push(byte); // Final byte without continuation bit + + return new Uint8Array(bytes); +} + +/** + * Create a TransformStream that wraps pack data in sideband format. + * + * Sideband format: pkt-line with first byte indicating channel + * - Channel 1: pack data + * - Channel 2: progress messages + * - Channel 3: error messages + * + * Max pkt-line payload is 65516 bytes (65520 - 4 byte length prefix). + * With sideband channel byte, max data per packet is 65515 bytes. + */ +export function createSidebandStream(): TransformStream< + Uint8Array, + Uint8Array +> { + const MAX_DATA_PER_PACKET = 65_515; + + return new TransformStream({ + transform(chunk, controller) { + let offset = 0; + + while (offset < chunk.length) { + const remaining = chunk.length - offset; + const chunkSize = Math.min(remaining, MAX_DATA_PER_PACKET); + const packetLen = chunkSize + 5; // 4 byte length + 1 byte channel + data + + // Encode packet length as 4 hex digits + const lenHex = packetLen.toString(16).padStart(4, "0"); + const header = new TextEncoder().encode(lenHex); + + // Build packet: length + channel(1) + data + const packet = new Uint8Array(packetLen); + packet.set(header, 0); + packet[4] = 0x01; // Channel 1 = pack data + packet.set(chunk.subarray(offset, offset + chunkSize), 5); + + controller.enqueue(packet); + offset += chunkSize; + } + }, + }); +} + +/** + * Encode a progress message in sideband format (channel 2). + */ +export function encodeSidebandProgress(message: string): Uint8Array { + const data = new TextEncoder().encode(message); + const packetLen = data.length + 5; // 4 byte length + 1 byte channel + data + const lenHex = packetLen.toString(16).padStart(4, "0"); + const header = new TextEncoder().encode(lenHex); + + const packet = new Uint8Array(packetLen); + packet.set(header, 0); + packet[4] = 0x02; // Channel 2 = progress + packet.set(data, 5); + + return packet; +} + +/** + * Encode an error message in sideband format (channel 3). + */ +export function encodeSidebandError(message: string): Uint8Array { + const data = new TextEncoder().encode(message); + const packetLen = data.length + 5; + const lenHex = packetLen.toString(16).padStart(4, "0"); + const header = new TextEncoder().encode(lenHex); + + const packet = new Uint8Array(packetLen); + packet.set(header, 0); + packet[4] = 0x03; // Channel 3 = error + packet.set(data, 5); + + return packet; +} diff --git a/apps/web/src/routes/$owner/$repo/git-upload-pack.ts b/apps/web/src/routes/$owner/$repo/git-upload-pack.ts index 40de73f..fe53c5d 100644 --- a/apps/web/src/routes/$owner/$repo/git-upload-pack.ts +++ b/apps/web/src/routes/$owner/$repo/git-upload-pack.ts @@ -1,11 +1,29 @@ import { createFileRoute } from "@tanstack/react-router"; +import { getHybridRepoDOStub } from "@/do/hybrid-repo"; export const Route = createFileRoute("/$owner/$repo/git-upload-pack")({ server: { handlers: { - POST: async () => { - // TODO: Implement upload-pack (git fetch/clone) in HybridRepo DO - return new Response("upload-pack not yet implemented", { status: 501 }); + POST: async ({ request, params }) => { + const { owner, repo } = params; + const repoName = repo.endsWith(".git") ? repo.slice(0, -4) : repo; + + const stub = getHybridRepoDOStub(`${owner}/${repoName}`); + + const gitProtocol = request.headers.get("Git-Protocol") ?? ""; + const contentType = + request.headers.get("Content-Type") ?? + "application/x-git-upload-pack-request"; + + return stub.fetch("https://do/git-upload-pack", { + method: "POST", + body: request.body, + signal: request.signal, + headers: { + "Content-Type": contentType, + "Git-Protocol": gitProtocol, + }, + }); }, }, }, diff --git a/docs/cleanup.md b/docs/cleanup.md new file mode 100644 index 0000000..c54fd36 --- /dev/null +++ b/docs/cleanup.md @@ -0,0 +1,406 @@ +# Git Backend Rewrite Plan + +MOST IMPORTANAT: Do not create useless comments that simply describe what the code already says. Comments are for adding context around the code when that context is not immediately obvious. + +## Status Before Rewrite + +The codebase has two parallel git backend implementations: + +### Old Implementation (isomorphic-git based) + +Stores ALL git objects in Durable Objects SQLite via a virtual filesystem (DOFS). + +| File | Purpose | Lines | +| ----------------- | -------------------------------- | ----- | +| `do/repo.ts` | DO using isomorphic-git | 324 | +| `do/fs.ts` | DOFS virtual filesystem | ~200 | +| `do/cache.ts` | isomorphic-git cache adapter | ~50 | +| `do/logger.ts` | isomorphic-git logger adapter | ~30 | +| `git/service.ts` | isomorphic-git wrapper | 740 | +| `git/protocol.ts` | Protocol parsing (mixed old/new) | 693 | + +**Problems:** + +- DO SQLite has 128KB row limit - large objects fail +- No streaming - entire packfiles buffered in memory +- isomorphic-git designed for browser, not serverless + +### New Implementation (hybrid R2 + SQLite) + +Stores packfiles in R2, only metadata/refs in DO SQLite. + +| File | Purpose | Status | +| -------------------- | ---------------------------- | -------------------- | +| `do/hybrid-repo.ts` | New DO with R2 storage | Messy, needs rewrite | +| `do/pack-indexer.ts` | Custom packfile indexer | Clean, keep | +| `do/object-store.ts` | R2-backed object store | Clean, keep | +| `do/ref-store.ts` | SQLite-backed refs | Clean, keep | +| `do/r2.ts` | R2 wrapper with Result types | Clean, keep | +| `git/pack/` | Streaming packfile parser | Clean, keep | + +**Problems with hybrid-repo.ts:** + +- God class: routing + protocol + formatting + progress in one file +- Mixed error handling: Result pattern + try/catch +- Non-awaited writes in progress throttler +- Duplicated utilities (`concatUint8Arrays`) +- `@ts-expect-error` comments bypassing type safety + +**Problems with protocol.ts:** + +- 693 lines mixing receive-pack, fetch, ls-refs concerns +- No Result types for parsing +- Duplicated utilities +- Unused parameters, fragile fallbacks +- Response building scattered everywhere + +--- + +## What We're Doing + +Complete removal of old implementation + clean rewrite of protocol layer. + +### Delete (old implementation) + +``` +apps/web/src/do/repo.ts +apps/web/src/do/fs.ts +apps/web/src/do/cache.ts +apps/web/src/do/logger.ts +apps/web/src/git/service.ts +apps/web/src/git/protocol.ts +``` + +### Keep (clean hybrid components) + +``` +apps/web/src/do/pack-indexer.ts +apps/web/src/do/object-store.ts +apps/web/src/do/ref-store.ts +apps/web/src/do/r2.ts +apps/web/src/do/keys.ts +apps/web/src/do/schema/git.ts +apps/web/src/git/pack/ +apps/web/src/git/pkt.ts +apps/web/src/git/object.ts +apps/web/src/git/delta.ts +``` + +### Create (new clean architecture) + +``` +apps/web/src/lib/bytes.ts # shared byte utilities +apps/web/src/git/errors.ts # TaggedError types +apps/web/src/git/response.ts # git HTTP response builders +apps/web/src/git/sideband-writer.ts # sideband protocol abstraction +apps/web/src/git/protocol/receive-pack.ts # receive-pack parsing + encoding +apps/web/src/git/protocol/capabilities.ts # capability advertisement +apps/web/src/git/handlers/receive-pack.ts # receive-pack business logic +``` + +### Rewrite + +``` +apps/web/src/do/hybrid-repo.ts # simplify to routing only (~150 lines) +``` + +--- + +## Why We're Doing This + +1. **Eliminate dead code**: Old isomorphic-git implementation is unused, adds confusion +2. **Single source of truth**: One git backend, not two parallel implementations +3. **Clean architecture**: Separation of concerns - routing, parsing, business logic, response building +4. **Consistent error handling**: All Result, no mixed try/catch +5. **Type safety**: Remove all `@ts-expect-error` comments +6. **Maintainability**: Smaller focused modules instead of 700-line files +7. **Streaming-first**: Proper sideband writer with awaited writes + +--- + +## New Architecture + +``` + ┌─────────────────────────────────────┐ + │ HybridRepo DO │ + │ (routing + lifecycle only, ~150L) │ + └─────────────────┬───────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ /info/refs │ │ /receive-pack │ │ /upload-pack │ + │ (capability │ │ │ │ (future) │ + │ advertisement)│ │ │ │ │ + └────────┬────────┘ └────────┬────────┘ └─────────────────┘ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ capabilities │ │ parseReceive- │ + │ .ts │ │ PackRequest() │ + └─────────────────┘ └────────┬────────┘ + │ + ▼ Result + ┌─────────────────┐ + │ handleReceive- │ + │ Pack() │ + └────────┬────────┘ + │ + ┌───────────────────────────────┼───────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ PackIndexer │ │ R2.put() │ │ RefStore │ + │ .index() │ │ (store pack) │ │ .applyUpdates()│ + └─────────────────┘ └─────────────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ SidebandWriter │ ──────▶ Response stream + │ (progress/status)│ + └─────────────────┘ +``` + +--- + +## Implementation Plan + +### Phase 1: Foundation ✅ DONE + +Create shared utilities and error types. + +**1.1 `lib/bytes.ts`** + +```ts +export function concatUint8Arrays( + arrays: Uint8Array[], + totalLength: number, +): Uint8Array; +``` + +**1.2 `git/errors.ts`** + +```ts +import { TaggedError } from "better-result"; + +export class ProtocolError extends TaggedError("ProtocolError")<{ + code: + | "invalid_pkt_line" + | "malformed_command" + | "missing_flush" + | "invalid_packfile"; + detail?: string; +}> {} + +export class ReceivePackError extends TaggedError("ReceivePackError")<{ + code: "parse_failed" | "index_failed" | "upload_failed" | "ref_update_failed"; + cause?: Error; +}> {} +``` + +**1.3 `git/response.ts`** + +```ts +type GitService = "upload-pack" | "receive-pack"; + +export function gitResponse( + body: Uint8Array, + service: GitService, + type: "advertisement" | "result", +): Response; +export function gitStreamResponse( + body: ReadableStream, + service: GitService, +): Response; +``` + +### Phase 2: Sideband Abstraction ✅ DONE + +**2.1 `git/sideband-writer.ts`** + +```ts +export class SidebandWriter { + constructor( + writer: WritableStreamDefaultWriter, + enabled: boolean, + ); + + async writeData(data: Uint8Array): Promise; + async writeProgress(msg: string): Promise; + async writeError(msg: string): Promise; + async flush(): Promise; + async close(): Promise; +} + +export class ProgressReporter { + constructor(writer: SidebandWriter, throttleMs?: number); + async report(event: ProgressEvent): Promise; +} +``` + +### Phase 3: Protocol Layer ✅ DONE + +**3.1 `git/protocol/receive-pack.ts`** + +```ts +import { Result } from "better-result"; + +export type Command = { oldOid: string; newOid: string; ref: string }; + +export type ReceivePackRequest = { + commands: Command[]; + capabilities: string[]; + packfileStream: ReadableStream | null; +}; + +export function parseReceivePackRequest( + stream: ReadableStream, +): Promise>; + +export function encodeUnpackStatus(ok: boolean, error?: string): Uint8Array; +export function encodeRefStatus( + name: string, + ok: boolean, + error?: string, +): Uint8Array; +``` + +**3.2 `git/protocol/capabilities.ts`** + +```ts +export type Ref = { name: string; oid: string }; + +export function advertiseReceivePackCapabilities( + refs: Ref[], + symbolicHead: string | null, +): Uint8Array; +``` + +### Phase 4: Handler ✅ DONE + +**4.1 `git/handlers/receive-pack.ts`** + +```ts +import { Result } from "better-result"; + +export type ReceivePackContext = { + r2: R2; + refs: RefStore; + indexer: PackIndexer; + repoPath: string; +}; + +export function handleReceivePack( + ctx: ReceivePackContext, + request: ReceivePackRequest, + useSideband: boolean, +): Promise, ReceivePackError>>; +``` + +### Phase 5: Rewrite HybridRepo ✅ DONE + +Simplify to routing only: + +```ts +export class HybridRepo extends DurableObject { + // ... constructor, lifecycle methods ... + + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === "/info/refs") { + return this.handleInfoRefs(url); + } + + if (url.pathname === "/git-receive-pack") { + return this.handleReceivePack(request); + } + + return new Response("Not Found", { status: 404 }); + } + + private async handleInfoRefs(url: URL): Promise { + const service = url.searchParams.get("service"); + if (service === "git-receive-pack") { + const { refs, symbolicHead } = await this.listRefs(); + const body = advertiseReceivePackCapabilities(refs, symbolicHead); + return gitResponse(body, "receive-pack", "advertisement"); + } + return new Response("Unknown service", { status: 400 }); + } + + private async handleReceivePack(request: Request): Promise { + if (!request.body) { + return gitErrorResponse("No request body", "receive-pack"); + } + + const parseResult = await parseReceivePackRequest(request.body); + if (parseResult.isErr()) { + return gitErrorResponse(parseResult.error.message, "receive-pack"); + } + + const { commands, capabilities, packfileStream } = parseResult.value; + const useSideband = capabilities.includes("side-band-64k"); + + const result = await handleReceivePack( + { + r2: this.r2, + refs: this.refs, + indexer: this.indexer, + repoPath: this.fullName, + }, + parseResult.value, + useSideband, + ); + + if (result.isErr()) { + return gitErrorResponse(result.error.message, "receive-pack"); + } + + return gitStreamResponse(result.value, "receive-pack"); + } +} +``` + +### Phase 6: Delete Old Files ✅ DONE + +Remove after new implementation is complete and tested: + +```bash +rm apps/web/src/do/repo.ts +rm apps/web/src/do/fs.ts +rm apps/web/src/do/cache.ts +rm apps/web/src/do/logger.ts +rm apps/web/src/git/service.ts +rm apps/web/src/git/protocol.ts +``` + +--- + +## Success Criteria + +- [ ] Old implementation files deleted +- [ ] `hybrid-repo.ts` under 200 lines (routing only) +- [ ] All parsing returns `Result` +- [ ] No `@ts-expect-error` comments +- [ ] No duplicated utilities +- [ ] All `writer.write()` calls properly awaited +- [ ] `pnpm check` passes + +--- + +## Scope + +**In scope:** + +- receive-pack (git push) functionality +- Capability advertisement for receive-pack +- Clean error handling with Result types +- Streaming response with proper sideband + +**Out of scope (future work):** + +- upload-pack (git fetch/clone) +- Protocol v2 upgrade for receive-pack +- Web UI features (getTree, getBlob, etc.) diff --git a/docs/important-notes-always-read.md b/docs/important-notes-always-read.md new file mode 100644 index 0000000..090e5ff --- /dev/null +++ b/docs/important-notes-always-read.md @@ -0,0 +1,9 @@ +### Delta Resolution + +- delta resolution will be done dynamically when isomorphic-git reads the object. +- when `Fs.read` is called for a delta object, we (the fs adapter) will read the object from sqlite table, and then we recursively read its base object until we reach a non-delta object. +- after we have the full base object, we will apply all the deltas on top of it to reconstruct the full object. and cache it in Cache API for future reads. + +### Other Important Notes + +- We will not advertise "OFS_DELTA" capability, so we only have to support REF_DELTA which has base oid. diff --git a/docs/pull.md b/docs/pull.md new file mode 100644 index 0000000..20b1d57 --- /dev/null +++ b/docs/pull.md @@ -0,0 +1,1090 @@ +# Plan: git-upload-pack (fetch/clone) Implementation + +## Executive Summary + +Implement streaming git-upload-pack to support `git fetch` and `git clone` operations using the hybrid storage model (R2 packfiles + SQLite metadata). The implementation will: + +- Stream pack data directly from R2 to client (no full buffering) +- Use Result/TaggedError for all error handling +- Follow the clean architecture established in receive-pack +- **Protocol v2** (HTTP smart protocol) - command-based, stateless +- **Minimize subrequests** via commit graph cache and batch R2 reads + +--- + +## Lessons Learned from receive-pack + +| Mistake | How to Avoid | +| ---------------------------------------- | ------------------------------------------------------------- | +| God class with too many responsibilities | Small, focused modules: protocol/, handlers/, do/ | +| Mixed concerns in single file | Separate: parsing, traversal, pack-writing, orchestration | +| Buffering entire packfile | Stream chunks directly via TransformStream | +| Inconsistent error handling | Result everywhere, define error codes upfront | +| Missing type definitions | Define all types in dedicated files first | + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ HTTP Layer (Protocol v2) │ +│ GET /info/refs?service=git-upload-pack (capability discovery) │ +│ POST /git-upload-pack (commands: ls-refs, fetch) │ +└────────────────────────┬────────────────────────────────────────┘ + │ +┌────────────────────────▼────────────────────────────────────────┐ +│ git/handlers/upload-pack.ts │ +│ Orchestrates: parse request → traverse → generate pack │ +│ Detects fresh clone vs incremental fetch │ +└────────────────────────┬────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ +┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ +│ protocol/ │ │ walker/ │ │ pack-writer/ │ +│upload-pack.ts│ │object-walk.ts│ │ pack-stream.ts │ +│ parse wants │ │ Uses commit │ │ streaming pack │ +│ parse haves │ │ graph cache │ │ generation │ +│ encode caps │ │ (SQLite) │ │ batch R2 reads │ +└─────────────┘ └─────────────┘ └─────────────────┘ + │ │ + ▼ ▼ + ┌──────────────────────────────────────┐ + │ do/object-store.ts │ + │ readBatch() - coalesced R2 reads │ + │ getAllMetadata() - for fresh clone │ + └──────────────────────────────────────┘ +``` + +--- + +## Critical Optimization: Minimize Subrequests + +### The Problem + +Naive implementation for a 2,600 object repo: + +- Walk: ~600 reads (commits + trees) +- Pack generation: ~2,600 reads +- **Total: ~3,200 SQLite queries + ~3,200 × Δ R2 requests** + +### The Solution + +| Scenario | Naive | Optimized | +| ---------------------- | --------------- | ------------------------ | +| Fresh clone (2.6k obj) | ~6,000 requests | **1 SQLite + ~2 R2** | +| Fetch 1 commit | ~60 requests | **~5 SQLite + ~3 R2** | +| Fetch 100 commits | ~7,000 requests | **~200 SQLite + ~20 R2** | + +--- + +## Phase 0: Schema Changes (receive-pack modification) ✅ DONE + +**IMPORTANT:** These changes must be made to receive-pack to populate caches during push. + +### 0.1 Commit Graph Table + +Add to schema and populate during pack indexing: + +```sql +CREATE TABLE commit_graph ( + oid TEXT PRIMARY KEY, + tree_oid TEXT NOT NULL, + parent_oids TEXT NOT NULL -- JSON array, e.g. '["abc123","def456"]' or '[]' +); +``` + +**Why:** Eliminates R2 reads during commit traversal. Walk uses SQLite only for commits. + +**Why JSON array:** Enables recursive CTE with `json_each()` for efficient ancestor traversal in a single query. + +### 0.2 Repo Stats in typedStorage + +Use the existing `typedStorage` in HybridRepo DO instead of a separate table: + +```typescript +// In hybrid-repo.ts typedStorage interface, add: +interface StorageSchema { + // ... existing keys + objectCount: number; + lastPushAt: number; // timestamp +} + +// Usage: +await this.typedStorage.put("objectCount", totalObjects); +const count = await this.typedStorage.get("objectCount"); +``` + +**Why:** Instant object count for pack header without scanning. Uses existing typed storage pattern. + +### 0.3 Modification to PackIndexer + +During `processPackfile()`, after parsing each object: + +```typescript +if (obj.type === "commit") { + const parsed = parseCommitForWalk(obj.data); + await db.insert(commitGraph).values({ + oid: obj.oid, + treeOid: parsed.tree, + parentOids: parsed.parents, // Drizzle handles JSON serialization with text({ mode: 'json' }) + }); +} +``` + +**Schema definition with Drizzle:** + +```typescript +// In do/schema/git.ts +export const commitGraph = sqliteTable("commit_graph", { + oid: text("oid").primaryKey(), + treeOid: text("tree_oid").notNull(), + parentOids: text("parent_oids", { mode: "json" }).$type().notNull(), +}); +``` + +**Files to modify:** + +- `do/schema/git.ts` - Add commit_graph table +- `do/pack-indexer.ts` - Populate commit_graph during indexing +- `do/hybrid-repo.ts` - Add objectCount/lastPushAt to typedStorage + +--- + +## Phase 1: Types & Error Definitions ✅ DONE + +**Reuse existing types and errors from `git/` directory. Only add new types if not already present.** + +### Existing Types to Reuse + +- `git/errors.ts` - Extend existing error types (ProtocolError, etc.) +- `git/object.ts` - GitObjectType +- `git/pkt.ts` - Packet line utilities + +### New Types (add to `git/types.ts` if needed) + +```typescript +// Object metadata for batch fetching (may already exist in object-store) +type ObjectMetadata = { + oid: string; + type: GitObjectType; + packfileId: string; + offset: number; + size: number; +}; +``` + +### Error Handling + +Extend existing `ProtocolError` in `git/errors.ts` with new codes if needed: + +```typescript +// Add to existing ProtocolErrorCode union +| "INVALID_WANT" // Unknown OID in want +| "TRAVERSAL_ERROR" // Walk failed +| "PACK_WRITE_ERROR" // Pack generation failed +| "REPO_TOO_LARGE" // Exceeds size limit +``` + +**Do NOT create upload-pack specific error classes. Reuse existing patterns.** + +--- + +## Phase 2: Protocol Layer (v2) ✅ DONE + +**File:** `git/protocol/upload-pack-v2.ts` + +### 2.1 Protocol v2 Overview + +Git protocol v2 is **command-based** and **stateless**. Key differences from v1: + +| Aspect | v1 | v2 | +| ----------- | --------------------------- | -------------------------- | +| Discovery | Refs + caps in one response | Explicit `ls-refs` command | +| Negotiation | Interleaved want/have/ACK | Single `fetch` command | +| Sections | Implicit | Explicit delimiters (0001) | +| Stateless | No | Yes (all state in request) | + +**Special packets:** + +- `0000` = flush-pkt (end of section) +- `0001` = delim-pkt (section separator) +- `0002` = response-end-pkt (end of response) + +### 2.2 Capability Advertisement (Info/Refs) + +Client sends `Git-Protocol: version=2` header. Server responds with capabilities: + +```typescript +// Server capabilities for v2 +const V2_CAPABILITIES = [ + "version 2", + "ls-refs", // Command: list references + "fetch", // Command: fetch objects + "server-option", // Allow client to send server-specific options +]; + +// Fetch command capabilities (sent with fetch command response) +const FETCH_CAPS = [ + "no-progress", // Client can suppress progress + // Note: We do NOT advertise ofs-delta, shallow, include-tag, thin-pack for MVP +]; + +function advertiseV2Capabilities(): Uint8Array; +// Output format: +// PKT-LINE("version 2\n") +// PKT-LINE("ls-refs\n") +// PKT-LINE("fetch\n") +// PKT-LINE("server-option\n") +// flush-pkt (0000) +``` + +### 2.3 Command: ls-refs + +Client requests refs with optional filtering: + +**Request format:** + +``` +command=ls-refs +0001 # delim-pkt +peel # (optional) Include peeled OIDs +symrefs # (optional) Include symref targets +ref-prefix refs/heads/ # (optional) Filter by prefix +ref-prefix refs/tags/ +0000 # flush-pkt +``` + +**Response format:** + +``` + SP [SP symref-target:] [SP peeled:] LF +... +0000 # flush-pkt +``` + +**Example:** + +``` +PKT-LINE("abc123 refs/heads/main symref-target:refs/heads/main\n") +PKT-LINE("def456 refs/heads/feature\n") +PKT-LINE("789abc refs/tags/v1.0 peeled:abc123\n") +flush-pkt +``` + +```typescript +type LsRefsRequest = { + peel: boolean; + symrefs: boolean; + refPrefixes: string[]; +}; + +function parseLsRefsRequest( + stream: ReadableStream, +): Promise>; + +function encodeLsRefsResponse( + refs: Ref[], + options: { peel: boolean; symrefs: boolean }, +): Uint8Array; +``` + +### 2.4 Command: fetch + +Client sends wants/haves in a single request: + +**Request format:** + +``` +command=fetch +0001 # delim-pkt +no-progress # (optional) Suppress progress +want # Objects client wants +want +have # Objects client has +have +done # Client ready for packfile (omit for negotiation-only) +0000 # flush-pkt +``` + +**Note:** We do NOT support `thin-pack` or `ofs-delta` capabilities. All objects are sent as non-delta. + +**Response format (with `done`):** + +``` +packfile # Section marker +0001 # delim-pkt +[sideband-multiplexed data] # Channel 1=pack, 2=progress, 3=error +0000 # flush-pkt +``` + +**Response format (without `done` - negotiation only):** + +``` +acknowledgments # Section marker +NAK # Or: ACK +0001 # delim-pkt (if more sections) +0000 # Or 0002 (response-end) +``` + +**Key rule:** If client sends `done`, skip `acknowledgments` section and go straight to `packfile`. + +```typescript +type FetchRequest = { + wants: string[]; + haves: string[]; + done: boolean; + capabilities: { + noProgress: boolean; + // Note: thinPack, ofsDelta, includeTag not supported in MVP + }; +}; + +function parseFetchRequest( + stream: ReadableStream, +): Promise>; +``` + +### 2.5 Response Encoding + +```typescript +// Section markers +function encodeAcknowledgments(acks: string[], ready: boolean): Uint8Array; +// Output: +// PKT-LINE("acknowledgments\n") +// PKT-LINE("ACK \n") for each ack +// PKT-LINE("ready\n") if ready, else PKT-LINE("NAK\n") +// delim-pkt (0001) or flush-pkt (0000) + +function encodePackfileSection(): Uint8Array; +// Output: +// PKT-LINE("packfile\n") +// [followed by sideband-multiplexed pack data] + +// Sideband channels (same as v1) +// Channel 1: Pack data +// Channel 2: Progress messages +// Channel 3: Error messages +``` + +### 2.6 HTTP Headers + +**Request:** + +``` +POST /$owner/$repo/git-upload-pack HTTP/1.1 +Content-Type: application/x-git-upload-pack-request +Git-Protocol: version=2 +``` + +**Response:** + +``` +HTTP/1.1 200 OK +Content-Type: application/x-git-upload-pack-result +``` + +--- + +## Phase 3: Object Traversal ✅ DONE + +**File:** `git/upload-pack/object-walk.ts` + +### 3.1 Fresh Clone Path (FAST) + +**No graph walking needed!** Query all objects directly from SQLite. + +```typescript +async function getAllObjectsForClone( + db: DrizzleDB, +): Promise>; + +// Implementation: +// SELECT oid, type, packfile_id, offset, size FROM objects +// ORDER BY packfile_id, offset -- Optimal for sequential R2 reads +``` + +**Subrequests: 1 SQLite query, 0 R2 reads for discovery** + +### 3.2 Incremental Fetch Path + +Uses commit_graph cache to avoid R2 reads for commits: + +```typescript +type WalkResult = { + objects: ObjectMetadata[]; // Already has packfile info for batching + commits: number; + trees: number; + blobs: number; +}; + +async function walkObjectsIncremental( + wants: string[], + haves: string[], + db: DrizzleDB, + objects: ObjectStore, + onProgress?: (count: number) => void, +): Promise>; +``` + +**Algorithm:** + +``` +1. Mark haves as complete (SINGLE SQLite query using recursive CTE + json_each) + + WITH RECURSIVE ancestors(oid) AS ( + -- Base case: the haves themselves + SELECT oid FROM commit_graph WHERE oid IN (?, ?, ...) + UNION + -- Recursive case: parents of ancestors (using json_each to expand JSON array) + SELECT j.value + FROM ancestors a + JOIN commit_graph cg ON cg.oid = a.oid + JOIN json_each(cg.parent_oids) j + ) + SELECT oid FROM ancestors + +2. Walk from wants (SINGLE SQLite query - same pattern) + - Recursive CTE from wants, stopping when hitting complete set + - Collect all commit OIDs and their tree_oids + +3. For trees: query type from objects table (SQLite, no R2!) + - Get subtree OIDs by reading tree content from R2 (batched by packfile) + - Recursively expand subtrees + +4. Collect blob OIDs from tree entries (no R2 read needed for blobs themselves) + +5. Return ObjectMetadata with packfile info for batch fetching +``` + +**Key insight:** Object types are in SQLite `objects` table, so checking if an OID is a tree/blob/commit is a SQLite query, NOT an R2 read. + +### 3.3 Ancestor Query (Single Recursive CTE) + +```typescript +// Mark all ancestors of haves as complete - SINGLE QUERY +async function getAncestorClosure( + db: DrizzleDB, + roots: string[], +): Promise>; + +// Implementation using raw SQL for recursive CTE with json_each: +const result = await db.run(sql` + WITH RECURSIVE ancestors(oid) AS ( + SELECT oid FROM commit_graph WHERE oid IN ${roots} + UNION + SELECT j.value + FROM ancestors a + JOIN commit_graph cg ON cg.oid = a.oid + JOIN json_each(cg.parent_oids) j + ) + SELECT oid FROM ancestors +`); +return new Set(result.rows.map((r) => r.oid)); +``` + +### 3.4 Walk From Wants (Single Recursive CTE) + +```typescript +// Walk from wants, excluding complete set - returns commits + their trees +async function walkFromWants( + db: DrizzleDB, + wants: string[], + complete: Set, +): Promise<{ commits: string[]; trees: string[] }>; + +// Implementation: CTE that stops when hitting complete set +// Returns commit OIDs and their tree_oids +``` + +### 3.5 Tree Parser (still needed for tree→blob discovery) + +```typescript +type TreeEntry = { + mode: string; + name: string; + oid: string; +}; + +function parseTreeEntries(data: Uint8Array): Result; +``` + +--- + +## Phase 4: Batch Object Reading ✅ DONE + +**File:** `do/object-store.ts` (modifications) + +### 4.1 New Methods + +```typescript +// Get all object metadata (for fresh clone) +async getAllMetadata(): Promise> + +// Batch read with packfile-aware coalescing +async readBatch( + oids: string[], + onObject: (oid: string, type: GitObjectType, data: Uint8Array) => Promise +): Promise> +``` + +### 4.2 Batch Read Implementation + +```typescript +async readBatch(oids, onObject) { + // 1. Single SQLite query for all metadata + const metadata = await this.getMetadataForOids(oids) + + // 2. Group by packfile, sort by offset + const byPackfile = groupByPackfile(metadata) + + // 3. For each packfile, fetch spanning range + for (const [packfileId, objects] of byPackfile) { + const minOffset = Math.min(...objects.map(o => o.offset)) + const maxEnd = Math.max(...objects.map(o => o.offset + o.size)) + + // Single R2 range request + const data = await this.r2.get(packfileId, { + range: { offset: minOffset, length: maxEnd - minOffset } + }) + + // Extract individual objects from fetched range + for (const obj of objects) { + const localOffset = obj.offset - minOffset + const compressed = data.slice(localOffset, localOffset + obj.size) + const decompressed = await this.decompress(compressed) + const resolved = await this.resolveDelta(obj, decompressed) + await onObject(obj.oid, resolved.type, resolved.data) + } + } +} +``` + +**Subrequests:** 1 SQLite + P R2 (where P = number of packfiles, usually 1-3) + +--- + +## Phase 5: Streaming Pack Generation ✅ DONE + +**File:** `git/upload-pack/pack-stream.ts` + +### 5.1 Design Constraints (CF Workers) + +- **128MB memory limit** → cannot buffer full pack +- **Must stream** → write header, then objects one-by-one +- **SHA1 checksum** → incremental hash as we write + +### 5.2 Pack Format + +``` +PACK # 4 bytes magic "PACK" +version (2) # 4 bytes big-endian +object count # 4 bytes big-endian +[object entries...] # Variable +SHA1 checksum # 20 bytes trailer +``` + +### 5.3 Object Entry Format (non-delta) + +``` +[type+size header] # Variable-length encoding + - bits 4-6: type (1=commit, 2=tree, 3=blob, 4=tag) + - bits 0-3: size bits 0-3 + - if MSB set: continue with more size bits +[zlib-compressed data] # deflate(raw object data) +``` + +### 5.4 Streaming Writer + +```typescript +class PackStreamWriter { + private sha1: IncrementalSha1; + private writer: WritableStreamDefaultWriter; + private written: number = 0; + + constructor(writable: WritableStream, objectCount: number); + + async writeHeader(): Promise>; + + async writeObject( + type: GitObjectType, + data: Uint8Array, + ): Promise>; + + async finalize(): Promise>; +} +``` + +### 5.5 Incremental SHA1 + +```typescript +// Use node:crypto which supports .update() for incremental hashing +// CF Workers fully supports node:crypto +import { createHash } from "node:crypto"; + +class IncrementalSha1 { + private hasher = createHash("sha1"); + + update(chunk: Uint8Array): void { + this.hasher.update(chunk); + } + + digest(): Uint8Array { + return new Uint8Array(this.hasher.digest()); + } +} +``` + +**Note:** This pattern is already used in `git/pack/index.ts` for packfile checksum validation. + +--- + +## Phase 6: Handler Integration ✅ DONE + +**File:** `git/handlers/upload-pack.ts` + +### 6.1 Command Router + +Protocol v2 requires routing based on command: + +```typescript +async function handleUploadPackV2( + ctx: { + db: DrizzleDB; + refs: RefStore; + objects: ObjectStore; + }, + request: Request, +): Promise> { + // Parse the command from request body + const command = yield * parseCommand(request.body); + + switch (command.type) { + case "ls-refs": + return handleLsRefs(ctx, command.args); + case "fetch": + return handleFetch(ctx, command.args); + default: + return Result.err( + new UploadPackError({ + code: "PROTOCOL_ERROR", + message: `Unknown command: ${command.type}`, + }), + ); + } +} +``` + +### 6.2 Handle ls-refs Command + +```typescript +async function handleLsRefs( + ctx: Context, + args: LsRefsRequest, +): Promise> { + const refs = await ctx.refs.list(); + + // Filter by prefixes if specified + let filtered = refs; + if (args.refPrefixes.length > 0) { + filtered = refs.filter((r) => + args.refPrefixes.some((p) => r.name.startsWith(p)), + ); + } + + const response = encodeLsRefsResponse(filtered, { + peel: args.peel, + symrefs: args.symrefs, + }); + + return Result.ok( + new Response(response, { + headers: { "Content-Type": "application/x-git-upload-pack-result" }, + }), + ); +} +``` + +### 6.3 Handle fetch Command + +```typescript +async function handleFetch( + ctx: Context, + req: FetchRequest, +): Promise> { + // If no 'done', return acknowledgments only (negotiation phase) + if (!req.done) { + return handleFetchNegotiation(ctx, req); + } + + // Client sent 'done' - skip acknowledgments, send packfile directly + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + const progress = new ProgressReporter((p) => writeProgress(writer, p)); + + ctx.waitUntil( + (async () => { + const result = await processFetchWithPack(ctx, req, writer, progress); + if (result.isErr()) { + await writeSidebandError(writer, result.error.message); + } + await writer.close(); + })(), + ); + + return Result.ok( + new Response(readable, { + headers: { "Content-Type": "application/x-git-upload-pack-result" }, + }), + ); +} + +async function handleFetchNegotiation( + ctx: Context, + req: FetchRequest, +): Promise> { + // Check which haves we have in common + const validHaves = await filterExistingOids(ctx.objects, req.haves); + + const response = encodeAcknowledgments( + validHaves, + validHaves.length > 0, // ready if we have common objects + ); + + return Result.ok( + new Response(response, { + headers: { "Content-Type": "application/x-git-upload-pack-result" }, + }), + ); +} +``` + +### 6.4 Process Fetch with Packfile + +```typescript +async function processFetchWithPack( + ctx: Context, + req: FetchRequest, + writer: WritableStreamDefaultWriter, + progress: ProgressReporter, +): Promise> { + return Result.gen(async function* () { + // 1. Validate wants are advertised refs + const refs = await ctx.refs.list(); + const advertisedOids = new Set(refs.map((r) => r.oid)); + for (const want of req.wants) { + if (!advertisedOids.has(want)) { + return Result.err( + new UploadPackError({ + code: "INVALID_WANT", + message: `Object ${want} was not advertised`, + }), + ); + } + } + + // 2. Determine if fresh clone or incremental fetch + const isFreshClone = req.haves.length === 0; + + let objectsToSend: ObjectMetadata[]; + + if (isFreshClone) { + // FAST PATH: No walking, just query all objects + progress.report({ type: "counting", current: 0 }); + objectsToSend = yield* ctx.objects.getAllMetadata(); + progress.report({ type: "counting", current: objectsToSend.length }); + } else { + // Incremental: Walk using commit_graph cache + const walkResult = yield* walkObjectsIncremental( + req.wants, + req.haves, + ctx.db, + ctx.objects, + (count) => progress.report({ type: "counting", current: count }), + ); + objectsToSend = walkResult.objects; + } + + // 3. Write packfile section header + await writer.write(encodePackfileSection()); + + // 4. Stream packfile with sideband multiplexing + const packWriter = new PackStreamWriter(writer, objectsToSend.length); + yield* packWriter.writeHeader(); + + let written = 0; + yield* ctx.objects.readBatch( + objectsToSend.map((o) => o.oid), + async (oid, type, data) => { + await packWriter.writeObject(type, data); + written++; + if (!req.capabilities.noProgress) { + progress.report({ + type: "writing", + current: written, + total: objectsToSend.length, + }); + } + }, + ); + + yield* packWriter.finalize(); + + // 5. Flush to end response + await writer.write(FLUSH_PKT); + + return Result.ok(undefined); + }); +} +``` + +```` + +--- + +## Phase 7: DO Integration ✅ DONE + +**File:** `do/hybrid-repo.ts` (modifications) + +### 7.1 Protocol Version Detection + +```typescript +// Check Git-Protocol header for version +private getProtocolVersion(request: Request): 1 | 2 { + const proto = request.headers.get("Git-Protocol"); + if (proto?.includes("version=2")) return 2; + return 1; // Fallback (but we only support v2) +} +```` + +### 7.2 New Route for git-upload-pack + +```typescript +// In fetch() method switch +case "git-upload-pack": + return this.routeUploadPackV2(request); + +private async routeUploadPackV2(request: Request): Promise { + const version = this.getProtocolVersion(request); + if (version !== 2) { + return new Response("Protocol version 2 required", { status: 400 }); + } + + const result = await handleUploadPackV2( + { + db: this.db, + refs: this.refs, + objects: this.objects, + }, + request, + ); + + if (result.isErr()) { + // Return error via sideband channel 3 + return new Response(encodeSidebandError(result.error.message), { + status: 200, // Git expects 200 even for errors + headers: { + "Content-Type": "application/x-git-upload-pack-result", + }, + }); + } + + return result.value; +} +``` + +### 7.3 Info/Refs for upload-pack (v2 capability advertisement) + +```typescript +// In routeInfoRefs() +case "git-upload-pack": + const version = this.getProtocolVersion(request); + + if (version === 2) { + // v2: Return capability advertisement + return new Response(advertiseV2Capabilities(), { + headers: { + "Content-Type": "application/x-git-upload-pack-advertisement", + "Cache-Control": "no-cache", + }, + }); + } + + // v1: Not supported + return new Response("Protocol version 2 required", { status: 400 }); +``` + +### 7.4 Response Format Summary + +**GET /info/refs?service=git-upload-pack (v2):** + +``` +001e# service=git-upload-pack +0000 +000bversion 2 +000cls-refs +000bfetch +0014server-option +0000 +``` + +**POST /git-upload-pack (ls-refs command):** + +``` +# Request: +0014command=ls-refs +0001 +0009peel +000csymrefs +0000 + +# Response: +003f7c8a2b... refs/heads/main symref-target:HEAD +002a8d9e3f... refs/heads/feature +0000 +``` + +**POST /git-upload-pack (fetch command with done):** + +``` +# Request: +0012command=fetch +0001 +0032want 7c8a2b... +0032want 8d9e3f... +0032have abc123... +0009done +0000 + +# Response: +0010packfile +0001 +[sideband-multiplexed pack data] +0000 +``` + +--- + +## Phase 8: Route Handler ✅ DONE + +**File:** `routes/$owner/$repo/git-upload-pack.ts` + +```typescript +import { createAPIFileRoute } from "@tanstack/react-start/api"; +import { getRepoStub } from "@/lib/repo-stub"; + +export const Route = createAPIFileRoute("/$owner/$repo/git-upload-pack")({ + POST: async ({ request, params }) => { + const stub = await getRepoStub(params.owner, params.repo); + return stub.fetch( + new Request("http://do/git-upload-pack", { + method: "POST", + body: request.body, + headers: request.headers, + duplex: "half", + }), + ); + }, +}); +``` + + + +## File Structure (Final) + +``` +apps/web/src/ +├── do/ +│ ├── schema/git.ts # Add commit_graph table +│ ├── pack-indexer.ts # Populate commit_graph during push +│ ├── object-store.ts # Add getAllMetadata(), readBatch() +│ └── hybrid-repo.ts # Add routeUploadPackV2, typedStorage keys +├── git/ +│ ├── errors.ts # Extend with new error codes (INVALID_WANT, etc.) +│ ├── upload-pack/ +│ │ ├── object-walk.ts # getAllObjectsForClone, walkObjectsIncremental +│ │ └── pack-stream.ts # PackStreamWriter (uses node:crypto for SHA1) +│ ├── protocol/ +│ │ └── upload-pack-v2.ts # v2 command parsing & response encoding +│ │ # - advertiseV2Capabilities() +│ │ # - parseLsRefsRequest() / encodeLsRefsResponse() +│ │ # - parseFetchRequest() / encodePackfileSection() +│ │ # - encodeAcknowledgments() +│ ├── parsers/ +│ │ └── tree.ts # parseTreeEntries (reusable) +│ └── handlers/ +│ └── upload-pack.ts # handleUploadPackV2, handleLsRefs, handleFetch +└── routes/$owner/$repo/ + └── git-upload-pack.ts # Forward to DO +``` + +--- + +## Subrequest Summary + +| Scenario | SQLite Queries | R2 Requests | Notes | +| ----------------- | -------------- | ------------- | ------------------------------------------- | +| Fresh clone | 1 | P (packfiles) | No graph walk, just `SELECT * FROM objects` | +| Fetch 1 commit | 3-4 | ~2 | 2 recursive CTEs + tree expansion | +| Fetch 100 commits | 3-5 | ~5-10 | Same CTEs, more trees to expand | + +**SQLite breakdown for incremental fetch:** + +1. Recursive CTE: ancestor closure from haves (1 query) +2. Recursive CTE: walk from wants (1 query) +3. Get tree OIDs' packfile metadata (1 query) +4. Get all objects' metadata for pack (1 query) + +**R2 breakdown:** + +- Only tree content reads (to discover blob OIDs) +- Batched by packfile with range coalescing + +Where P = number of packfiles (typically 1-3 after periodic repacking) + +--- + +## Deferred (Not MVP) + +| Feature | Why Defer | +| ---------------------- | ---------------------------------------- | +| Delta compression | Adds complexity; non-delta works for MVP | +| Thin packs | Requires tracking client's objects | +| Shallow clones | Niche use case; requires depth tracking | +| Filter (partial clone) | Complex; requires blob filtering | +| include-tag | Client can fetch tags explicitly | +| Protocol v1 | Deprecated; v2 is simpler and stateless | + +**Note:** We do not support `ofs-delta` capability. All pack objects are sent as non-delta. + +--- + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +| ----------------------------------------- | ------ | ----------------------------------------------------- | +| Large repos (>50k objects) exceed 30s CPU | High | Hard limit with clear error; chunked generation in v2 | +| Tree parsing still needs R2 | Medium | Acceptable; trees are fewer than blobs | +| Delta chain resolution during batch read | Medium | Coalesce delta chains in same R2 request | + +--- + +## Resolved Questions + +| Question | Decision | Rationale | +| ------------------ | ----------------------------------------------------- | ------------------------------------------------------------- | +| Walk memory | Hybrid: in-memory up to 50k, SQLite temp table beyond | Most repos < 50k | +| Compressed caching | Skip | Objects already compressed; re-compression needed anyway | +| Negotiation | Single-round with `done` | v2 allows client to send `done` immediately; skip multi-round | +| include-tag | Skip | Client can `git fetch --tags` | +| Request timeout | Hard limit 50k objects for MVP | Chunked generation deferred | +| Protocol version | v2 only | Simpler, stateless, command-based | + +--- + +## Open Questions + +1. **Periodic repacking** - Should we consolidate multiple packfiles into one? (Reduces R2 requests further) +2. **Delta chain depth limit** - Current limit is 50; is this sufficient? +3. **Progress throttling** - 100ms interval sufficient for large fetches? diff --git a/docs/push.md b/docs/push.md new file mode 100644 index 0000000..51ae164 --- /dev/null +++ b/docs/push.md @@ -0,0 +1,786 @@ +# Receive-Pack (Push) Implementation Plan + +## Overview + +When a client pushes to gitflare, we need to: + +1. Stream the packfile to R2 +2. Parse the packfile to extract objects +3. Compute OIDs for all objects (requires delta resolution) +4. Build object index in SQLite +5. Update refs in SQLite +6. Return status to client (with sideband progress messages) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Receive-Pack Flow │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Client │ +│ │ │ +│ │ POST /git-receive-pack │ +│ │ Body: [commands] + [packfile] │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Parse Request │ Extract ref update commands + packfile stream │ +│ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Tee Stream │────▶│ R2 Upload │ Stream packfile to R2 │ +│ └────────┬────────┘ └─────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Pack Parser │ Existing streaming parser │ +│ │ (src/git/pack) │ Emits: header, objects, checksum │ +│ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Pack Indexer │────▶│ Delta Resolver │ For delta objects │ +│ │ │ │ (in-memory) │ │ +│ │ Computes OIDs │ └─────────────────┘ │ +│ │ Builds index │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ SQLite Writes │ 1. Insert into git_packs │ +│ │ │ 2. Batch insert into git_object_index │ +│ │ │ 3. Update git_refs │ +│ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Report Status │ Sideband progress + ref update results │ +│ └─────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## File Structure + +``` +src/git/ +├── delta.ts # DONE: Delta application (pure) +├── object.ts # DONE: OID computation, wrap/unwrap (pure) +├── pack/ +│ ├── index.ts # EXISTS: Streaming parser (consider rename to parser.ts) +│ ├── types.ts # EXISTS +│ └── error.ts # EXISTS +├── pkt.ts # EXISTS: Packet-line encoding (has sideband support) +└── protocol.ts # EXISTS: Protocol helpers + +src/do/ +├── hybrid-repo.ts # EXISTS: Wire up new components +├── pack-indexer.ts # NEW: Index packs, compute OIDs +├── ref-store.ts # DONE: Ref CRUD in SQLite +├── object-store.ts # DONE: Read objects (needed for thin packs) +├── r2.ts # EXISTS +├── cache.ts # EXISTS +└── schema/ + └── git.ts # EXISTS +``` + +## Sideband Progress Messages (GitHub-style) + +During push, GitHub sends **real-time streaming progress** via sideband channel 2. The client sees progress updates as each object is processed, not just final counts at the end. + +### GitHub Push Output Example + +``` +Enumerating objects: 5, done. +Counting objects: 100% (5/5), done. +Delta compression using up to 8 threads +Compressing objects: 100% (3/3), done. +Writing objects: 100% (3/3), 1.23 KiB | 1.23 MiB/s, done. +Total 3 (delta 2), reused 0 (delta 0), pack-reused 0 +remote: Resolving deltas: 0% (0/2) +remote: Resolving deltas: 50% (1/2) +remote: Resolving deltas: 100% (2/2), completed with 1 local object. +remote: Processing changes: refs: 1, done. +To github.com:user/repo.git + abc1234..def5678 main -> main +``` + +**Key observation**: The `remote:` lines update in-place using `\r` (carriage return) until completion, then use `\n`. + +### Static vs Streaming Progress + +| Approach | Behavior | User Experience | +| ------------- | -------------------------- | ---------------------------------------- | +| **Static** | Send all counts at end | User sees nothing, then suddenly "done" | +| **Streaming** | Send updates as processing | User sees real-time 0%→50%→100% progress | + +**We want streaming.** + +### Sideband Protocol + +```typescript +// Channel 1: Pack data (not used in receive-pack response) +// Channel 2: Progress messages (what we use) +// Channel 3: Error messages + +// PktLine already has helpers: +PktLine.encodeProgress(message: string) // Channel 2 +PktLine.encodeSidebandError(message: string) // Channel 3 +``` + +### Progress Message Format + +```typescript +// In-progress update (overwrites previous line with \r): +`remote: Resolving deltas: 50% (1/2)\r` +// Final message (newline, moves to next line): +`remote: Resolving deltas: 100% (2/2), done.\n`; +``` + +### Streaming Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Streaming Response Architecture │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Client Server │ +│ │ │ │ +│ │◀──────────────────────────────────│ HTTP Response starts │ +│ │ │ (chunked transfer encoding) │ +│ │ │ │ +│ │◀── sideband(2, "remote: ...") ───│ Progress: object 1/100 │ +│ │◀── sideband(2, "remote: ...") ───│ Progress: object 2/100 │ +│ │◀── sideband(2, "remote: ...") ───│ Progress: object 3/100 │ +│ │ ... time passes ... │ │ +│ │◀── sideband(2, "remote: ...") ───│ Progress: 100/100, done │ +│ │◀── sideband(2, "remote: ...") ───│ Progress: deltas 1/5 │ +│ │ ... time passes ... │ │ +│ │◀── sideband(2, "remote: ...") ───│ Progress: deltas 5/5, done │ +│ │◀── sideband(2, "remote: ...") ───│ Progress: refs done │ +│ │◀── pkt-line("unpack ok\n") ───│ Final status │ +│ │◀── pkt-line("ok refs/heads/main") │ │ +│ │◀── flush (0000) ───│ End of response │ +│ │ │ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Implementation: TransformStream + +```typescript +async function receivePack( + packStream: ReadableStream, + commands: RefUpdate[], +): Promise { + // Create a streaming response + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + + // Process in background, writing progress as we go + const processPromise = (async () => { + try { + // Each writer.write() sends data to client immediately + await writer.write( + PktLine.encodeProgress("remote: Unpacking objects: 0% (0/10)\r"), + ); + // ... process object 1 ... + await writer.write( + PktLine.encodeProgress("remote: Unpacking objects: 10% (1/10)\r"), + ); + // ... etc ... + } finally { + await writer.close(); + } + })(); + + // Don't await - let it run while response streams + ctx.waitUntil(processPromise); + + return new Response(readable, { + headers: { "Content-Type": "application/x-git-receive-pack-result" }, + }); +} +``` + +### Progress Callback in PackIndexer + +The PackIndexer emits progress events that the caller converts to sideband messages: + +```typescript +type ProgressEvent = + | { phase: "unpacking"; current: number; total: number } + | { phase: "resolving"; current: number; total: number } + | { phase: "writing" }; + +interface PackIndexer { + index( + packId: string, + r2Key: string, + packStream: ReadableStream, + onProgress?: (event: ProgressEvent) => Promise, // async for backpressure + ): Promise>; +} +``` + +### Throttling Progress Updates + +Don't send a message for every single object - throttle to avoid overhead: + +```typescript +function createProgressThrottler( + writer: WritableStreamDefaultWriter, +) { + let lastUpdate = 0; + const MIN_INTERVAL_MS = 100; // Max 10 updates/second + + return async (phase: string, current: number, total: number) => { + const now = Date.now(); + const isComplete = current === total; + const shouldUpdate = isComplete || now - lastUpdate >= MIN_INTERVAL_MS; + + if (!shouldUpdate) return; + + lastUpdate = now; + const pct = Math.floor((current / total) * 100); + const suffix = isComplete ? ", done.\n" : "\r"; + + await writer.write( + PktLine.encodeProgress( + `remote: ${phase}: ${pct}% (${current}/${total})${suffix}`, + ), + ); + }; +} +``` + +## Changes to Existing Code + +This section lists modifications needed to already-implemented files. + +### `src/git/protocol.ts` - MODIFY + +Current `buildReportStatus()` returns a static `Response`. For streaming, we need a different approach: + +**Option A**: Keep `buildReportStatus()` for final status only, streaming happens in DO + +```typescript +// Current signature (keep as-is for final status) +export async function buildReportStatus( + results: RefUpdateResult[], + unpackOk: boolean, +): Promise; + +// Add new helper for streaming status lines (not full Response) +export function encodeReportStatusLines( + results: RefUpdateResult[], + unpackOk: boolean, +): Uint8Array[] { + const lines: Uint8Array[] = []; + + if (unpackOk) { + lines.push(PktLine.encode("unpack ok\n")); + } else { + const error = results.find((r) => r.ref === "*")?.error ?? "unknown error"; + lines.push(PktLine.encode(`unpack ${error}\n`)); + } + + for (const result of results) { + if (result.ref === "*") continue; + if (result.ok) { + lines.push(PktLine.encode(`ok ${result.ref}\n`)); + } else { + lines.push(PktLine.encode(`ng ${result.ref} ${result.error}\n`)); + } + } + + lines.push(PktLine.encodeFlush()); + return lines; +} +``` + +### `src/do/hybrid-repo.ts` - MODIFY + +Current skeleton needs full `receivePack()` implementation with streaming response. + +**Changes needed:** + +1. Add `PackIndexer` dependency +2. Add `RefStore` dependency +3. Add `ObjectStore` dependency (for thin packs) +4. Implement `receivePack()` with `TransformStream` +5. Wire up progress callbacks + +### `src/routes/$owner/$repo/git-receive-pack.ts` - MODIFY + +Current route handler calls the old `Repo` DO. Need to: + +1. Switch to `HybridRepo` DO (or add feature flag) +2. Pass the packfile as a stream (not buffered `Uint8Array`) +3. Return streaming response from DO + +**Current code issue:** + +```typescript +// Current: buffers entire request +const data = new Uint8Array(await request.arrayBuffer()); +``` + +**Needed:** + +```typescript +// Stream the request body directly +const packStream = request.body; // ReadableStream +``` + +### `src/git/pack/index.ts` - NO CHANGES + +Parser already supports streaming via `AsyncIterable`. No changes needed. + +### `src/do/object-store.ts` - NO CHANGES + +Already implemented with delta resolution. Will be used by pack-indexer for thin packs. + +### `src/do/ref-store.ts` - NO CHANGES + +Already implemented. Will be used by `receivePack()`. + +--- + +## Implementation Order + +### Phase 1: Pure Git Primitives ✅ + +No storage dependencies. Fully testable with unit tests. + +#### 1.1 `src/git/delta.ts` ✅ + +Delta application for REF_DELTA objects. **DONE** + +- Varint parsing ✅ +- Delta header parsing ✅ +- Copy/insert instruction parsing ✅ +- `Delta.apply()` function ✅ + +#### 1.2 `src/git/object.ts` ✅ + +Git object hashing and framing. **DONE** + +- `GitObject.computeOid()` ✅ +- `GitObject.wrap()` ✅ +- `GitObject.unwrap()` ✅ + +### Phase 2: Storage Layer + +#### 2.1 `src/do/ref-store.ts` ✅ + +Simple CRUD for refs in SQLite. **DONE** + +- `get()` ✅ +- `list()` ✅ +- `resolve()` with symbolic ref support ✅ +- `applyUpdates()` with atomic transactions ✅ +- `setSymbolic()` ✅ + +#### 2.2 `src/do/object-store.ts` ✅ + +Read objects from storage. **DONE** + +- `has()` ✅ +- `read()` with recursive CTE for delta chains ✅ +- Coalesced R2 range requests ✅ +- Delta resolution ✅ + +#### 2.3 `src/do/pack-indexer.ts` ✅ + +The core of receive-pack. Indexes a packfile and stores metadata in SQLite. **DONE** + +```typescript +import { Result, TaggedError } from "better-result"; +import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; +import { PackfileParser } from "@/git/pack"; +import { Delta } from "@/git/delta"; +import { GitObject, type GitObjectType } from "@/git/object"; +import type { ObjectStore } from "./object-store"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +type IndexedObject = { + oid: string; + type: GitObjectType; + offset: number; + compressedSize: number; + isDelta: boolean; + baseOid?: string; +}; + +type IndexResult = { + packId: string; + objectCount: number; + deltaCount: number; + objects: IndexedObject[]; +}; + +// ─── Errors ────────────────────────────────────────────────────────────────── + +export class PackIndexError extends TaggedError("PackIndexError")<{ + code: + | "PARSE_ERROR" + | "DELTA_BASE_NOT_FOUND" + | "DELTA_APPLY_ERROR" + | "OFS_DELTA_NOT_SUPPORTED" + | "DB_ERROR" + | "CHECKSUM_MISMATCH"; + message: string; + offset?: number; +}>() {} + +// ─── Public API ────────────────────────────────────────────────────────────── + +type PackIndexerDeps = { + db: DB; + existingObjects: ObjectStore; // For thin pack support +}; + +interface PackIndexer { + /** + * Index a packfile stream. + * Yields progress events during indexing. + */ + index( + packId: string, + r2Key: string, + packStream: ReadableStream, + onProgress?: (event: ProgressEvent) => void, + ): Promise>; +} + +type ProgressEvent = + | { type: "unpacking"; current: number; total: number } + | { type: "resolving_deltas"; current: number; total: number } + | { type: "writing_index"; count: number }; +``` + +**index() implementation:** + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Pack Indexing Algorithm │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Memory structures: │ +│ - resolvedObjects: Map (for computing deltas) │ +│ - pendingDeltas: Array<{ offset, baseHash, deltaData, compressedSize }> │ +│ - indexEntries: Array │ +│ │ +│ Pass 1: Parse all objects │ +│ ───────────────────────── │ +│ for each object from parser: │ +│ onProgress({ type: 'unpacking', current, total }) │ +│ │ +│ if base object (commit/tree/blob/tag): │ +│ oid = computeOid(type, data) │ +│ resolvedObjects.set(oid, { type, data }) │ +│ indexEntries.push({ oid, type, offset, compressedSize, isDelta:false })│ +│ │ +│ if ref_delta: │ +│ pendingDeltas.push({ offset, baseHash, deltaData, compressedSize }) │ +│ │ +│ if ofs_delta: │ +│ return Error("OFS_DELTA_NOT_SUPPORTED") │ +│ │ +│ Pass 2: Resolve deltas │ +│ ────────────────────── │ +│ resolvedCount = 0 │ +│ while pendingDeltas.length > 0: │ +│ resolved_any = false │ +│ for delta in pendingDeltas: │ +│ base = resolvedObjects.get(delta.baseHash) │ +│ ?? await existingObjects.read(delta.baseHash) │ +│ if base: │ +│ resolved = applyDelta(base.data, delta.deltaData) │ +│ oid = computeOid(base.type, resolved) │ +│ resolvedObjects.set(oid, { type: base.type, data: resolved }) │ +│ indexEntries.push({ oid, type, offset, isDelta: true, baseOid }) │ +│ remove from pendingDeltas │ +│ resolvedCount += 1 │ +│ onProgress({ type: 'resolving_deltas', current: resolvedCount }) │ +│ resolved_any = true │ +│ │ +│ if !resolved_any && pendingDeltas.length > 0: │ +│ return Error("DELTA_BASE_NOT_FOUND") │ +│ │ +│ Pass 3: Write to SQLite │ +│ ─────────────────────── │ +│ onProgress({ type: 'writing_index', count: indexEntries.length }) │ +│ transaction: │ +│ insert into git_packs (packId, r2Key, objectCount, ...) │ +│ batch insert into git_object_index │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Phase 3: Integration + +#### 3.1 Update `src/do/hybrid-repo.ts` + +```typescript +import { DurableObject } from "cloudflare:workers"; +import { drizzle } from "drizzle-orm/durable-sqlite"; +import { R2 } from "./r2"; +import { PackIndexer } from "./pack-indexer"; +import { RefStore } from "./ref-store"; +import { ObjectStore } from "./object-store"; +import * as schema from "./schema/git"; + +export class HybridRepo extends DurableObject { + // ... existing setup ... + + /** + * Handle git-receive-pack request. + * Returns a streaming response with sideband progress. + */ + async receivePack( + packStream: ReadableStream, + commands: RefUpdate[], + ): Promise { + const packId = `pack-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; + const r2Key = `${this.repoPrefix}/packs/${packId}.pack`; + + // Create response stream for progress + status + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + + // Process in background, streaming progress + this.ctx.waitUntil( + (async () => { + try { + // 1. Tee stream: one to R2, one to parser + const [r2Stream, parseStream] = packStream.tee(); + + // 2. Upload to R2 + const uploadResult = await this.r2.put(r2Key, r2Stream); + if (uploadResult.isErr()) { + await writer.write( + PktLine.encodeProgress( + `remote: error: ${uploadResult.error.message}\n`, + ), + ); + await writer.write( + PktLine.encode(`unpack ${uploadResult.error.message}\n`), + ); + await writer.write(PktLine.encodeFlush()); + await writer.close(); + return; + } + + // 3. Index the pack with progress + const indexResult = await this.indexer.index( + packId, + r2Key, + parseStream, + async (event) => { + if (event.type === "unpacking") { + await writer.write( + PktLine.encodeProgress( + `remote: Unpacking objects: ${percent(event.current, event.total)}% (${event.current}/${event.total})${event.current === event.total ? ", done.\n" : "\r"}`, + ), + ); + } else if (event.type === "resolving_deltas") { + await writer.write( + PktLine.encodeProgress( + `remote: Resolving deltas: ${percent(event.current, event.total)}% (${event.current}/${event.total})${event.current === event.total ? ", done.\n" : "\r"}`, + ), + ); + } + }, + ); + + if (indexResult.isErr()) { + await this.r2.delete(r2Key); + await writer.write( + PktLine.encodeProgress( + `remote: error: ${indexResult.error.message}\n`, + ), + ); + await writer.write( + PktLine.encode(`unpack ${indexResult.error.message}\n`), + ); + await writer.write(PktLine.encodeFlush()); + await writer.close(); + return; + } + + // 4. Apply ref updates + const refResults = await this.refs.applyUpdates(commands); + + await writer.write( + PktLine.encodeProgress( + `remote: Processing changes: refs: ${commands.length}, done.\n`, + ), + ); + + // 5. Send final status + await writer.write(PktLine.encode("unpack ok\n")); + for (const result of refResults) { + if (result.ok) { + await writer.write(PktLine.encode(`ok ${result.name}\n`)); + } else { + await writer.write( + PktLine.encode(`ng ${result.name} ${result.error}\n`), + ); + } + } + await writer.write(PktLine.encodeFlush()); + await writer.close(); + } catch (error) { + await writer.write( + PktLine.encodeSidebandError(`fatal: ${String(error)}\n`), + ); + await writer.close(); + } + })(), + ); + + return new Response(readable, { + status: 200, + headers: { + "Content-Type": "application/x-git-receive-pack-result", + "Cache-Control": "no-cache", + }, + }); + } +} + +function percent(current: number, total: number): number { + return total === 0 ? 100 : Math.floor((current / total) * 100); +} +``` + +## Error Handling Strategy + +All errors use `TaggedError` from `better-result`: + +```typescript +// Each module defines its own error class +export class DeltaError extends TaggedError("DeltaError")<{...}>() {} +export class ObjectError extends TaggedError("ObjectError")<{...}>() {} +export class PackIndexError extends TaggedError("PackIndexError")<{...}>() {} +export class RefError extends TaggedError("RefError")<{...}>() {} + +// Higher-level errors wrap lower-level ones +export class ReceivePackError extends TaggedError("ReceivePackError")<{ + code: "UPLOAD_FAILED" | "INDEX_FAILED" | "REF_UPDATE_FAILED" + message: string + cause?: DeltaError | PackIndexError | RefError | R2Error +}>() {} +``` + +## Testing Strategy + +### Unit Tests (Pure Git Layer) + +```typescript +// src/git/delta.test.ts +describe("Delta.apply", () => { + it("applies insert-only delta", () => { ... }) + it("applies copy-only delta", () => { ... }) + it("applies mixed delta", () => { ... }) + it("returns error for invalid header", () => { ... }) + it("returns error for size mismatch", () => { ... }) +}) + +// src/git/object.test.ts +describe("GitObject.computeOid", () => { + it("computes correct OID for blob", () => { + const data = new TextEncoder().encode("hello world\n") + const oid = GitObject.computeOid("blob", data) + expect(oid).toBe("3b18e512dba79e4c8300dd08aeb37f8e728b8dad") + }) +}) +``` + +### Integration Tests (Storage Layer) + +Use Miniflare for D1/R2/DO testing: + +```typescript +// src/do/pack-indexer.test.ts +describe("PackIndexer", () => { + it("indexes a simple pack with no deltas", async () => { ... }) + it("indexes a pack with ref_delta objects", async () => { ... }) + it("rejects pack with ofs_delta", async () => { ... }) + it("handles thin pack (base in existing storage)", async () => { ... }) +}) +``` + +## Implementation Checklist + +### Phase 1: Pure Git Primitives + +- [x] Create `src/git/delta.ts` + - [x] Implement varint parsing + - [x] Implement delta header parsing + - [x] Implement copy/insert instruction parsing + - [x] Implement `Delta.apply()` + - [ ] Add unit tests + +- [x] Create `src/git/object.ts` + - [x] Implement `GitObject.computeOid()` + - [x] Implement `GitObject.wrap()` + - [x] Implement `GitObject.unwrap()` + - [ ] Add unit tests + +### Phase 2: Storage Layer + +- [x] Create `src/do/ref-store.ts` + - [x] Implement `get()` + - [x] Implement `list()` + - [x] Implement `resolve()` + - [x] Implement `applyUpdates()` + - [x] Implement `setSymbolic()` + +- [x] Create `src/do/object-store.ts` + - [x] Implement `has()` + - [x] Implement `read()` with delta resolution + - [x] Implement coalesced R2 range requests + - [x] Implement recursive CTE for delta chains + +- [x] Create `src/do/pack-indexer.ts` + - [x] Implement base object indexing + - [x] Implement delta resolution loop + - [x] Implement SQLite batch writes + - [x] Add progress callback support + +### Phase 3: Integration + +- [x] Update `src/do/hybrid-repo.ts` + - [x] Wire up all components + - [x] Implement `receivePack()` with streaming response + - [x] Add stream tee for R2 + parser + +- [x] Update `src/git/protocol.ts` + - [x] Add `encodeReportStatusLines()` helper for streaming + +- [x] Update route handler + - [x] Switch to HybridRepo DO + - [x] Return streaming response + +### Phase 4: Testing & Cleanup + +- [ ] Add unit tests for delta.ts +- [ ] Add unit tests for object.ts +- [ ] Add integration tests with Miniflare +- [ ] Test with real git client +- [ ] Performance testing with large packs +- [ ] Error handling edge cases + +## Open Questions + +1. **Pack size limit**: What's the maximum pack size we accept? Suggested: 100MB initially. + +2. **Thin pack support**: Currently object-store.ts supports reading existing objects for thin packs. Need to verify this works in pack-indexer. + +3. **Concurrent pushes**: DO handles serialization automatically. Should be fine. + +4. **Capabilities negotiation**: We advertise `no-thin` currently. If we want thin pack support, remove it. + +5. **OFS_DELTA**: We don't advertise it, but should still reject gracefully if client sends one.