diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fee830..4b3e88f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: pi: ${{ steps.filter.outputs.pi }} opencode: ${{ steps.filter.outputs.opencode }} pydantic_ai: ${{ steps.filter.outputs.pydantic_ai }} + convex: ${{ steps.filter.outputs.convex }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -45,6 +46,8 @@ jobs: - 'packages/opencode-plugin/**' pydantic_ai: - 'packages/pydantic-ai-daytona/**' + convex: + - 'packages/convex/**' adk: needs: changes @@ -208,6 +211,36 @@ jobs: # NOTE: live tests (tests/integration_tests, real Daytona sandboxes) run via # the separate integration workflow, not per-PR CI. + convex: + needs: changes + if: needs.changes.outputs.convex == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "lts/*" + - name: Detect package + id: guard + run: | + test -f packages/convex/package.json && echo "ready=true" >> "$GITHUB_OUTPUT" || echo "ready=false" >> "$GITHUB_OUTPUT" + - name: Install + if: steps.guard.outputs.ready == 'true' + working-directory: packages/convex + run: npm ci + - name: Typecheck + if: steps.guard.outputs.ready == 'true' + working-directory: packages/convex + run: npm run typecheck + - name: Build + if: steps.guard.outputs.ready == 'true' + working-directory: packages/convex + run: npm run build + - name: Unit tests (offline, mocked Daytona API) + if: steps.guard.outputs.ready == 'true' + working-directory: packages/convex + run: npm test + # Single, stable status check to require in branch protection. # It ALWAYS runs (if: always()) and fails only if a relevant job failed or # was cancelled. A "skipped" job (a package this PR didn't touch) is fine — @@ -215,7 +248,7 @@ jobs: # merges when a touched package's lint/test/build fails. ci-success: name: ci-success - needs: [changes, adk, langchain, n8n, pi, opencode, pydantic-ai] + needs: [changes, adk, langchain, n8n, pi, opencode, pydantic-ai, convex] if: always() runs-on: ubuntu-latest steps: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 3d2b68e..c8011d2 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -32,6 +32,7 @@ on: options: - all - adk + - convex - langchain - n8n - pi @@ -64,6 +65,7 @@ jobs: pull-requests: read outputs: adk: ${{ steps.decide.outputs.adk }} + convex: ${{ steps.decide.outputs.convex }} langchain: ${{ steps.decide.outputs.langchain }} n8n: ${{ steps.decide.outputs.n8n }} pi: ${{ steps.decide.outputs.pi }} @@ -77,6 +79,8 @@ jobs: filters: | adk: - 'packages/adk-plugin/**' + convex: + - 'packages/convex/**' langchain: - 'packages/langchain-data-analysis/**' n8n: @@ -93,6 +97,7 @@ jobs: # Fork = the PR's source branch lives in a different repo than ours. IS_FORK: ${{ (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && github.event.pull_request.head.repo.full_name != github.repository }} CH_ADK: ${{ steps.filter.outputs.adk }} + CH_CONVEX: ${{ steps.filter.outputs.convex }} CH_LANGCHAIN: ${{ steps.filter.outputs.langchain }} CH_N8N: ${{ steps.filter.outputs.n8n }} CH_PI: ${{ steps.filter.outputs.pi }} @@ -120,6 +125,7 @@ jobs: } { echo "adk=$(decide adk "${CH_ADK:-false}")" + echo "convex=$(decide convex "${CH_CONVEX:-false}")" echo "langchain=$(decide langchain "${CH_LANGCHAIN:-false}")" echo "n8n=$(decide n8n "${CH_N8N:-false}")" echo "pi=$(decide pi "${CH_PI:-false}")" @@ -162,6 +168,38 @@ jobs: DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} run: pytest + convex: + needs: changes + if: needs.changes.outputs.convex == 'true' + runs-on: ubuntu-latest + environment: integration-tests + timeout-minutes: 30 + steps: + # Fork lane checks out the PR head (see the adk job note). + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }} + # Fork-lane opt-in + credential hygiene — see the adk job for rationale. + allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: "lts/*" + - name: Install + working-directory: packages/convex + run: npm ci + - name: Build + working-directory: packages/convex + run: npm run build + # Boots an anonymous LOCAL Convex deployment (no Convex account), deploys + # the example app with the component installed, and drives it end to end + # against real Daytona sandboxes via `convex run`. + - name: Live E2E (local Convex deployment + real Daytona) + working-directory: packages/convex + env: + DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} + run: npm run test:live + langchain: needs: changes if: needs.changes.outputs.langchain == 'true' @@ -313,7 +351,7 @@ jobs: # required checks treat as satisfied, so the owning lane's verdict governs. integration-success: name: integration-success - needs: [changes, adk, langchain, n8n, pi, pydantic-ai] + needs: [changes, adk, convex, langchain, n8n, pi, pydantic-ai] if: >- always() && ( github.event_name == 'workflow_dispatch' || diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d875ae3..526c01c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,8 @@ jobs: opencode_tag: ${{ steps.release.outputs['packages/opencode-plugin--tag_name'] }} pydantic_ai_released: ${{ steps.release.outputs['packages/pydantic-ai-daytona--release_created'] }} pydantic_ai_tag: ${{ steps.release.outputs['packages/pydantic-ai-daytona--tag_name'] }} + convex_released: ${{ steps.release.outputs['packages/convex--release_created'] }} + convex_tag: ${{ steps.release.outputs['packages/convex--tag_name'] }} steps: - uses: googleapis/release-please-action@v4 id: release @@ -205,6 +207,33 @@ jobs: npm run build --if-present npm publish --provenance --access public + # ---- npm: @daytona/convex (Convex component) ---------------------------- + publish-convex: + needs: release-please + if: needs.release-please.outputs.convex_released == 'true' + runs-on: ubuntu-latest + permissions: + id-token: write # OIDC -> npm Trusted Publishing + provenance + contents: read + # See publish-n8n for the npm environment contract (repo Settings + npm). + environment: npm + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "lts/*" + registry-url: "https://registry.npmjs.org" + - name: Upgrade npm + run: npm install -g npm@latest + # `npm run build` compiles src/ (including the committed component + # _generated code) to dist/ — no Convex deployment needed at build time. + - name: Install, build, publish + working-directory: packages/convex + run: | + npm ci + npm run build + npm publish --provenance --access public + # ---- npm: @daytona/opencode (OpenCode plugin) --------------------------- publish-opencode: needs: release-please diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 778021a..ed98df0 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -4,5 +4,6 @@ "packages/n8n-nodes-daytona": "0.1.3", "packages/pi-extension": "0.191.1", "packages/opencode-plugin": "0.193.0", - "packages/pydantic-ai-daytona": "0.1.0" + "packages/pydantic-ai-daytona": "0.1.0", + "packages/convex": "0.0.0" } diff --git a/README.md b/README.md index 243a6fa..cf4d7cb 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Packages published to **a package registry** live in [`packages/`](packages/): |---|---| | [`adk-plugin`](packages/adk-plugin) — Google ADK plugin | PyPI · [`daytona-adk`](https://pypi.org/project/daytona-adk/) | | [`langchain-data-analysis`](packages/langchain-data-analysis) — LangChain data-analysis tool | PyPI · [`langchain-daytona-data-analysis`](https://pypi.org/project/langchain-daytona-data-analysis/) | +| [`convex`](packages/convex) — Convex sandbox component | npm · [`@daytona/convex`](https://www.npmjs.com/package/@daytona/convex) | | [`n8n-nodes-daytona`](packages/n8n-nodes-daytona) — n8n community node | npm · [`@daytona/n8n-nodes-daytona`](https://www.npmjs.com/package/@daytona/n8n-nodes-daytona) | | [`pi-extension`](packages/pi-extension) — Pi coding-agent extension | npm · [`@daytona/pi`](https://www.npmjs.com/package/@daytona/pi) | | [`opencode-plugin`](packages/opencode-plugin) — OpenCode plugin | npm · [`@daytona/opencode`](https://www.npmjs.com/package/@daytona/opencode) | diff --git a/packages/convex/.gitignore b/packages/convex/.gitignore new file mode 100644 index 0000000..cc5ff07 --- /dev/null +++ b/packages/convex/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +*.tsbuildinfo +*.tgz +*.log +.env.local diff --git a/packages/convex/LICENSE b/packages/convex/LICENSE new file mode 100644 index 0000000..87d052a --- /dev/null +++ b/packages/convex/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Daytona Platforms Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/convex/README.md b/packages/convex/README.md new file mode 100644 index 0000000..8cf6c79 --- /dev/null +++ b/packages/convex/README.md @@ -0,0 +1,223 @@ +# Daytona Component for Convex + +[![npm version](https://img.shields.io/npm/v/@daytona/convex)](https://www.npmjs.com/package/@daytona/convex) + + + +Run [Daytona](https://www.daytona.io) sandboxes from your Convex backend: create isolated sandboxes, execute shell commands and code, read/write files, and get live preview URLs — with sandbox and execution state tracked in reactive Convex tables your UI can subscribe to. + +```ts +// convex/agent.ts +import { Daytona } from "@daytona/convex"; +import { v } from "convex/values"; +import { components } from "./_generated/api"; +import { action } from "./_generated/server"; + +const daytona = new Daytona(components.daytona); + +export const codeInterpreter = action({ + args: { code: v.string() }, + handler: async (ctx, args) => { + const { sandboxId } = await daytona.createSandbox(ctx, { + autoStopInterval: 15, + }); + const { result, exitCode } = await daytona.runCode(ctx, { + sandboxId, + code: args.code, + language: "python", + }); + return { result, exitCode }; + }, +}); +``` + +Why a component instead of calling the Daytona API directly? + +- **Reactive state** — every sandbox and execution is recorded in component tables. Drive agent UIs ("running…", exit codes, output history) from live Convex queries instead of polling. +- **Isolated bookkeeping** — the component keeps its tables in its own namespace; it can't touch your app's data, and your app manages it through one typed client. +- **No SDK bundle** — the component talks to the Daytona REST API with plain `fetch` in Convex's default runtime: no `"use node"`, no heavy dependencies, fast cold starts. + +## Installation + +```bash +npm install @daytona/convex +``` + +Create or update `convex/convex.config.ts` in your app: + +```ts +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import daytona from "@daytona/convex/convex.config.js"; + +const app = defineApp(); +app.use(daytona); + +export default app; +``` + +Set your Daytona API key on your deployment (create one at [app.daytona.io/dashboard/keys](https://app.daytona.io/dashboard/keys)): + +```bash +npx convex env set DAYTONA_API_KEY dtn_... +``` + +Optional: `DAYTONA_API_URL` (self-hosted instances; defaults to `https://app.daytona.io/api`). + +## Usage + +Instantiate the client with the installed component. Credentials resolve from your deployment's environment variables, or pass them explicitly: + +```ts +import { Daytona } from "@daytona/convex"; +import { components } from "./_generated/api"; + +const daytona = new Daytona(components.daytona); +// or: new Daytona(components.daytona, { apiKey: "dtn_..." }) +``` + +### Sandbox lifecycle + +```ts +export const create = action({ + args: {}, + handler: async (ctx) => { + return await daytona.createSandbox(ctx, { + snapshot: "my-snapshot", // optional — org default when omitted + autoStopInterval: 15, // pause after 15 idle minutes (fs preserved) + autoDeleteInterval: -1, // never auto-delete + userKey: "user_123", // scope sandboxes to a user/agent (see below) + }); + // → { sandboxId: "…", state: "started" } + }, +}); +``` + +Sandboxes are created from a **snapshot** (yours, or the org default) *or* built from a Docker **image** — pass one or the other. `cpu`/`memory`/`disk` only apply to image-based creation; snapshots define their own resources: + +```ts +await daytona.createSandbox(ctx, { image: "python:3.12", cpu: 2, memory: 4 }); +``` + +Also available: `startSandbox`, `stopSandbox`, `deleteSandbox`, and `refreshSandbox` (re-syncs remote state into the component's tables; returns `null` if the sandbox no longer exists). + +### Run commands and code + +```ts +const { executionId, exitCode, result } = await daytona.run(ctx, { + sandboxId, + command: "python train.py", + cwd: "/home/daytona/project", + timeoutSeconds: 300, +}); + +const py = await daytona.runCode(ctx, { + sandboxId, + code: "print(sum(range(10)))", + language: "python", +}); +``` + +A stopped or archived sandbox is transparently restarted first (disable with `autoStart: false`). Every call records an execution row (`running` → `completed`/`failed`) with truncated output, so history and status are queryable. + +### Reactive state + +These read the component's tables — no Daytona API call, and they update live: + +```ts +export const sandboxes = query({ + args: {}, + handler: async (ctx) => daytona.listSandboxes(ctx, { userKey: "user_123" }), +}); + +export const executions = query({ + args: { sandboxId: v.string() }, + handler: async (ctx, args) => daytona.listExecutions(ctx, args), +}); +``` + +Also: `getSandbox`, `getExecution`. + +### Files + +```ts +await daytona.writeFile(ctx, { sandboxId, path: "/home/daytona/app.py", content }); +const text = await daytona.readFile(ctx, { sandboxId, path: "/home/daytona/app.py" }); +const entries = await daytona.listFiles(ctx, { sandboxId, path: "/home/daytona" }); +await daytona.deleteFile(ctx, { sandboxId, path: "/home/daytona/tmp", recursive: true }); +``` + +File content crosses the function boundary as UTF-8 strings; keep individual files under Convex's 16 MiB argument/return limits. + +### Preview URLs + +```ts +const { url } = await daytona.getPreviewUrl(ctx, { sandboxId, port: 3000 }); +``` + +Signed URLs (default) embed a short-lived token; pass `signed: false` for a standard URL + token pair. + +### Authorization + +Components can't see your app's `ctx.auth` — authenticate callers in your own functions and scope sandboxes with `userKey`: + +```ts +export const mySandboxes = query({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Unauthorized"); + return await daytona.listSandboxes(ctx, { userKey: identity.subject }); + }, +}); +``` + +### Limits & long-running work + +- Convex actions time out after 10 minutes, so commands are always bounded below that ceiling: `timeoutSeconds` defaults to 540 and is capped at 570. For longer jobs, start a background process in the sandbox (`nohup … &`) and poll with follow-up `run` calls. +- Stored execution output is truncated (64 KB); the action's return value carries up to 4 MB. +- Sandboxes cost money while running: set `autoStopInterval`, and delete sandboxes you're done with. `refreshSandbox` reconciles records whose remote sandbox was removed out-of-band. + +See [example/convex/example.ts](./example/convex/example.ts) for a complete example app. + +Found a bug? Feature request? [File it here](https://github.com/daytona/integrations/issues). + + + +## Development + +This package lives in the [`daytona/integrations`](https://github.com/daytona/integrations) monorepo under `packages/convex` and is self-contained (own `package.json`, lockfile, no workspace tooling). + +```bash +git clone https://github.com/daytona/integrations +cd integrations/packages/convex +npm install +``` + +The component's `src/component/_generated` code is committed; regenerate it after changing component functions: + +```bash +npx convex init # one-time: bootstrap a local anonymous deployment +npm run codegen # component codegen (src/component/_generated) +npx convex codegen # example app codegen (example/convex/_generated) +``` + +Checks: + +```bash +npm run build # compile to dist/ +npm run typecheck # package sources +npm run typecheck:example # example app (needs codegen above) +npm test # offline unit tests (mocked Daytona API) +npm run test:live # live E2E: local Convex deployment + real Daytona (needs DAYTONA_API_KEY) +``` + +`npm run dev` starts a Convex dev deployment serving the example app — set `DAYTONA_API_KEY` on it to exercise the component against real sandboxes. + +### Publishing + +Releases are automated with [release-please](https://github.com/googleapis/release-please): merging this package's Release PR tags it and publishes to npm (public, with provenance) from the repo's release workflow. + +## License + +Apache-2.0 diff --git a/packages/convex/convex.json b/packages/convex/convex.json new file mode 100644 index 0000000..1704a04 --- /dev/null +++ b/packages/convex/convex.json @@ -0,0 +1,3 @@ +{ + "functions": "example/convex" +} diff --git a/packages/convex/example/convex/_generated/api.d.ts b/packages/convex/example/convex/_generated/api.d.ts new file mode 100644 index 0000000..97cdb25 --- /dev/null +++ b/packages/convex/example/convex/_generated/api.d.ts @@ -0,0 +1,51 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as example from "../example.js"; + +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; + +declare const fullApi: ApiFromModules<{ + example: typeof example; +}>; + +/** + * A utility for referencing Convex functions in your app's public API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export declare const api: FilterApi< + typeof fullApi, + FunctionReference +>; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ +export declare const internal: FilterApi< + typeof fullApi, + FunctionReference +>; + +export declare const components: { + daytona: import("@daytona/convex/_generated/component.js").ComponentApi<"daytona">; +}; diff --git a/packages/convex/example/convex/_generated/api.js b/packages/convex/example/convex/_generated/api.js new file mode 100644 index 0000000..44bf985 --- /dev/null +++ b/packages/convex/example/convex/_generated/api.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { anyApi, componentsGeneric } from "convex/server"; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api = anyApi; +export const internal = anyApi; +export const components = componentsGeneric(); diff --git a/packages/convex/example/convex/_generated/dataModel.d.ts b/packages/convex/example/convex/_generated/dataModel.d.ts new file mode 100644 index 0000000..f97fd19 --- /dev/null +++ b/packages/convex/example/convex/_generated/dataModel.d.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; diff --git a/packages/convex/example/convex/_generated/server.d.ts b/packages/convex/example/convex/_generated/server.d.ts new file mode 100644 index 0000000..f235db4 --- /dev/null +++ b/packages/convex/example/convex/_generated/server.d.ts @@ -0,0 +1,162 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +type Env = { + readonly CONVEX_CLOUD_URL: string; + readonly CONVEX_SITE_URL: string; +}; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const query: QueryBuilder; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const internalQuery: QueryBuilder; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const mutation: MutationBuilder; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const internalMutation: MutationBuilder; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export declare const action: ActionBuilder; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export declare const internalAction: ActionBuilder; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export declare const httpAction: HttpActionBuilder; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +export declare const env: Env; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * This differs from the {@link MutationCtx} because all of the services are + * read-only. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/packages/convex/example/convex/_generated/server.js b/packages/convex/example/convex/_generated/server.js new file mode 100644 index 0000000..2dbe0db --- /dev/null +++ b/packages/convex/example/convex/_generated/server.js @@ -0,0 +1,101 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery = internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation = internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction = internalActionGeneric; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export const httpAction = httpActionGeneric; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +export const env = process.env; diff --git a/packages/convex/example/convex/convex.config.ts b/packages/convex/example/convex/convex.config.ts new file mode 100644 index 0000000..fe8075c --- /dev/null +++ b/packages/convex/example/convex/convex.config.ts @@ -0,0 +1,7 @@ +import { defineApp } from "convex/server"; +import daytona from "@daytona/convex/convex.config.js"; + +const app = defineApp(); +app.use(daytona); + +export default app; diff --git a/packages/convex/example/convex/example.test.ts b/packages/convex/example/convex/example.test.ts new file mode 100644 index 0000000..cff252b --- /dev/null +++ b/packages/convex/example/convex/example.test.ts @@ -0,0 +1,69 @@ +/// + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { api } from "./_generated/api.js"; +import { initConvexTest } from "./setup.test.js"; + +function stubDaytonaApi() { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + if (method === "POST" && url.includes("/process/execute")) { + return new Response(JSON.stringify({ exitCode: 0, result: "hi\n" })); + } + if (method === "POST" && url.endsWith("/api/sandbox")) { + return new Response(JSON.stringify({ id: "sbx-1", state: "creating" })); + } + if (method === "GET" && url.includes("/api/sandbox/sbx-1")) { + return new Response( + JSON.stringify({ + id: "sbx-1", + state: "started", + toolboxProxyUrl: "https://proxy.daytona.test/toolbox", + }), + ); + } + return new Response(`no stub for ${method} ${url}`, { status: 500 }); + }), + ); +} + +beforeEach(() => { + process.env.DAYTONA_API_KEY = "test-key"; + process.env.DAYTONA_API_URL = "https://daytona.test/api"; + stubDaytonaApi(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.DAYTONA_API_KEY; + delete process.env.DAYTONA_API_URL; +}); + +describe("example app (full consumer path: app → client → component)", () => { + test("createSandbox, runCommand, and reactive queries", async () => { + const t = initConvexTest(); + + const created = await t.action(api.example.createSandbox, {}); + expect(created).toEqual({ sandboxId: "sbx-1", state: "started" }); + + const execution = await t.action(api.example.runCommand, { + sandboxId: "sbx-1", + command: "echo hi", + }); + expect(execution.exitCode).toBe(0); + expect(execution.result).toBe("hi\n"); + + const sandboxes = await t.query(api.example.sandboxes, {}); + expect(sandboxes).toHaveLength(1); + expect(sandboxes[0].state).toBe("started"); + + const executions = await t.query(api.example.executions, { + sandboxId: "sbx-1", + }); + expect(executions).toHaveLength(1); + expect(executions[0].status).toBe("completed"); + }); +}); diff --git a/packages/convex/example/convex/example.ts b/packages/convex/example/convex/example.ts new file mode 100644 index 0000000..df4cb48 --- /dev/null +++ b/packages/convex/example/convex/example.ts @@ -0,0 +1,91 @@ +import { Daytona } from "@daytona/convex"; +import { v } from "convex/values"; +import { components } from "./_generated/api.js"; +import { action, query } from "./_generated/server.js"; + +// Reads DAYTONA_API_KEY (and optional DAYTONA_API_URL) from this deployment's +// environment variables: `npx convex env set DAYTONA_API_KEY ...` +const daytona = new Daytona(components.daytona); + +// NOTE: in a real app, authenticate the caller (ctx.auth) in each of these +// functions and scope sandboxes with `userKey` — the component can't see your +// app's auth, so authorization belongs here in the host app. + +/** Create a sandbox and wait until it's running. */ +export const createSandbox = action({ + args: { snapshot: v.optional(v.string()) }, + handler: async (ctx, args) => { + return await daytona.createSandbox(ctx, { + snapshot: args.snapshot, + labels: { "created-by": "convex-example" }, + // Pause after 15 idle minutes (filesystem preserved); never auto-delete. + autoStopInterval: 15, + autoDeleteInterval: -1, + }); + }, +}); + +/** Run a shell command inside a sandbox. */ +export const runCommand = action({ + args: { + sandboxId: v.string(), + command: v.string(), + cwd: v.optional(v.string()), + }, + handler: async (ctx, args) => { + return await daytona.run(ctx, args); + }, +}); + +/** Run a Python snippet inside a sandbox. */ +export const runPython = action({ + args: { sandboxId: v.string(), code: v.string() }, + handler: async (ctx, args) => { + return await daytona.runCode(ctx, { ...args, language: "python" }); + }, +}); + +/** Write then read back a file in the sandbox. */ +export const writeAndReadFile = action({ + args: { sandboxId: v.string(), path: v.string(), content: v.string() }, + handler: async (ctx, args) => { + await daytona.writeFile(ctx, args); + return await daytona.readFile(ctx, { + sandboxId: args.sandboxId, + path: args.path, + }); + }, +}); + +/** Get a signed preview URL for a port (e.g. after starting a dev server). */ +export const previewUrl = action({ + args: { sandboxId: v.string(), port: v.number() }, + handler: async (ctx, args) => { + return await daytona.getPreviewUrl(ctx, args); + }, +}); + +/** Stop / delete a sandbox. */ +export const stopSandbox = action({ + args: { sandboxId: v.string() }, + handler: async (ctx, args) => daytona.stopSandbox(ctx, args), +}); + +export const deleteSandbox = action({ + args: { sandboxId: v.string() }, + handler: async (ctx, args) => daytona.deleteSandbox(ctx, args), +}); + +// ---- Reactive state — drive your UI from these ---- + +/** All sandbox records (reactive — updates as actions record state). */ +export const sandboxes = query({ + args: {}, + handler: async (ctx) => daytona.listSandboxes(ctx), +}); + +/** Execution history for one sandbox (reactive). */ +export const executions = query({ + args: { sandboxId: v.string() }, + handler: async (ctx, args) => daytona.listExecutions(ctx, args), +}); diff --git a/packages/convex/example/convex/schema.ts b/packages/convex/example/convex/schema.ts new file mode 100644 index 0000000..26c1fbe --- /dev/null +++ b/packages/convex/example/convex/schema.ts @@ -0,0 +1,6 @@ +import { defineSchema } from "convex/server"; + +export default defineSchema({ + // Tables used by the example app itself would go here — the Daytona + // component keeps its own tables in its own namespace. +}); diff --git a/packages/convex/example/convex/setup.test.ts b/packages/convex/example/convex/setup.test.ts new file mode 100644 index 0000000..552edbc --- /dev/null +++ b/packages/convex/example/convex/setup.test.ts @@ -0,0 +1,15 @@ +/// +import { test } from "vitest"; +import { convexTest } from "convex-test"; +import daytonaTest from "@daytona/convex/test"; +import schema from "./schema.js"; + +export const modules = import.meta.glob("./**/*.*s"); + +export function initConvexTest() { + const t = convexTest(schema, modules); + daytonaTest.register(t); + return t; +} + +test("setup", () => {}); diff --git a/packages/convex/example/convex/tsconfig.json b/packages/convex/example/convex/tsconfig.json new file mode 100644 index 0000000..c88662c --- /dev/null +++ b/packages/convex/example/convex/tsconfig.json @@ -0,0 +1,25 @@ +{ + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings are required to use Convex. + */ + "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ + "allowJs": true, + "strict": true, + "moduleResolution": "Bundler", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "types": ["node"], + + /* These compiler options are required by Convex */ + "target": "ESNext", + "lib": ["ES2023", "dom"], + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"], + "exclude": ["./_generated"] +} diff --git a/packages/convex/package-lock.json b/packages/convex/package-lock.json new file mode 100644 index 0000000..e64e2d5 --- /dev/null +++ b/packages/convex/package-lock.json @@ -0,0 +1,1753 @@ +{ + "name": "@daytona/convex", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@daytona/convex", + "version": "0.0.0", + "license": "Apache-2.0", + "devDependencies": { + "@edge-runtime/vm": "^5.0.0", + "@types/node": "^24.0.0", + "convex": "^1.45.0", + "convex-test": "^0.0.58", + "typescript": "^5.9.0", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "convex": "^1.43.0" + } + }, + "node_modules/@edge-runtime/primitives": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-6.0.0.tgz", + "integrity": "sha512-FqoxaBT+prPBHBwE1WXS1ocnu/VLTQyZ6NMUBAdbP7N2hsFTTxMC/jMu2D/8GAlMQfxeuppcPuCUk/HO3fpIvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@edge-runtime/vm": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/vm/-/vm-5.0.0.tgz", + "integrity": "sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@edge-runtime/primitives": "6.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/convex": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.45.0.tgz", + "integrity": "sha512-AV3B56Ptu/14d76g3urBJ50dwpiZa6uJaLT84OWox5aCwZIJiuAamdjv3lLHDzs8vv5kC31anEZohhUe3qJ2bA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esbuild": "0.27.0", + "prettier": "^3.0.0", + "ws": "8.21.0" + }, + "bin": { + "convex": "bin/main.js" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@auth0/auth0-react": "^2.0.1", + "@clerk/clerk-react": "^4.12.8 || ^5.0.0", + "@clerk/react": "^6.4.3", + "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@auth0/auth0-react": { + "optional": true + }, + "@clerk/clerk-react": { + "optional": true + }, + "@clerk/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/convex-test": { + "version": "0.0.58", + "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.58.tgz", + "integrity": "sha512-VZ9VNrWZkdlWMmudnoOdiqTk8oc6r9pCcnxxPq1Kh/tt4dX9cyA7kJWFZ9LrAgEUxGcg2iPkiOb+bBvHfy3ihA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "convex": "^1.43.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/packages/convex/package.json b/packages/convex/package.json new file mode 100644 index 0000000..609b35e --- /dev/null +++ b/packages/convex/package.json @@ -0,0 +1,77 @@ +{ + "name": "@daytona/convex", + "version": "0.0.0", + "description": "Convex component for Daytona — create sandboxes, run commands and code, and manage sandbox files from your Convex backend, with sandbox and execution state tracked in reactive Convex tables", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/daytona/integrations.git", + "directory": "packages/convex" + }, + "author": "Daytona Platforms Inc.", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/daytona/integrations/issues" + }, + "homepage": "https://github.com/daytona/integrations/tree/main/packages/convex#readme", + "keywords": [ + "convex", + "component", + "convex-component", + "daytona", + "sandbox", + "code-execution", + "ai-agents" + ], + "files": [ + "dist", + "src", + "README.md" + ], + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/client/index.d.ts", + "default": "./dist/client/index.js" + }, + "./test": "./src/test.ts", + "./_generated/component.js": { + "types": "./dist/component/_generated/component.d.ts" + }, + "./_generated/component": { + "types": "./dist/component/_generated/component.d.ts" + }, + "./convex.config.js": { + "types": "./dist/component/convex.config.d.ts", + "default": "./dist/component/convex.config.js" + }, + "./convex.config": { + "types": "./dist/component/convex.config.d.ts", + "default": "./dist/component/convex.config.js" + } + }, + "types": "./dist/client/index.d.ts", + "module": "./dist/client/index.js", + "scripts": { + "codegen": "convex codegen --component-dir ./src/component", + "build": "tsc --project tsconfig.build.json", + "clean": "rm -rf dist *.tsbuildinfo", + "typecheck": "tsc --noEmit", + "typecheck:example": "tsc -p example/convex", + "test": "vitest run", + "test:watch": "vitest", + "test:live": "node scripts/live.mjs", + "dev": "convex dev" + }, + "peerDependencies": { + "convex": "^1.43.0" + }, + "devDependencies": { + "@edge-runtime/vm": "^5.0.0", + "@types/node": "^24.0.0", + "convex": "^1.45.0", + "convex-test": "^0.0.58", + "typescript": "^5.9.0", + "vitest": "^3.2.4" + } +} diff --git a/packages/convex/scripts/live.mjs b/packages/convex/scripts/live.mjs new file mode 100644 index 0000000..a93564c --- /dev/null +++ b/packages/convex/scripts/live.mjs @@ -0,0 +1,111 @@ +/** + * Copyright Daytona Platforms Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Live end-to-end test: runs the example app on a real (anonymous local) + * Convex deployment against real Daytona sandboxes. Exercises the component + * inside Convex's actual runtime — which convex-test's edge-runtime simulation + * cannot fully match (e.g. it caught a `URLSearchParams.size` incompatibility + * that dropped query strings). Needs DAYTONA_API_KEY. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +const apiKey = process.env.DAYTONA_API_KEY; +if (!apiKey) { + console.error("DAYTONA_API_KEY is required for live tests"); + process.exit(1); +} + +// Invoke the locally installed Convex CLI through the current Node binary — +// no npx, no shell: works identically on every platform and never re-parses +// JSON arguments through cmd.exe. +const convexBin = join( + dirname(createRequire(import.meta.url).resolve("convex/package.json")), + "bin/main.js", +); + +const convex = (...args) => + execFileSync(process.execPath, [convexBin, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + timeout: 300_000, + }); + +const run = (fn, args = {}) => { + const stdout = convex("run", fn, JSON.stringify(args)); + return stdout.trim() ? JSON.parse(stdout) : null; +}; + +const assert = (condition, message) => { + if (!condition) throw new Error(`Assertion failed: ${message}`); + console.log(`✓ ${message}`); +}; + +if (!existsSync("dist/component/convex.config.js")) { + console.error("dist/ missing — run `npm run build` first"); + process.exit(1); +} +if (!existsSync(".env.local")) { + convex("init"); +} +convex("env", "set", "DAYTONA_API_KEY", apiKey); +if (process.env.DAYTONA_API_URL) { + convex("env", "set", "DAYTONA_API_URL", process.env.DAYTONA_API_URL); +} +convex("dev", "--once"); +console.log("✓ example app deployed to local Convex with the daytona component"); + +let sandboxId; +try { + const created = run("example:createSandbox", {}); + sandboxId = created.sandboxId; + assert(created.state === "started", `createSandbox → started (${sandboxId})`); + + const exec = run("example:runCommand", { sandboxId, command: "echo live-e2e" }); + assert(exec.exitCode === 0 && exec.result.includes("live-e2e"), "runCommand round-trip"); + + const py = run("example:runPython", { sandboxId, code: "print(6*7)" }); + assert(py.result.trim() === "42", "runCode(python) returns 42"); + + const content = run("example:writeAndReadFile", { + sandboxId, + path: "/home/daytona/live-e2e.txt", + content: "convex-live-e2e", + }); + assert(content === "convex-live-e2e", "writeFile/readFile round-trip (upload-v2)"); + + const preview = run("example:previewUrl", { sandboxId, port: 3000 }); + assert( + typeof preview.url === "string" && preview.url.startsWith("https://"), + `signed preview URL (${preview.url})`, + ); + + const executions = run("example:executions", { sandboxId }); + assert( + executions.length === 2 && executions.every((e) => e.status === "completed"), + "executions table recorded 2 completed runs", + ); + + run("example:deleteSandbox", { sandboxId }); + const sandboxes = run("example:sandboxes", {}); + const record = sandboxes.find((s) => s.sandboxId === sandboxId); + assert(record?.state === "destroyed", "deleteSandbox marks record destroyed"); + sandboxId = undefined; + + console.log("\nALL LIVE E2E CHECKS PASSED"); +} finally { + if (sandboxId) { + try { + run("example:deleteSandbox", { sandboxId }); + console.log(`✓ cleanup: deleted sandbox ${sandboxId}`); + } catch (error) { + console.error(`cleanup failed for sandbox ${sandboxId}:`, error.message); + } + } +} diff --git a/packages/convex/src/client/index.test.ts b/packages/convex/src/client/index.test.ts new file mode 100644 index 0000000..78c7c6f --- /dev/null +++ b/packages/convex/src/client/index.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vitest"; +import { componentsGeneric } from "convex/server"; +import type { ComponentApi } from "../component/_generated/component.js"; +import { Daytona, type RunActionCtx } from "./index.js"; + +const components = componentsGeneric() as unknown as { daytona: ComponentApi }; + +const nullCtx: RunActionCtx = { + runQuery: async () => null, + runMutation: async () => null, + runAction: async () => null, +}; + +describe("Daytona client configuration", () => { + test("throws a clear error when no API key is configured", async () => { + const previous = process.env.DAYTONA_API_KEY; + delete process.env.DAYTONA_API_KEY; + try { + const daytona = new Daytona(components.daytona); + await expect( + daytona.getPreviewUrl(nullCtx, { sandboxId: "sbx-1", port: 3000 }), + ).rejects.toThrow(/DAYTONA_API_KEY/); + } finally { + if (previous !== undefined) process.env.DAYTONA_API_KEY = previous; + } + }); + + test("explicit options win over environment variables", async () => { + const previous = process.env.DAYTONA_API_KEY; + process.env.DAYTONA_API_KEY = "env-key"; + try { + let sent: Record | undefined; + const capturingCtx: RunActionCtx = { + ...nullCtx, + runAction: async (_ref, args) => { + sent = args as Record; + return null; + }, + }; + const daytona = new Daytona(components.daytona, { apiKey: "opt-key" }); + await daytona.getPreviewUrl(capturingCtx, { sandboxId: "sbx-1", port: 80 }); + expect(sent?.config).toMatchObject({ apiKey: "opt-key" }); + } finally { + if (previous === undefined) delete process.env.DAYTONA_API_KEY; + else process.env.DAYTONA_API_KEY = previous; + } + }); +}); diff --git a/packages/convex/src/client/index.ts b/packages/convex/src/client/index.ts new file mode 100644 index 0000000..555da50 --- /dev/null +++ b/packages/convex/src/client/index.ts @@ -0,0 +1,271 @@ +/** + * Copyright Daytona Platforms Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Host-facing client for the Daytona Convex component. + * + * Instantiate it once with `components.daytona` and call its methods from your + * app's queries, mutations, and actions. Credentials resolve from the options + * you pass, falling back to the host deployment's environment variables + * (`DAYTONA_API_KEY`, `DAYTONA_API_URL`) — components can't read your env vars + * themselves, so the client forwards them on each call. + */ + +import type { FunctionReference, FunctionReturnType } from "convex/server"; +import type { ComponentApi } from "../component/_generated/component.js"; + +export interface DaytonaOptions { + /** Daytona API key. Defaults to `process.env.DAYTONA_API_KEY`. */ + apiKey?: string; + /** API base URL. Defaults to `process.env.DAYTONA_API_URL` or Daytona Cloud. */ + apiUrl?: string; +} + +export interface CreateSandboxArgs { + name?: string; + /** Snapshot (ID or name) to base the sandbox on. Mutually exclusive with `image`. */ + snapshot?: string; + /** Docker image to build from. Required if you want to set `cpu`/`memory`/`disk`. */ + image?: string; + target?: string; + user?: string; + envVars?: Record; + labels?: Record; + public?: boolean; + cpu?: number; + memory?: number; + disk?: number; + autoStopInterval?: number; + autoArchiveInterval?: number; + autoDeleteInterval?: number; + userKey?: string; + wait?: boolean; + waitTimeoutMs?: number; +} + +export interface RunCommandArgs { + sandboxId: string; + command: string; + cwd?: string; + envs?: Record; + timeoutSeconds?: number; + autoStart?: boolean; +} + +export interface RunCodeArgs { + sandboxId: string; + code: string; + language: string; + argv?: string[]; + envs?: Record; + timeoutSeconds?: number; + autoStart?: boolean; +} + +// Minimal ctx types so methods accept any host function ctx (query, mutation, +// or action) that can run the required kind of component function. +export type RunQueryCtx = { + runQuery: >( + query: Query, + args: Query["_args"], + ) => Promise>; +}; +export type RunMutationCtx = RunQueryCtx & { + runMutation: < + Mutation extends FunctionReference<"mutation", "public" | "internal">, + >( + mutation: Mutation, + args: Mutation["_args"], + ) => Promise>; +}; +export type RunActionCtx = RunMutationCtx & { + runAction: < + Action extends FunctionReference<"action", "public" | "internal">, + >( + action: Action, + args: Action["_args"], + ) => Promise>; +}; + +export class Daytona { + constructor( + private readonly component: ComponentApi, + private readonly options?: DaytonaOptions, + ) {} + + private get config() { + const apiKey = this.options?.apiKey ?? process.env.DAYTONA_API_KEY; + if (!apiKey) { + throw new Error( + "Daytona API key missing. Pass `apiKey` to the Daytona client or set the " + + "DAYTONA_API_KEY environment variable on your Convex deployment " + + "(npx convex env set DAYTONA_API_KEY ...).", + ); + } + return { + apiKey, + apiUrl: this.options?.apiUrl ?? process.env.DAYTONA_API_URL, + }; + } + + // ---- Lifecycle ---- + + /** Create a sandbox and (by default) wait until it's running. */ + async createSandbox(ctx: RunActionCtx, args: CreateSandboxArgs = {}) { + return await ctx.runAction(this.component.sandboxes.create, { + config: this.config, + ...args, + }); + } + + /** Start a stopped/archived sandbox and wait until it's running. */ + async startSandbox( + ctx: RunActionCtx, + args: { sandboxId: string; waitTimeoutMs?: number }, + ) { + return await ctx.runAction(this.component.sandboxes.start, { + config: this.config, + ...args, + }); + } + + /** Stop a running sandbox (filesystem is preserved). */ + async stopSandbox( + ctx: RunActionCtx, + args: { sandboxId: string; waitTimeoutMs?: number }, + ) { + return await ctx.runAction(this.component.sandboxes.stop, { + config: this.config, + ...args, + }); + } + + /** Delete a sandbox permanently. Its component record is kept, marked "destroyed". */ + async deleteSandbox(ctx: RunActionCtx, args: { sandboxId: string }) { + return await ctx.runAction(this.component.sandboxes.remove, { + config: this.config, + ...args, + }); + } + + /** Re-sync remote sandbox state into the component table. Null if the sandbox is gone. */ + async refreshSandbox(ctx: RunActionCtx, args: { sandboxId: string }) { + return await ctx.runAction(this.component.sandboxes.refresh, { + config: this.config, + ...args, + }); + } + + // ---- Reactive state (component tables — no Daytona API call) ---- + + /** Last-observed sandbox record, or null. Reactive. */ + async getSandbox(ctx: RunQueryCtx, args: { sandboxId: string }) { + return await ctx.runQuery(this.component.sandboxes.get, args); + } + + /** Sandbox records, optionally scoped to a `userKey`. Reactive. */ + async listSandboxes( + ctx: RunQueryCtx, + args: { userKey?: string; limit?: number } = {}, + ) { + return await ctx.runQuery(this.component.sandboxes.list, args); + } + + /** Execution history for a sandbox, newest first. Reactive. */ + async listExecutions( + ctx: RunQueryCtx, + args: { sandboxId: string; limit?: number }, + ) { + return await ctx.runQuery(this.component.executions.list, args); + } + + /** A single execution record by ID, or null. Reactive. */ + async getExecution(ctx: RunQueryCtx, args: { executionId: string }) { + return await ctx.runQuery(this.component.executions.get, { + executionId: args.executionId as never, + }); + } + + // ---- Execution ---- + + /** Run a shell command in the sandbox. Records an execution row. */ + async run(ctx: RunActionCtx, args: RunCommandArgs) { + return await ctx.runAction(this.component.process.run, { + config: this.config, + ...args, + }); + } + + /** Run a code snippet (python/javascript/typescript). Records an execution row. */ + async runCode(ctx: RunActionCtx, args: RunCodeArgs) { + return await ctx.runAction(this.component.process.runCode, { + config: this.config, + ...args, + }); + } + + // ---- Files ---- + + /** Read a file from the sandbox as a UTF-8 string. */ + async readFile(ctx: RunActionCtx, args: { sandboxId: string; path: string }) { + return await ctx.runAction(this.component.files.readFile, { + config: this.config, + ...args, + }); + } + + /** Write a UTF-8 string to a file in the sandbox. */ + async writeFile( + ctx: RunActionCtx, + args: { sandboxId: string; path: string; content: string }, + ) { + return await ctx.runAction(this.component.files.writeFile, { + config: this.config, + ...args, + }); + } + + /** List a directory in the sandbox. */ + async listFiles( + ctx: RunActionCtx, + args: { sandboxId: string; path: string }, + ) { + return await ctx.runAction(this.component.files.listFiles, { + config: this.config, + ...args, + }); + } + + /** Delete a file (or directory, with `recursive`) in the sandbox. */ + async deleteFile( + ctx: RunActionCtx, + args: { sandboxId: string; path: string; recursive?: boolean }, + ) { + return await ctx.runAction(this.component.files.deleteFile, { + config: this.config, + ...args, + }); + } + + // ---- Preview ---- + + /** Get a preview URL for a port. Signed URLs (default) embed a short-lived token. */ + async getPreviewUrl( + ctx: RunActionCtx, + args: { + sandboxId: string; + port: number; + signed?: boolean; + expiresInSeconds?: number; + }, + ) { + return await ctx.runAction(this.component.sandboxes.previewUrl, { + config: this.config, + ...args, + }); + } +} + +export type { ComponentApi }; diff --git a/packages/convex/src/component/_generated/api.ts b/packages/convex/src/component/_generated/api.ts new file mode 100644 index 0000000..d972303 --- /dev/null +++ b/packages/convex/src/component/_generated/api.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as daytona from "../daytona.js"; +import type * as executions from "../executions.js"; +import type * as files from "../files.js"; +import type * as process from "../process.js"; +import type * as sandboxes from "../sandboxes.js"; +import type * as types from "../types.js"; + +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; +import { anyApi, componentsGeneric } from "convex/server"; + +const fullApi: ApiFromModules<{ + daytona: typeof daytona; + executions: typeof executions; + files: typeof files; + process: typeof process; + sandboxes: typeof sandboxes; + types: typeof types; +}> = anyApi as any; + +/** + * A utility for referencing Convex functions in your app's public API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api: FilterApi< + typeof fullApi, + FunctionReference +> = anyApi as any; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ +export const internal: FilterApi< + typeof fullApi, + FunctionReference +> = anyApi as any; + +export const components = componentsGeneric() as unknown as {}; diff --git a/packages/convex/src/component/_generated/component.ts b/packages/convex/src/component/_generated/component.ts new file mode 100644 index 0000000..3930113 --- /dev/null +++ b/packages/convex/src/component/_generated/component.ts @@ -0,0 +1,273 @@ +/* eslint-disable */ +/** + * Generated `ComponentApi` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { FunctionReference } from "convex/server"; + +/** + * A utility for referencing a Convex component's exposed API. + * + * Useful when expecting a parameter like `components.myComponent`. + * Usage: + * ```ts + * async function myFunction(ctx: QueryCtx, component: ComponentApi) { + * return ctx.runQuery(component.someFile.someQuery, { ...args }); + * } + * ``` + */ +export type ComponentApi = + { + executions: { + get: FunctionReference< + "query", + "internal", + { executionId: string }, + null | { + _creationTime: number; + _id: string; + cwd?: string; + error?: string; + exitCode?: number; + finishedAt?: number; + input: string; + kind: "command" | "code"; + result?: string; + sandboxId: string; + startedAt: number; + status: "running" | "completed" | "failed"; + }, + Name + >; + list: FunctionReference< + "query", + "internal", + { limit?: number; sandboxId: string }, + Array<{ + _creationTime: number; + _id: string; + cwd?: string; + error?: string; + exitCode?: number; + finishedAt?: number; + input: string; + kind: "command" | "code"; + result?: string; + sandboxId: string; + startedAt: number; + status: "running" | "completed" | "failed"; + }>, + Name + >; + }; + files: { + deleteFile: FunctionReference< + "action", + "internal", + { + config: { apiKey: string; apiUrl?: string }; + path: string; + recursive?: boolean; + sandboxId: string; + }, + null, + Name + >; + listFiles: FunctionReference< + "action", + "internal", + { + config: { apiKey: string; apiUrl?: string }; + path: string; + sandboxId: string; + }, + Array<{ + isDir: boolean; + modTime?: string; + name: string; + size?: number; + }>, + Name + >; + readFile: FunctionReference< + "action", + "internal", + { + config: { apiKey: string; apiUrl?: string }; + path: string; + sandboxId: string; + }, + string, + Name + >; + writeFile: FunctionReference< + "action", + "internal", + { + config: { apiKey: string; apiUrl?: string }; + content: string; + path: string; + sandboxId: string; + }, + null, + Name + >; + }; + process: { + run: FunctionReference< + "action", + "internal", + { + autoStart?: boolean; + command: string; + config: { apiKey: string; apiUrl?: string }; + cwd?: string; + envs?: Record; + sandboxId: string; + timeoutSeconds?: number; + }, + { executionId: string; exitCode: number; result: string }, + Name + >; + runCode: FunctionReference< + "action", + "internal", + { + argv?: Array; + autoStart?: boolean; + code: string; + config: { apiKey: string; apiUrl?: string }; + envs?: Record; + language: string; + sandboxId: string; + timeoutSeconds?: number; + }, + { executionId: string; exitCode: number; result: string }, + Name + >; + }; + sandboxes: { + create: FunctionReference< + "action", + "internal", + { + autoArchiveInterval?: number; + autoDeleteInterval?: number; + autoStopInterval?: number; + config: { apiKey: string; apiUrl?: string }; + cpu?: number; + disk?: number; + envVars?: Record; + image?: string; + labels?: Record; + memory?: number; + name?: string; + public?: boolean; + snapshot?: string; + target?: string; + user?: string; + userKey?: string; + wait?: boolean; + waitTimeoutMs?: number; + }, + { sandboxId: string; state: string }, + Name + >; + get: FunctionReference< + "query", + "internal", + { sandboxId: string }, + null | { + _creationTime: number; + _id: string; + createdAt: number; + labels?: Record; + lastError?: string; + name?: string; + public?: boolean; + sandboxId: string; + snapshot?: string; + state: string; + target?: string; + updatedAt: number; + userKey?: string; + }, + Name + >; + list: FunctionReference< + "query", + "internal", + { limit?: number; userKey?: string }, + Array<{ + _creationTime: number; + _id: string; + createdAt: number; + labels?: Record; + lastError?: string; + name?: string; + public?: boolean; + sandboxId: string; + snapshot?: string; + state: string; + target?: string; + updatedAt: number; + userKey?: string; + }>, + Name + >; + previewUrl: FunctionReference< + "action", + "internal", + { + config: { apiKey: string; apiUrl?: string }; + expiresInSeconds?: number; + port: number; + sandboxId: string; + signed?: boolean; + }, + { port: number; token?: string; url: string }, + Name + >; + refresh: FunctionReference< + "action", + "internal", + { config: { apiKey: string; apiUrl?: string }; sandboxId: string }, + null | { sandboxId: string; state: string }, + Name + >; + remove: FunctionReference< + "action", + "internal", + { config: { apiKey: string; apiUrl?: string }; sandboxId: string }, + null, + Name + >; + start: FunctionReference< + "action", + "internal", + { + config: { apiKey: string; apiUrl?: string }; + sandboxId: string; + waitTimeoutMs?: number; + }, + { sandboxId: string; state: string }, + Name + >; + stop: FunctionReference< + "action", + "internal", + { + config: { apiKey: string; apiUrl?: string }; + sandboxId: string; + waitTimeoutMs?: number; + }, + { sandboxId: string; state: string }, + Name + >; + }; + }; diff --git a/packages/convex/src/component/_generated/dataModel.ts b/packages/convex/src/component/_generated/dataModel.ts new file mode 100644 index 0000000..f97fd19 --- /dev/null +++ b/packages/convex/src/component/_generated/dataModel.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; diff --git a/packages/convex/src/component/_generated/server.ts b/packages/convex/src/component/_generated/server.ts new file mode 100644 index 0000000..1a7688a --- /dev/null +++ b/packages/convex/src/component/_generated/server.ts @@ -0,0 +1,169 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} from "convex/server"; +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +type Env = { + readonly CONVEX_CLOUD_URL: string; + readonly CONVEX_SITE_URL: string; +}; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query: QueryBuilder = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery: QueryBuilder = + internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation: MutationBuilder = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation: MutationBuilder = + internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action: ActionBuilder = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction: ActionBuilder = + internalActionGeneric; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export const httpAction: HttpActionBuilder = httpActionGeneric; +export const env: Env = (globalThis as unknown as { process: { env: Env } }) + .process.env; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + * + * If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/packages/convex/src/component/component.test.ts b/packages/convex/src/component/component.test.ts new file mode 100644 index 0000000..f5968a6 --- /dev/null +++ b/packages/convex/src/component/component.test.ts @@ -0,0 +1,521 @@ +/// + +import { afterEach, describe, expect, test, vi } from "vitest"; +import { api, internal } from "./_generated/api.js"; +import { initConvexTest } from "./setup.test.js"; + +const config = { apiKey: "test-key", apiUrl: "https://daytona.test/api" }; + +/** Build a fetch stub that routes by method + URL substring. */ +function stubFetch( + routes: Array<{ + method: string; + match: string; + response: () => Response; + }>, +) { + const calls: Array<{ + method: string; + url: string; + body?: string; + headers: Record; + }> = []; + const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + calls.push({ + method, + url, + body: typeof init?.body === "string" ? init.body : undefined, + headers: (init?.headers ?? {}) as Record, + }); + const route = routes.find( + (r) => r.method === method && url.includes(r.match), + ); + if (!route) { + return new Response(`no stub for ${method} ${url}`, { status: 500 }); + } + return route.response(); + }); + vi.stubGlobal("fetch", mock); + return { calls, mock }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const startedSandbox = { + id: "sbx-1", + state: "started", + toolboxProxyUrl: "https://proxy.daytona.test/toolbox", +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("state bookkeeping (queries + internal mutations)", () => { + test("upsertSandbox inserts then patches without clearing fields", async () => { + const t = initConvexTest(); + await t.mutation(internal.sandboxes.upsertSandbox, { + sandboxId: "sbx-1", + state: "creating", + snapshot: "snap-a", + userKey: "user-1", + }); + await t.mutation(internal.sandboxes.upsertSandbox, { + sandboxId: "sbx-1", + state: "started", + }); + + const sandbox = await t.query(api.sandboxes.get, { sandboxId: "sbx-1" }); + expect(sandbox?.state).toBe("started"); + // Partial update must not clear previously known fields. + expect(sandbox?.snapshot).toBe("snap-a"); + expect(sandbox?.userKey).toBe("user-1"); + }); + + test("list scopes by userKey", async () => { + const t = initConvexTest(); + await t.mutation(internal.sandboxes.upsertSandbox, { + sandboxId: "sbx-1", + state: "started", + userKey: "user-1", + }); + await t.mutation(internal.sandboxes.upsertSandbox, { + sandboxId: "sbx-2", + state: "started", + userKey: "user-2", + }); + + expect(await t.query(api.sandboxes.list, {})).toHaveLength(2); + const scoped = await t.query(api.sandboxes.list, { userKey: "user-1" }); + expect(scoped).toHaveLength(1); + expect(scoped[0].sandboxId).toBe("sbx-1"); + }); + + test("execution lifecycle: running → completed", async () => { + const t = initConvexTest(); + const executionId = await t.mutation(internal.executions.startExecution, { + sandboxId: "sbx-1", + kind: "command", + input: "echo hi", + }); + let execution = await t.query(api.executions.get, { executionId }); + expect(execution?.status).toBe("running"); + + await t.mutation(internal.executions.finishExecution, { + executionId, + status: "completed", + exitCode: 0, + result: "hi", + }); + execution = await t.query(api.executions.get, { executionId }); + expect(execution?.status).toBe("completed"); + expect(execution?.exitCode).toBe(0); + expect(execution?.finishedAt).toBeDefined(); + + const history = await t.query(api.executions.list, { + sandboxId: "sbx-1", + }); + expect(history).toHaveLength(1); + }); +}); + +describe("sandbox lifecycle actions", () => { + test("create records the sandbox and waits for started", async () => { + const t = initConvexTest(); + let polls = 0; + stubFetch([ + { + method: "POST", + match: "/api/sandbox", + response: () => json({ id: "sbx-1", state: "creating" }), + }, + { + method: "GET", + match: "/api/sandbox/sbx-1", + response: () => + json(++polls < 2 ? { id: "sbx-1", state: "creating" } : startedSandbox), + }, + ]); + + const result = await t.action(api.sandboxes.create, { + config, + snapshot: "snap-a", + userKey: "user-1", + }); + expect(result).toEqual({ sandboxId: "sbx-1", state: "started" }); + + const sandbox = await t.query(api.sandboxes.get, { sandboxId: "sbx-1" }); + expect(sandbox?.state).toBe("started"); + expect(sandbox?.userKey).toBe("user-1"); + }); + + test("create rejects resources without image, and snapshot+image together", async () => { + const t = initConvexTest(); + stubFetch([]); + + await expect( + t.action(api.sandboxes.create, { config, cpu: 2 }), + ).rejects.toThrow(/only valid with image-based creation/); + await expect( + t.action(api.sandboxes.create, { + config, + snapshot: "snap-a", + image: "python:3.12", + }), + ).rejects.toThrow(/not both/); + }); + + test("create from image sends buildInfo and allows resources", async () => { + const t = initConvexTest(); + const { calls } = stubFetch([ + { + method: "POST", + match: "/api/sandbox", + response: () => json({ id: "sbx-img", state: "pending_build" }), + }, + { + method: "GET", + match: "/api/sandbox/sbx-img", + response: () => json({ id: "sbx-img", state: "started" }), + }, + ]); + + const result = await t.action(api.sandboxes.create, { + config, + image: "python:3.12", + cpu: 2, + memory: 4, + }); + expect(result).toEqual({ sandboxId: "sbx-img", state: "started" }); + + const createCall = calls.find( + (c) => c.method === "POST" && c.url.endsWith("/api/sandbox"), + ); + expect(JSON.parse(createCall?.body ?? "{}")).toMatchObject({ + buildInfo: { dockerfileContent: "FROM python:3.12" }, + cpu: 2, + memory: 4, + }); + }); + + test("create surfaces failure states and records the error", async () => { + const t = initConvexTest(); + stubFetch([ + { + method: "POST", + match: "/api/sandbox", + response: () => json({ id: "sbx-1", state: "creating" }), + }, + { + method: "GET", + match: "/api/sandbox/sbx-1", + response: () => + json({ id: "sbx-1", state: "build_failed", errorReason: "bad image" }), + }, + ]); + + await expect( + t.action(api.sandboxes.create, { config }), + ).rejects.toThrow(/build_failed/); + const sandbox = await t.query(api.sandboxes.get, { sandboxId: "sbx-1" }); + expect(sandbox?.lastError).toMatch(/build_failed/); + }); + + test("refresh returns null and marks destroyed on 404", async () => { + const t = initConvexTest(); + await t.mutation(internal.sandboxes.upsertSandbox, { + sandboxId: "sbx-gone", + state: "started", + }); + stubFetch([ + { + method: "GET", + match: "/api/sandbox/sbx-gone", + response: () => new Response("not found", { status: 404 }), + }, + ]); + + const result = await t.action(api.sandboxes.refresh, { + config, + sandboxId: "sbx-gone", + }); + expect(result).toBeNull(); + const sandbox = await t.query(api.sandboxes.get, { sandboxId: "sbx-gone" }); + expect(sandbox?.state).toBe("destroyed"); + }); + + test("stop treats an already-deleted sandbox (404) as destroyed", async () => { + const t = initConvexTest(); + await t.mutation(internal.sandboxes.upsertSandbox, { + sandboxId: "sbx-ephemeral", + state: "started", + }); + stubFetch([ + { + method: "POST", + match: "/api/sandbox/sbx-ephemeral/stop", + response: () => new Response("not found", { status: 404 }), + }, + ]); + + const result = await t.action(api.sandboxes.stop, { + config, + sandboxId: "sbx-ephemeral", + }); + expect(result).toEqual({ sandboxId: "sbx-ephemeral", state: "destroyed" }); + const sandbox = await t.query(api.sandboxes.get, { sandboxId: "sbx-ephemeral" }); + expect(sandbox?.state).toBe("destroyed"); + }); + + test("previewUrl returns signed URL fields", async () => { + const t = initConvexTest(); + stubFetch([ + { + method: "GET", + match: "/ports/3000/signed-preview-url", + response: () => + json({ url: "https://3000-sbx-1.preview.daytona.test?tkn=abc", port: 3000 }), + }, + ]); + + const result = await t.action(api.sandboxes.previewUrl, { + config, + sandboxId: "sbx-1", + port: 3000, + }); + expect(result.url).toContain("preview.daytona.test"); + expect(result.port).toBe(3000); + }); +}); + +describe("process actions", () => { + test("run executes via the toolbox and records a completed execution", async () => { + const t = initConvexTest(); + const { calls } = stubFetch([ + { + method: "GET", + match: "/api/sandbox/sbx-1", + response: () => json(startedSandbox), + }, + { + method: "POST", + match: "/process/execute", + response: () => json({ exitCode: 0, result: "hello\n" }), + }, + ]); + + const result = await t.action(api.process.run, { + config, + sandboxId: "sbx-1", + command: "echo hello", + }); + expect(result.exitCode).toBe(0); + expect(result.result).toBe("hello\n"); + + // Toolbox URL is composed as {toolboxProxyUrl}/{sandboxId}{path}. + const executeCall = calls.find((c) => c.url.includes("/process/execute")); + expect(executeCall?.url).toBe( + "https://proxy.daytona.test/toolbox/sbx-1/process/execute", + ); + expect(executeCall?.headers.Authorization).toBe("Bearer test-key"); + expect(JSON.parse(executeCall?.body ?? "{}")).toMatchObject({ + command: "echo hello", + // Default timeout is bounded below Convex's 10-minute action ceiling. + timeout: 540, + }); + + const history = await t.query(api.executions.list, { + sandboxId: "sbx-1", + }); + expect(history).toHaveLength(1); + expect(history[0].status).toBe("completed"); + expect(history[0].result).toBe("hello\n"); + }); + + test("run records a failed execution when the toolbox errors", async () => { + const t = initConvexTest(); + stubFetch([ + { + method: "GET", + match: "/api/sandbox/sbx-1", + response: () => json(startedSandbox), + }, + { + method: "POST", + match: "/process/execute", + response: () => new Response("boom", { status: 500 }), + }, + ]); + + await expect( + t.action(api.process.run, { + config, + sandboxId: "sbx-1", + command: "exit 1", + }), + ).rejects.toThrow(/500/); + + const history = await t.query(api.executions.list, { + sandboxId: "sbx-1", + }); + expect(history).toHaveLength(1); + expect(history[0].status).toBe("failed"); + expect(history[0].error).toMatch(/500/); + }); + + test("runCode posts to code-run and records kind=code", async () => { + const t = initConvexTest(); + const { calls } = stubFetch([ + { + method: "GET", + match: "/api/sandbox/sbx-1", + response: () => json(startedSandbox), + }, + { + method: "POST", + match: "/process/code-run", + response: () => json({ exitCode: 0, result: "42" }), + }, + ]); + + const result = await t.action(api.process.runCode, { + config, + sandboxId: "sbx-1", + code: "print(42)", + language: "python", + }); + expect(result.result).toBe("42"); + const call = calls.find((c) => c.url.includes("/process/code-run")); + expect(JSON.parse(call?.body ?? "{}")).toMatchObject({ + code: "print(42)", + language: "python", + }); + + const history = await t.query(api.executions.list, { + sandboxId: "sbx-1", + }); + expect(history[0].kind).toBe("code"); + }); + + test("run rejects non-positive timeoutSeconds", async () => { + const t = initConvexTest(); + stubFetch([]); + await expect( + t.action(api.process.run, { + config, + sandboxId: "sbx-1", + command: "true", + timeoutSeconds: -5, + }), + ).rejects.toThrow(/positive number/); + }); + + test("run auto-starts a stopped sandbox before executing", async () => { + const t = initConvexTest(); + let state = "stopped"; + const { calls } = stubFetch([ + { + method: "GET", + match: "/api/sandbox/sbx-1", + response: () => json({ ...startedSandbox, state }), + }, + { + method: "POST", + match: "/api/sandbox/sbx-1/start", + response: () => { + state = "started"; + return json({}); + }, + }, + { + method: "POST", + match: "/process/execute", + response: () => json({ exitCode: 0, result: "ok" }), + }, + ]); + + const result = await t.action(api.process.run, { + config, + sandboxId: "sbx-1", + command: "true", + }); + expect(result.exitCode).toBe(0); + expect(calls.some((c) => c.url.endsWith("/sandbox/sbx-1/start"))).toBe(true); + }); +}); + +describe("file actions", () => { + test("readFile and writeFile round-trip through the toolbox", async () => { + const t = initConvexTest(); + const { calls } = stubFetch([ + { + method: "GET", + match: "/api/sandbox/sbx-1", + response: () => json(startedSandbox), + }, + { + method: "POST", + match: "/files/upload-v2", + response: () => new Response("", { status: 200 }), + }, + { + method: "GET", + match: "/files/download", + response: () => new Response("file-content"), + }, + ]); + + await t.action(api.files.writeFile, { + config, + sandboxId: "sbx-1", + path: "/home/daytona/hello.txt", + content: "file-content", + }); + const content = await t.action(api.files.readFile, { + config, + sandboxId: "sbx-1", + path: "/home/daytona/hello.txt", + }); + expect(content).toBe("file-content"); + + const upload = calls.find((c) => c.url.includes("/files/upload-v2")); + expect(upload?.url).toContain("path=%2Fhome%2Fdaytona%2Fhello.txt"); + }); + + test("listFiles maps toolbox file info", async () => { + const t = initConvexTest(); + stubFetch([ + { + method: "GET", + match: "/api/sandbox/sbx-1", + response: () => json(startedSandbox), + }, + { + method: "GET", + match: "/files?", + response: () => + json([ + { name: "src", isDir: true, extra: "dropped" }, + { name: "main.py", isDir: false, size: 12 }, + ]), + }, + ]); + + const files = await t.action(api.files.listFiles, { + config, + sandboxId: "sbx-1", + path: "/home/daytona", + }); + expect(files).toEqual([ + { name: "src", isDir: true, size: undefined, modTime: undefined }, + { name: "main.py", isDir: false, size: 12, modTime: undefined }, + ]); + }); +}); diff --git a/packages/convex/src/component/convex.config.ts b/packages/convex/src/component/convex.config.ts new file mode 100644 index 0000000..3b23518 --- /dev/null +++ b/packages/convex/src/component/convex.config.ts @@ -0,0 +1,3 @@ +import { defineComponent } from "convex/server"; + +export default defineComponent("daytona"); diff --git a/packages/convex/src/component/daytona.ts b/packages/convex/src/component/daytona.ts new file mode 100644 index 0000000..c02358f --- /dev/null +++ b/packages/convex/src/component/daytona.ts @@ -0,0 +1,328 @@ +/** + * Copyright Daytona Platforms Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Minimal fetch-based Daytona REST client. + * + * Convex components run in Convex's default JavaScript runtime, which supports + * `fetch` but not the Node.js APIs the full `@daytona/sdk` depends on (ws, + * aws-sdk, tar, …) — and bundling that SDK would blow up component bundle size + * and cold starts. So, like the official Convex components for other external + * services, this wraps the Daytona REST + toolbox APIs directly. Endpoint and + * payload shapes mirror `packages/n8n-nodes-daytona`. + */ + +import { + DEFAULT_API_URL, + type ApiSandbox, + type CreateSandboxRequest, + type DaytonaConfig, + type FileInfo, + type PreviewUrlResponse, + type ProcessExecutionResponse, + type SandboxState, +} from "./types.js"; + +export class DaytonaApiError extends Error { + constructor( + message: string, + public readonly status?: number, + ) { + super(message); + this.name = "DaytonaApiError"; + } +} + +type Query = Record; + +const SANDBOX_READY_POLL = { intervalMs: 1000, timeoutMs: 60_000 }; +/** Terminal failure states — polling for readiness bails out on these. */ +const FAILURE_STATES: ReadonlySet = new Set([ + "error", + "build_failed", +]); + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class DaytonaClient { + private readonly baseUrl: string; + /** toolboxProxyUrl per sandbox, cached for the lifetime of one action call. */ + private readonly toolboxBaseCache = new Map(); + + constructor(private readonly config: DaytonaConfig) { + // A blank env var must fall back to Daytona Cloud, not produce relative URLs. + const apiUrl = config.apiUrl?.trim(); + this.baseUrl = (apiUrl || DEFAULT_API_URL).replace(/\/+$/, ""); + } + + private headers(json: boolean): Record { + return { + Authorization: `Bearer ${this.config.apiKey}`, + ...(json ? { "Content-Type": "application/json" } : {}), + }; + } + + private async request( + url: string, + method: string, + body?: unknown, + query?: Query, + ): Promise { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) params.set(key, String(value)); + } + // `URLSearchParams.size` is not implemented in Convex's runtime — use toString(). + const encoded = params.toString(); + const qs = encoded ? `?${encoded}` : ""; + const isForm = body instanceof FormData; + const response = await fetch(`${url}${qs}`, { + method, + headers: this.headers(body !== undefined && !isForm), + body: + body === undefined + ? undefined + : isForm + ? body + : JSON.stringify(body), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new DaytonaApiError( + `Daytona API ${method} ${url} failed with ${response.status}: ${detail.slice(0, 500)}`, + response.status, + ); + } + return response; + } + + private async api( + method: string, + path: string, + body?: unknown, + query?: Query, + ): Promise { + const response = await this.request( + `${this.baseUrl}${path}`, + method, + body, + query, + ); + const text = await response.text(); + return (text ? JSON.parse(text) : undefined) as T; + } + + /** + * Toolbox endpoints hang off a per-sandbox proxy base: + * `{toolboxProxyUrl}/{sandboxId}{path}`. The `toolboxProxyUrl` returned by + * `GET /sandbox/{id}` already includes the `/toolbox` segment. + */ + private async toolboxBase(sandboxId: string): Promise { + const cached = this.toolboxBaseCache.get(sandboxId); + if (cached) return cached; + const sandbox = await this.getSandbox(sandboxId); + if (!sandbox.toolboxProxyUrl) { + throw new DaytonaApiError( + `Sandbox "${sandboxId}" has no toolboxProxyUrl (state: ${sandbox.state}). It may not be started yet.`, + ); + } + const base = sandbox.toolboxProxyUrl.replace(/\/+$/, ""); + this.toolboxBaseCache.set(sandboxId, base); + return base; + } + + private async toolbox( + sandboxId: string, + method: string, + path: string, + body?: unknown, + query?: Query, + ): Promise { + const base = await this.toolboxBase(sandboxId); + return await this.request( + `${base}/${encodeURIComponent(sandboxId)}${path}`, + method, + body, + query, + ); + } + + // ---- Sandbox lifecycle ---- + + async createSandbox(request: CreateSandboxRequest): Promise { + return await this.api("POST", "/sandbox", request); + } + + async getSandbox(sandboxId: string): Promise { + return await this.api( + "GET", + `/sandbox/${encodeURIComponent(sandboxId)}`, + ); + } + + async startSandbox(sandboxId: string): Promise { + await this.api("POST", `/sandbox/${encodeURIComponent(sandboxId)}/start`); + } + + async stopSandbox(sandboxId: string): Promise { + await this.api("POST", `/sandbox/${encodeURIComponent(sandboxId)}/stop`); + } + + async deleteSandbox(sandboxId: string): Promise { + await this.api("DELETE", `/sandbox/${encodeURIComponent(sandboxId)}`); + } + + /** Poll until the sandbox reaches one of `targets`; throws on failure states. */ + async waitForState( + sandboxId: string, + targets: SandboxState[], + options?: { timeoutMs?: number; intervalMs?: number }, + ): Promise { + const targetSet = new Set(targets); + const timeoutMs = options?.timeoutMs ?? SANDBOX_READY_POLL.timeoutMs; + const intervalMs = options?.intervalMs ?? SANDBOX_READY_POLL.intervalMs; + const startedAt = Date.now(); + let lastState: SandboxState = "unknown"; + while (Date.now() - startedAt <= timeoutMs) { + const sandbox = await this.getSandbox(sandboxId); + lastState = sandbox.state; + if (targetSet.has(sandbox.state)) return sandbox; + if (FAILURE_STATES.has(sandbox.state)) { + throw new DaytonaApiError( + `Sandbox "${sandboxId}" entered failure state "${sandbox.state}"${sandbox.errorReason ? `: ${sandbox.errorReason}` : ""}`, + ); + } + await sleep(intervalMs); + } + throw new DaytonaApiError( + `Timed out after ${timeoutMs}ms waiting for sandbox "${sandboxId}" to reach [${targets.join(", ")}]. Last state: "${lastState}".`, + ); + } + + /** Start a stopped/paused/archived sandbox and wait until it's running. No-op if started. */ + async ensureStarted( + sandboxId: string, + options?: { timeoutMs?: number }, + ): Promise { + const sandbox = await this.getSandbox(sandboxId); + if (sandbox.state === "started") return sandbox; + if ( + sandbox.state === "stopped" || + sandbox.state === "paused" || + sandbox.state === "archived" + ) { + await this.startSandbox(sandboxId); + } + return await this.waitForState(sandboxId, ["started"], options); + } + + // ---- Process (toolbox) ---- + + async execute( + sandboxId: string, + args: { + command: string; + cwd?: string; + envs?: Record; + timeoutSeconds?: number; + }, + ): Promise { + const response = await this.toolbox(sandboxId, "POST", "/process/execute", { + command: args.command, + cwd: args.cwd, + envs: args.envs, + timeout: args.timeoutSeconds, + }); + return (await response.json()) as ProcessExecutionResponse; + } + + async runCode( + sandboxId: string, + args: { + code: string; + language: string; + argv?: string[]; + envs?: Record; + timeoutSeconds?: number; + }, + ): Promise { + const response = await this.toolbox( + sandboxId, + "POST", + "/process/code-run", + { + code: args.code, + language: args.language, + argv: args.argv, + envs: args.envs, + timeout: args.timeoutSeconds, + }, + ); + return (await response.json()) as ProcessExecutionResponse; + } + + // ---- Files (toolbox) ---- + + async downloadFile(sandboxId: string, path: string): Promise { + const response = await this.toolbox(sandboxId, "GET", "/files/download", undefined, { path }); + return await response.text(); + } + + async uploadFile( + sandboxId: string, + path: string, + content: string, + ): Promise { + const form = new FormData(); + const filename = path.split("/").pop() || "upload"; + form.append( + "file", + new Blob([content], { type: "application/octet-stream" }), + filename, + ); + await this.toolbox(sandboxId, "POST", "/files/upload-v2", form, { path }); + } + + async listFiles(sandboxId: string, path: string): Promise { + const response = await this.toolbox(sandboxId, "GET", "/files", undefined, { path }); + const files = (await response.json()) as FileInfo[]; + return Array.isArray(files) ? files : []; + } + + async deleteFile( + sandboxId: string, + path: string, + recursive?: boolean, + ): Promise { + await this.toolbox(sandboxId, "DELETE", "/files", undefined, { + path, + ...(recursive ? { recursive: true } : {}), + }); + } + + // ---- Preview URLs ---- + + async previewUrl(sandboxId: string, port: number): Promise { + return await this.api( + "GET", + `/sandbox/${encodeURIComponent(sandboxId)}/ports/${port}/preview-url`, + ); + } + + async signedPreviewUrl( + sandboxId: string, + port: number, + expiresInSeconds?: number, + ): Promise { + return await this.api( + "GET", + `/sandbox/${encodeURIComponent(sandboxId)}/ports/${port}/signed-preview-url`, + undefined, + { expiresInSeconds }, + ); + } +} diff --git a/packages/convex/src/component/executions.ts b/packages/convex/src/component/executions.ts new file mode 100644 index 0000000..ce3ce04 --- /dev/null +++ b/packages/convex/src/component/executions.ts @@ -0,0 +1,73 @@ +/** + * Copyright Daytona Platforms Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Execution records: reactive queries over the `executions` table, plus the + * internal bookkeeping mutations used by the process actions. + */ + +import { v } from "convex/values"; +import { internalMutation, query } from "./_generated/server.js"; +import { executionFields } from "./schema.js"; +import { clampLimit } from "./types.js"; + +export const executionDoc = v.object({ + ...executionFields, + _id: v.id("executions"), + _creationTime: v.number(), +}); + +export const list = query({ + args: { sandboxId: v.string(), limit: v.optional(v.number()) }, + returns: v.array(executionDoc), + handler: async (ctx, args) => { + return await ctx.db + .query("executions") + .withIndex("sandboxId", (q) => q.eq("sandboxId", args.sandboxId)) + .order("desc") + .take(clampLimit(args.limit, 50)); + }, +}); + +export const get = query({ + args: { executionId: v.id("executions") }, + returns: v.union(v.null(), executionDoc), + handler: async (ctx, args) => { + return await ctx.db.get(args.executionId); + }, +}); + +export const startExecution = internalMutation({ + args: { + sandboxId: v.string(), + kind: v.union(v.literal("command"), v.literal("code")), + input: v.string(), + cwd: v.optional(v.string()), + }, + returns: v.id("executions"), + handler: async (ctx, args) => { + return await ctx.db.insert("executions", { + ...args, + status: "running", + startedAt: Date.now(), + }); + }, +}); + +export const finishExecution = internalMutation({ + args: { + executionId: v.id("executions"), + status: v.union(v.literal("completed"), v.literal("failed")), + exitCode: v.optional(v.number()), + result: v.optional(v.string()), + error: v.optional(v.string()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const { executionId, ...rest } = args; + await ctx.db.patch(executionId, { ...rest, finishedAt: Date.now() }); + return null; + }, +}); diff --git a/packages/convex/src/component/files.ts b/packages/convex/src/component/files.ts new file mode 100644 index 0000000..8c839a1 --- /dev/null +++ b/packages/convex/src/component/files.ts @@ -0,0 +1,85 @@ +/** + * Copyright Daytona Platforms Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Sandbox filesystem actions (Daytona toolbox files API). Content crosses the + * Convex function boundary as UTF-8 strings — fine for source files and text + * artifacts. Keep individual files under Convex's function argument/return + * limits (16 MiB); move bigger payloads via URLs inside the sandbox instead. + */ + +import { v } from "convex/values"; +import { action } from "./_generated/server.js"; +import { DaytonaClient } from "./daytona.js"; +import { configValidator } from "./types.js"; + +export const readFile = action({ + args: { + config: configValidator, + sandboxId: v.string(), + path: v.string(), + }, + returns: v.string(), + handler: async (_ctx, args) => { + const client = new DaytonaClient(args.config); + return await client.downloadFile(args.sandboxId, args.path); + }, +}); + +export const writeFile = action({ + args: { + config: configValidator, + sandboxId: v.string(), + path: v.string(), + content: v.string(), + }, + returns: v.null(), + handler: async (_ctx, args) => { + const client = new DaytonaClient(args.config); + await client.uploadFile(args.sandboxId, args.path, args.content); + return null; + }, +}); + +export const listFiles = action({ + args: { + config: configValidator, + sandboxId: v.string(), + path: v.string(), + }, + returns: v.array( + v.object({ + name: v.string(), + isDir: v.boolean(), + size: v.optional(v.number()), + modTime: v.optional(v.string()), + }), + ), + handler: async (_ctx, args) => { + const client = new DaytonaClient(args.config); + const files = await client.listFiles(args.sandboxId, args.path); + return files.map((file) => ({ + name: file.name, + isDir: Boolean(file.isDir), + size: typeof file.size === "number" ? file.size : undefined, + modTime: typeof file.modTime === "string" ? file.modTime : undefined, + })); + }, +}); + +export const deleteFile = action({ + args: { + config: configValidator, + sandboxId: v.string(), + path: v.string(), + recursive: v.optional(v.boolean()), + }, + returns: v.null(), + handler: async (_ctx, args) => { + const client = new DaytonaClient(args.config); + await client.deleteFile(args.sandboxId, args.path, args.recursive); + return null; + }, +}); diff --git a/packages/convex/src/component/process.ts b/packages/convex/src/component/process.ts new file mode 100644 index 0000000..77f9a36 --- /dev/null +++ b/packages/convex/src/component/process.ts @@ -0,0 +1,183 @@ +/** + * Copyright Daytona Platforms Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Command/code execution actions. Every execution is recorded in the + * `executions` table (running → completed/failed) so the host app can render + * live status and history via reactive queries. + * + * Executions are synchronous within the action: Daytona's toolbox `execute` + * resolves when the command finishes. Convex actions have a 10-minute ceiling — + * for longer-running work, start a background process inside the sandbox (e.g. + * `nohup … &`) and poll it with follow-up executions. + */ + +import { v } from "convex/values"; +import { internal } from "./_generated/api.js"; +import type { Id } from "./_generated/dataModel.js"; +import { action, type ActionCtx } from "./_generated/server.js"; +import { DaytonaClient } from "./daytona.js"; +import { + MAX_RETURNED_OUTPUT, + MAX_STORED_INPUT, + MAX_STORED_OUTPUT, + configValidator, + truncate, + type ProcessExecutionResponse, +} from "./types.js"; + +/** + * Convex actions hard-timeout at 10 minutes — a command outliving the action + * would leave its execution row stuck "running" (the catch below never runs). + * So the remote command is always bounded BELOW the action ceiling: default + * 9 minutes, capped at 9.5. + */ +const DEFAULT_EXEC_TIMEOUT_SECONDS = 540; +const MAX_EXEC_TIMEOUT_SECONDS = 570; + +function boundedTimeout(requested: number | undefined): number { + if (requested === undefined) return DEFAULT_EXEC_TIMEOUT_SECONDS; + if (!Number.isFinite(requested) || requested <= 0) { + throw new Error( + `timeoutSeconds must be a positive number, got ${requested}`, + ); + } + return Math.min(requested, MAX_EXEC_TIMEOUT_SECONDS); +} + +const executionResult = v.object({ + executionId: v.id("executions"), + exitCode: v.number(), + result: v.string(), +}); + +async function recordAndRun( + ctx: ActionCtx, + args: { + sandboxId: string; + kind: "command" | "code"; + input: string; + cwd?: string; + autoStart?: boolean; + }, + client: DaytonaClient, + execute: () => Promise, +): Promise<{ executionId: Id<"executions">; exitCode: number; result: string }> { + const executionId: Id<"executions"> = await ctx.runMutation( + internal.executions.startExecution, + { + sandboxId: args.sandboxId, + kind: args.kind, + input: truncate(args.input, MAX_STORED_INPUT), + cwd: args.cwd, + }, + ); + try { + if (args.autoStart !== false) { + const sandbox = await client.ensureStarted(args.sandboxId); + await ctx.runMutation(internal.sandboxes.upsertSandbox, { + sandboxId: args.sandboxId, + state: sandbox.state, + }); + } + const response = await execute(); + const output = response.result ?? response.artifacts?.stdout ?? ""; + await ctx.runMutation(internal.executions.finishExecution, { + executionId, + status: "completed", + exitCode: response.exitCode, + result: truncate(output, MAX_STORED_OUTPUT), + }); + return { + executionId, + exitCode: response.exitCode ?? 0, + // Bounded so huge outputs can't blow Convex's function return limits + // after the execution was already marked completed. + result: truncate(output, MAX_RETURNED_OUTPUT), + }; + } catch (error) { + await ctx.runMutation(internal.executions.finishExecution, { + executionId, + status: "failed", + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +export const run = action({ + args: { + config: configValidator, + sandboxId: v.string(), + command: v.string(), + cwd: v.optional(v.string()), + envs: v.optional(v.record(v.string(), v.string())), + /** Max seconds for the command (default 540, capped at 570 — below Convex's 10-min action ceiling). */ + timeoutSeconds: v.optional(v.number()), + /** Transparently restart a stopped/archived sandbox first (default true). */ + autoStart: v.optional(v.boolean()), + }, + returns: executionResult, + handler: async (ctx, args) => { + // Validate before any remote call or execution row is created. + const timeoutSeconds = boundedTimeout(args.timeoutSeconds); + const client = new DaytonaClient(args.config); + return await recordAndRun( + ctx, + { + sandboxId: args.sandboxId, + kind: "command", + input: args.command, + cwd: args.cwd, + autoStart: args.autoStart, + }, + client, + () => + client.execute(args.sandboxId, { + command: args.command, + cwd: args.cwd, + envs: args.envs, + timeoutSeconds, + }), + ); + }, +}); + +export const runCode = action({ + args: { + config: configValidator, + sandboxId: v.string(), + code: v.string(), + /** Required by the Daytona toolbox: e.g. "python", "javascript", "typescript". */ + language: v.string(), + argv: v.optional(v.array(v.string())), + envs: v.optional(v.record(v.string(), v.string())), + timeoutSeconds: v.optional(v.number()), + autoStart: v.optional(v.boolean()), + }, + returns: executionResult, + handler: async (ctx, args) => { + const timeoutSeconds = boundedTimeout(args.timeoutSeconds); + const client = new DaytonaClient(args.config); + return await recordAndRun( + ctx, + { + sandboxId: args.sandboxId, + kind: "code", + input: args.code, + autoStart: args.autoStart, + }, + client, + () => + client.runCode(args.sandboxId, { + code: args.code, + language: args.language, + argv: args.argv, + envs: args.envs, + timeoutSeconds, + }), + ); + }, +}); diff --git a/packages/convex/src/component/sandboxes.ts b/packages/convex/src/component/sandboxes.ts new file mode 100644 index 0000000..adb5180 --- /dev/null +++ b/packages/convex/src/component/sandboxes.ts @@ -0,0 +1,365 @@ +/** + * Copyright Daytona Platforms Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Sandbox state and lifecycle: reactive queries over the `sandboxes` table, + * the internal bookkeeping mutations, and the lifecycle actions that call the + * Daytona API and record the observed state for the host app to subscribe to. + */ + +import { v } from "convex/values"; +import { internal } from "./_generated/api.js"; +import { action, internalMutation, query } from "./_generated/server.js"; +import { DaytonaApiError, DaytonaClient } from "./daytona.js"; +import { sandboxFields } from "./schema.js"; +import { clampLimit, configValidator } from "./types.js"; + +export const sandboxDoc = v.object({ + ...sandboxFields, + _id: v.id("sandboxes"), + _creationTime: v.number(), +}); + +const sandboxSummary = v.object({ + sandboxId: v.string(), + state: v.string(), +}); + +export const get = query({ + args: { sandboxId: v.string() }, + returns: v.union(v.null(), sandboxDoc), + handler: async (ctx, args) => { + return await ctx.db + .query("sandboxes") + .withIndex("sandboxId", (q) => q.eq("sandboxId", args.sandboxId)) + .unique(); + }, +}); + +export const list = query({ + args: { userKey: v.optional(v.string()), limit: v.optional(v.number()) }, + returns: v.array(sandboxDoc), + handler: async (ctx, args) => { + const limit = clampLimit(args.limit, 100); + if (args.userKey !== undefined) { + const userKey = args.userKey; + return await ctx.db + .query("sandboxes") + .withIndex("userKey", (q) => q.eq("userKey", userKey)) + .order("desc") + .take(limit); + } + return await ctx.db.query("sandboxes").order("desc").take(limit); + }, +}); + +export const upsertSandbox = internalMutation({ + args: { + sandboxId: v.string(), + state: v.string(), + name: v.optional(v.string()), + snapshot: v.optional(v.string()), + target: v.optional(v.string()), + public: v.optional(v.boolean()), + labels: v.optional(v.record(v.string(), v.string())), + userKey: v.optional(v.string()), + lastError: v.optional(v.string()), + }, + returns: v.id("sandboxes"), + handler: async (ctx, args) => { + const now = Date.now(); + const existing = await ctx.db + .query("sandboxes") + .withIndex("sandboxId", (q) => q.eq("sandboxId", args.sandboxId)) + .unique(); + // Skip undefined fields so a partial update never clears known values. + const defined = Object.fromEntries( + Object.entries(args).filter(([, value]) => value !== undefined), + ) as typeof args; + if (existing) { + await ctx.db.patch(existing._id, { ...defined, updatedAt: now }); + return existing._id; + } + return await ctx.db.insert("sandboxes", { + ...defined, + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const setSandboxError = internalMutation({ + args: { + sandboxId: v.string(), + error: v.string(), + /** Last observed remote state, when known (e.g. "build_failed"). */ + state: v.optional(v.string()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const existing = await ctx.db + .query("sandboxes") + .withIndex("sandboxId", (q) => q.eq("sandboxId", args.sandboxId)) + .unique(); + if (existing) { + await ctx.db.patch(existing._id, { + lastError: args.error, + ...(args.state !== undefined ? { state: args.state } : {}), + updatedAt: Date.now(), + }); + } + return null; + }, +}); + +export const create = action({ + args: { + config: configValidator, + name: v.optional(v.string()), + /** Snapshot (ID or name) to base the sandbox on. Omit for the org default. */ + snapshot: v.optional(v.string()), + /** Docker image to build the sandbox from. Mutually exclusive with `snapshot`. */ + image: v.optional(v.string()), + target: v.optional(v.string()), + user: v.optional(v.string()), + envVars: v.optional(v.record(v.string(), v.string())), + labels: v.optional(v.record(v.string(), v.string())), + public: v.optional(v.boolean()), + /** CPU cores — only valid with `image` (snapshots define their own resources). */ + cpu: v.optional(v.number()), + memory: v.optional(v.number()), + disk: v.optional(v.number()), + /** Minutes idle before Daytona pauses the sandbox. */ + autoStopInterval: v.optional(v.number()), + autoArchiveInterval: v.optional(v.number()), + /** Minutes before auto-delete; -1 disables. */ + autoDeleteInterval: v.optional(v.number()), + /** Opaque owner/tenant key from the host app (see schema). */ + userKey: v.optional(v.string()), + /** Wait until the sandbox is started (default true). */ + wait: v.optional(v.boolean()), + waitTimeoutMs: v.optional(v.number()), + }, + returns: sandboxSummary, + handler: async (ctx, args) => { + if (args.snapshot && args.image) { + throw new Error( + "Pass either `snapshot` or `image`, not both — a snapshot already defines its base image.", + ); + } + const hasResources = + args.cpu !== undefined || + args.memory !== undefined || + args.disk !== undefined; + if (hasResources && !args.image) { + throw new Error( + "`cpu`/`memory`/`disk` are only valid with image-based creation (pass `image`). " + + "Snapshot-based sandboxes get their resources from the snapshot.", + ); + } + const client = new DaytonaClient(args.config); + const created = await client.createSandbox({ + name: args.name, + snapshot: args.snapshot, + buildInfo: args.image + ? { dockerfileContent: `FROM ${args.image}` } + : undefined, + target: args.target, + user: args.user, + env: args.envVars, + labels: args.labels, + public: args.public, + cpu: args.cpu, + memory: args.memory, + disk: args.disk, + autoStopInterval: args.autoStopInterval, + autoArchiveInterval: args.autoArchiveInterval, + autoDeleteInterval: args.autoDeleteInterval, + }); + await ctx.runMutation(internal.sandboxes.upsertSandbox, { + sandboxId: created.id, + state: created.state, + name: created.name, + snapshot: created.snapshot, + target: created.target, + public: created.public, + labels: created.labels, + userKey: args.userKey, + }); + let state = created.state as string; + if (args.wait !== false) { + try { + const started = await client.waitForState(created.id, ["started"], { + timeoutMs: args.waitTimeoutMs, + }); + state = started.state; + } catch (error) { + // Best-effort: capture the observed failure state (e.g. "build_failed") + // so the reactive record doesn't stay "creating" forever. + const observed = await client + .getSandbox(created.id) + .then((s) => s.state as string) + .catch(() => undefined); + await ctx.runMutation(internal.sandboxes.setSandboxError, { + sandboxId: created.id, + error: error instanceof Error ? error.message : String(error), + state: observed, + }); + throw error; + } + await ctx.runMutation(internal.sandboxes.upsertSandbox, { + sandboxId: created.id, + state, + }); + } + return { sandboxId: created.id, state }; + }, +}); + +export const start = action({ + args: { + config: configValidator, + sandboxId: v.string(), + waitTimeoutMs: v.optional(v.number()), + }, + returns: sandboxSummary, + handler: async (ctx, args) => { + const client = new DaytonaClient(args.config); + try { + const sandbox = await client.ensureStarted(args.sandboxId, { + timeoutMs: args.waitTimeoutMs, + }); + await ctx.runMutation(internal.sandboxes.upsertSandbox, { + sandboxId: args.sandboxId, + state: sandbox.state, + }); + return { sandboxId: args.sandboxId, state: sandbox.state }; + } catch (error) { + await ctx.runMutation(internal.sandboxes.setSandboxError, { + sandboxId: args.sandboxId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, +}); + +export const stop = action({ + args: { + config: configValidator, + sandboxId: v.string(), + waitTimeoutMs: v.optional(v.number()), + }, + returns: sandboxSummary, + handler: async (ctx, args) => { + const client = new DaytonaClient(args.config); + try { + await client.stopSandbox(args.sandboxId); + // `destroyed` is a valid terminal outcome: autoDeleteInterval 0 deletes + // a sandbox as soon as it stops. + const sandbox = await client.waitForState( + args.sandboxId, + ["stopped", "destroyed"], + { timeoutMs: args.waitTimeoutMs }, + ); + await ctx.runMutation(internal.sandboxes.upsertSandbox, { + sandboxId: args.sandboxId, + state: sandbox.state, + }); + return { sandboxId: args.sandboxId, state: sandbox.state }; + } catch (error) { + if (error instanceof DaytonaApiError && error.status === 404) { + await ctx.runMutation(internal.sandboxes.upsertSandbox, { + sandboxId: args.sandboxId, + state: "destroyed", + }); + return { sandboxId: args.sandboxId, state: "destroyed" }; + } + await ctx.runMutation(internal.sandboxes.setSandboxError, { + sandboxId: args.sandboxId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, +}); + +export const remove = action({ + args: { config: configValidator, sandboxId: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + const client = new DaytonaClient(args.config); + await client.deleteSandbox(args.sandboxId); + // Keep the row (with its execution history) as an audit record. + await ctx.runMutation(internal.sandboxes.upsertSandbox, { + sandboxId: args.sandboxId, + state: "destroyed", + }); + return null; + }, +}); + +/** Re-read remote state into the component table. Returns null if the sandbox is gone. */ +export const refresh = action({ + args: { config: configValidator, sandboxId: v.string() }, + returns: v.union(v.null(), sandboxSummary), + handler: async (ctx, args) => { + const client = new DaytonaClient(args.config); + try { + const sandbox = await client.getSandbox(args.sandboxId); + await ctx.runMutation(internal.sandboxes.upsertSandbox, { + sandboxId: args.sandboxId, + state: sandbox.state, + name: sandbox.name, + snapshot: sandbox.snapshot, + target: sandbox.target, + public: sandbox.public, + labels: sandbox.labels, + }); + return { sandboxId: args.sandboxId, state: sandbox.state }; + } catch (error) { + if (error instanceof DaytonaApiError && error.status === 404) { + await ctx.runMutation(internal.sandboxes.upsertSandbox, { + sandboxId: args.sandboxId, + state: "destroyed", + }); + return null; + } + throw error; + } + }, +}); + +export const previewUrl = action({ + args: { + config: configValidator, + sandboxId: v.string(), + port: v.number(), + /** Signed URLs embed a short-lived token in the URL itself (default true). */ + signed: v.optional(v.boolean()), + expiresInSeconds: v.optional(v.number()), + }, + returns: v.object({ + url: v.string(), + token: v.optional(v.string()), + port: v.number(), + }), + handler: async (_ctx, args) => { + const client = new DaytonaClient(args.config); + const response = + args.signed === false + ? await client.previewUrl(args.sandboxId, args.port) + : await client.signedPreviewUrl( + args.sandboxId, + args.port, + args.expiresInSeconds, + ); + return { + url: response.url, + token: response.token, + port: response.port ?? args.port, + }; + }, +}); diff --git a/packages/convex/src/component/schema.ts b/packages/convex/src/component/schema.ts new file mode 100644 index 0000000..b264929 --- /dev/null +++ b/packages/convex/src/component/schema.ts @@ -0,0 +1,54 @@ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +/** + * Sandbox records mirror the Daytona sandboxes this component has created or + * touched. They are the component's durable, reactive view of remote state — + * the source of truth is always the Daytona API (see `refresh`). + */ +export const sandboxFields = { + /** Daytona sandbox ID (external — not a Convex document ID). */ + sandboxId: v.string(), + name: v.optional(v.string()), + /** Last observed Daytona sandbox state (e.g. "started", "stopped"). */ + state: v.string(), + snapshot: v.optional(v.string()), + target: v.optional(v.string()), + public: v.optional(v.boolean()), + labels: v.optional(v.record(v.string(), v.string())), + /** + * Opaque owner/tenant key provided by the host app (components can't read + * the app's `ctx.auth`). Use it to scope sandboxes to a user or agent. + */ + userKey: v.optional(v.string()), + lastError: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), +}; + +/** One row per command/code execution — history readable via reactive queries. */ +export const executionFields = { + sandboxId: v.string(), + kind: v.union(v.literal("command"), v.literal("code")), + /** The command or code that ran (truncated for storage). */ + input: v.string(), + cwd: v.optional(v.string()), + status: v.union( + v.literal("running"), + v.literal("completed"), + v.literal("failed"), + ), + exitCode: v.optional(v.number()), + /** Combined output (truncated for storage — full output is the action's return value). */ + result: v.optional(v.string()), + error: v.optional(v.string()), + startedAt: v.number(), + finishedAt: v.optional(v.number()), +}; + +export default defineSchema({ + sandboxes: defineTable(sandboxFields) + .index("sandboxId", ["sandboxId"]) + .index("userKey", ["userKey"]), + executions: defineTable(executionFields).index("sandboxId", ["sandboxId"]), +}); diff --git a/packages/convex/src/component/setup.test.ts b/packages/convex/src/component/setup.test.ts new file mode 100644 index 0000000..001b357 --- /dev/null +++ b/packages/convex/src/component/setup.test.ts @@ -0,0 +1,12 @@ +/// +import { test } from "vitest"; +import { convexTest } from "convex-test"; +import schema from "./schema.js"; + +export const modules = import.meta.glob("./**/*.*s"); + +export function initConvexTest() { + return convexTest(schema, modules); +} + +test("setup", () => {}); diff --git a/packages/convex/src/component/types.ts b/packages/convex/src/component/types.ts new file mode 100644 index 0000000..271d5c4 --- /dev/null +++ b/packages/convex/src/component/types.ts @@ -0,0 +1,124 @@ +import { v, type Infer } from "convex/values"; + +/** + * Connection settings for the Daytona API. Components are isolated from the + * host app's environment variables, so the host-side client (`src/client`) + * resolves credentials (from options or `process.env`) and passes them into + * every component action — the same pattern used by other Convex components + * that wrap external APIs. + */ +export const configValidator = v.object({ + /** Daytona API key (create one at https://app.daytona.io/dashboard/keys). */ + apiKey: v.string(), + /** API base URL. Defaults to Daytona Cloud (`https://app.daytona.io/api`). */ + apiUrl: v.optional(v.string()), +}); + +export type DaytonaConfig = Infer; + +export const DEFAULT_API_URL = "https://app.daytona.io/api"; + +/** Mirrors the Daytona API SandboxState enum (wire values, api-client v0.214). */ +export type SandboxState = + | "creating" + | "restoring" + | "starting" + | "started" + | "stopping" + | "stopped" + | "pausing" + | "paused" + | "resuming" + | "archiving" + | "archived" + | "destroying" + | "destroyed" + | "resizing" + | "snapshotting" + | "forking" + | "pulling_snapshot" + | "building_snapshot" + | "pending_build" + | "build_failed" + | "error" + | "unknown"; + +export interface ApiSandbox { + id: string; + name?: string; + state: SandboxState; + target?: string; + snapshot?: string; + labels?: Record; + public?: boolean; + toolboxProxyUrl?: string; + errorReason?: string; + [k: string]: unknown; +} + +export interface CreateSandboxRequest { + name?: string; + snapshot?: string; + buildInfo?: { dockerfileContent: string }; + user?: string; + env?: Record; + labels?: Record; + public?: boolean; + target?: string; + cpu?: number; + memory?: number; + disk?: number; + autoStopInterval?: number; + autoArchiveInterval?: number; + autoDeleteInterval?: number; +} + +export interface ProcessExecutionResponse { + exitCode: number; + result: string; + artifacts?: { + stdout?: string; + charts?: Array>; + }; +} + +export interface FileInfo { + name: string; + isDir: boolean; + size?: number; + modTime?: string; + mode?: string; + permissions?: string; + owner?: string; + group?: string; + [k: string]: unknown; +} + +export interface PreviewUrlResponse { + url: string; + token?: string; + port: number; + [k: string]: unknown; +} + +/** Cap stored execution output so documents stay far below Convex's 1 MiB doc limit. */ +export const MAX_STORED_OUTPUT = 64_000; +/** Cap stored command/code input. */ +export const MAX_STORED_INPUT = 4_000; +/** Cap output returned across the Convex function boundary (limit is 16 MiB total). */ +export const MAX_RETURNED_OUTPUT = 4_000_000; + +/** Clamp caller-provided limits to a sane integer range for `.take()`. */ +export function clampLimit(limit: number | undefined, fallback: number): number { + const floored = Math.floor(limit ?? fallback); + if (!Number.isFinite(floored)) return fallback; + return Math.min(Math.max(floored, 1), 500); +} + +const TRUNCATION_MARKER = "\n…[truncated]"; + +/** Truncate to a HARD bound of `max` characters, marker included. */ +export function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - TRUNCATION_MARKER.length))}${TRUNCATION_MARKER}`; +} diff --git a/packages/convex/src/test.ts b/packages/convex/src/test.ts new file mode 100644 index 0000000..3e1b140 --- /dev/null +++ b/packages/convex/src/test.ts @@ -0,0 +1,25 @@ +/// +import type { TestConvex } from "convex-test"; +import type { GenericSchema, SchemaDefinition } from "convex/server"; +import schema from "./component/schema.js"; + +export const modules = import.meta.glob("./component/**/*.ts"); + +/** + * Register the Daytona component with a `convex-test` instance, e.g.: + * + * ```ts + * import { convexTest } from "convex-test"; + * import daytonaTest from "@daytona/convex/test"; + * const t = convexTest(schema, modules); + * daytonaTest.register(t); + * ``` + */ +export function register( + t: TestConvex>, + name: string = "daytona", +) { + t.registerComponent(name, schema, modules); +} + +export default { register, schema, modules }; diff --git a/packages/convex/tsconfig.build.json b/packages/convex/tsconfig.build.json new file mode 100644 index 0000000..36e8298 --- /dev/null +++ b/packages/convex/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/*.test.*", "./src/test.ts"] +} diff --git a/packages/convex/tsconfig.json b/packages/convex/tsconfig.json new file mode 100644 index 0000000..65b03ff --- /dev/null +++ b/packages/convex/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "allowJs": true, + "strict": true, + "target": "ESNext", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "noErrorTruncation": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "composite": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "rootDir": "./src", + "outDir": "./dist", + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["./src/**/*"] +} diff --git a/packages/convex/tsconfig.test.json b/packages/convex/tsconfig.test.json new file mode 100644 index 0000000..cc95662 --- /dev/null +++ b/packages/convex/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "declarationMap": false + }, + "include": ["src/**/*"] +} diff --git a/packages/convex/vitest.config.ts b/packages/convex/vitest.config.ts new file mode 100644 index 0000000..e9408f8 --- /dev/null +++ b/packages/convex/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "edge-runtime", + typecheck: { + tsconfig: "./tsconfig.test.json", + }, + }, +}); diff --git a/release-please-config.json b/release-please-config.json index ad073a5..4c88306 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -32,6 +32,11 @@ "release-type": "python", "package-name": "pydantic-ai-daytona", "component": "pydantic-ai-daytona" + }, + "packages/convex": { + "release-type": "node", + "package-name": "@daytona/convex", + "component": "convex" } } }