Compare commits

...
Author SHA1 Message Date
Kit Langton 1ecf27f389 refactor(opencode): pass prepared request to native runtime 2026-05-20 21:55:45 -04:00
Kit Langton 548446a990 refactor(opencode): extract session LLM request prep 2026-05-20 21:38:32 -04:00
Kit Langton ddd6eb4496 fix(tui): separate question checkmark labels (#28558) 2026-05-20 21:35:59 -04:00
opencode-agent[bot] 12bbe84360 chore: generate 2026-05-21 01:30:35 +00:00
Kit Langton 661df8fcf8 fix(opencode): register account events in EventV2 bridge (#28555) 2026-05-20 21:29:12 -04:00
Kit Langton 16fb6dac8d fix(llm): restore OpenAI reasoning streams (#28552) 2026-05-20 21:02:59 -04:00
opencode-agent[bot] 93131b6e4c chore: generate 2026-05-21 00:55:10 +00:00
Dax 8643c0721e Rename v2 auth service to account (#28260) 2026-05-21 00:53:27 +00:00
opencode-agent[bot] bd41dac88f chore: update nix node_modules hashes 2026-05-21 00:26:08 +00:00
Kit Langton 41f6daf96a Refactor LLM route-first provider API (#28523) 2026-05-20 20:15:52 -04:00
Kit Langton 5381795844 feat(effect-drizzle-sqlite): add vendored sqlite adapter (#28547) 2026-05-20 20:09:07 -04:00
Frank 7b9d7a7b7d sync 2026-05-20 19:45:59 -04:00
Frank 7b0fd9fed2 zen: grok build 0.1 2026-05-20 19:38:37 -04:00
opencode-agent[bot] b4a01cc3cd chore: generate 2026-05-20 22:37:29 +00:00
James Long a6e1aa085f fix(tui): default new sessions always to local project (#28541) 2026-05-20 18:35:37 -04:00
opencode 6a9cbe7de0 sync release versions for v1.15.6 2026-05-20 21:44:08 +00:00
opencode-agent[bot] 0e17c4f299 chore: update nix node_modules hashes 2026-05-20 21:01:29 +00:00
opencode-agent[bot] 7c121d48b9 chore: generate 2026-05-20 20:56:18 +00:00
Aiden Cline f5a8202b41 fix(tui): simplify thinking toggle styling (#28487) 2026-05-20 15:54:29 -05:00
Kit Langton 43c24d8d0f fix(tui): gate Zed context on terminal env (#28517) 2026-05-20 16:52:33 -04:00
opencode-agent[bot] 09603ed52f chore: generate 2026-05-20 20:50:28 +00:00
Kit Langton 38e3b4087b test(server): port event-diagnostics to Effect runner (#28520) 2026-05-20 16:48:41 -04:00
opencode-agent[bot] eb52362e96 chore: generate 2026-05-20 20:45:33 +00:00
Sebastian 2969a513ce upgrade opentui to 0.2.15 (#28510) 2026-05-20 22:44:09 +02:00
opencode-agent[bot] de672fd214 chore: generate 2026-05-20 20:23:54 +00:00
James Long d0779d2aca feat(tui): collapse directories when possible in file tree (#28512) 2026-05-20 16:22:04 -04:00
opencode-agent[bot] 650594e801 chore: update nix node_modules hashes 2026-05-20 20:13:43 +00:00
James Long 58143c4b07 feat(tui): focus first file in file tree (#28513) 2026-05-20 16:04:45 -04:00
Kit Langton 4cbeacbc76 chore: update Drizzle RC (#28506) 2026-05-20 15:55:36 -04:00
opencode-agent[bot] b0ca0419be chore: generate 2026-05-20 19:25:52 +00:00
Shoubhit Dash 4308dd75fb fix(httpapi): expose v2 catalog errors (#28498) 2026-05-21 00:53:35 +05:30
opencode-agent[bot] 7690481fc1 chore: update nix node_modules hashes 2026-05-20 19:23:14 +00:00
Kit Langton ec6d42d41d chore: update Effect beta (#28505) 2026-05-20 15:09:19 -04:00
opencode-agent[bot] 3553754083 chore: generate 2026-05-20 19:09:16 +00:00
Simon Klee ed839846d1 run: replace subagent tabs with on-demand picker (#28508)
Move subagent navigation into the existing palette: a
"View subagents" command entry, a dedicated picker panel, and a
Down-arrow shortcut from the empty composer.
2026-05-20 21:07:35 +02:00
Kit Langton ba803dd89a fix(sdk): unbreak typecheck on dev after v2 error widening (#28503) 2026-05-20 14:29:19 -04:00
opencode-agent[bot] 0e118d1961 chore: generate 2026-05-20 17:57:38 +00:00
Shoubhit Dash 40e73c4910 fix(httpapi): expose v2 request errors (#28495) 2026-05-20 23:23:23 +05:30
opencode-agent[bot] 9559e2425b chore: generate 2026-05-20 17:29:32 +00:00
Frank ac76af8c56 zen: add gemini 3.5 flash 2026-05-20 13:20:54 -04:00
Victor Navarro b67b03e0ae chore: simplify referral codes (#28491) 2026-05-20 19:15:20 +02:00
Sebastian ef82426e28 handle permanent file plugin errors (#27344) 2026-05-20 18:47:30 +02:00
OpeOginni 14e9e5d9d6 fix(enterprise): message nav hovercard and active state (#23964) 2026-05-21 00:06:13 +08:00
Dax 9324ef0d08 fix(cli): default console login url (#28474) 2026-05-20 11:34:56 -04:00
opencode-agent[bot] 8940572098 chore: generate 2026-05-20 15:18:18 +00:00
James Long 17d66ee4fe feat(tui): initial impl of diff viewer (#28476) 2026-05-20 11:16:56 -04:00
293 changed files with 18727 additions and 5114 deletions
+8
View File
@@ -4,6 +4,14 @@
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility. - Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
## Commits and PR Titles
Use conventional commit-style messages and PR titles: `type(scope): summary`.
Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes are optional; use the affected package or area when helpful, e.g. `core`, `opencode`, `tui`, `app`, `desktop`, `sdk`, or `plugin`.
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
## Style Guide ## Style Guide
### General Principles ### General Principles
+64 -43
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.15.5", "version": "1.15.6",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -253,7 +253,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -305,9 +305,23 @@
"@parcel/watcher-win32-x64": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1",
}, },
}, },
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.5",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -337,7 +351,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -353,7 +367,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -366,7 +380,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2", "@smithy/util-utf8": "4.2.2",
@@ -384,7 +398,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.15.5", "version": "1.15.6",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -522,7 +536,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
@@ -538,9 +552,9 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.2.14", "@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.14", "@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.14", "@opentui/solid": ">=0.2.15",
}, },
"optionalPeers": [ "optionalPeers": [
"@opentui/core", "@opentui/core",
@@ -560,7 +574,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -575,7 +589,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -610,7 +624,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -659,7 +673,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -715,17 +729,18 @@
}, },
"catalog": { "catalog": {
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@effect/opentelemetry": "4.0.0-beta.65", "@effect/opentelemetry": "4.0.0-beta.66",
"@effect/platform-node": "4.0.0-beta.65", "@effect/platform-node": "4.0.0-beta.66",
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@lydell/node-pty": "1.2.0-beta.10", "@lydell/node-pty": "1.2.0-beta.10",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.14", "@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.14", "@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.14", "@opentui/solid": "0.2.15",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
@@ -747,9 +762,9 @@
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.65", "effect": "4.0.0-beta.66",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -1108,11 +1123,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.65", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.65" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-0CD2fSsXrDM7FP2WFkbGJO1DwMqWR3UKHh6oBDXPHAPA+RsJSKoh3pLQsbQfldLuKnhOy87Bv0v9r9IdrIHCQw=="], "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.66", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.66" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-LU3ejAzJS+4P+Qtfn9ULnsGcIPmx1tUUB2ZswFRL+EolD8US7zMljHTwGuQRUBJOjDwt7wFCMN5AR512vdY8FQ=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.65", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.65", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.65", "ioredis": "^5.7.0" } }, "sha512-QQy3KRcMwP0TngQdfQGl2u1zp03B7k7DuF5SNS8aZhD0dDBpKZpCwFad1ODY5qdY3ycPgMwBwKRRK7y/aw0C9w=="], "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.66", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.66", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.66", "ioredis": "^5.7.0" } }, "sha512-s/0RgaQFuszzdorRnX1PwEQNnSOi+JgMJo3zEe9O2NR3sosMhTr0Uk+1AF6bUOI9uJ2CPT3KpTIIU7q5/TpOkg=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.65", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.65" } }, "sha512-3rY8F3WLEax6Hj08GI/OvDIH+KqjfxH7RM2bAMfgR75NgRmwDtny1P49PtPkoRjH5dcdtThThtsvE4X9OTZkpQ=="], "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.66", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.66" } }, "sha512-+ymrhBnESv/hmn5SKTe2//IY9Ox/hGPeoogEWhW47ZGyhFI5eMYFxdEUBa+3IAV05rrBzrxON9lynu68n0DM7w=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.66", "", { "peerDependencies": { "effect": "^4.0.0-beta.66" } }, "sha512-UYsrAb/5T0ZRypeN9Kmv3/ZInibGCjM6dtoiAWtfG+xKyuq8N05wmuVCXB0+XgVmUBxDWjw/S1fu4ivS0vZVuw=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -1542,6 +1559,8 @@
"@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"],
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"], "@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
@@ -1592,23 +1611,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.14", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.14", "@opentui/core-darwin-x64": "0.2.14", "@opentui/core-linux-arm64": "0.2.14", "@opentui/core-linux-x64": "0.2.14", "@opentui/core-win32-arm64": "0.2.14", "@opentui/core-win32-x64": "0.2.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-17YCr3BqM9mhi/DdNVM+omgmrKQNIl0G5RzoaTFOHe4+OAhG+W3iooYi+WdsekJWSUOEwZqDRz0QBTZhOtgZsQ=="], "@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iS4NZQkOKX2EP5rsNjDcU7inDLcKhPaSBn8ENjDXKx2smOh7p/rgM2qlEaiLI3njtL784QoF+nxTzSXbEI6+Jw=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-ft4ZwYHCV0VtRMwQtHH5mAgwqRLHEXP26DWcwtCZWDEHDvghClBR0cj9UZLH5JAKn/j7ds5hZDCCZz+nUiEHYA=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-t/EKD4+rlzWuwYAa6NzGCmiBOHvF+hzjNwExj+dnSqX5wK7TU+VHl+N2iYUl4VhhJK94kPP6BnrF5GcHnZGFLg=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Lvqmd92UZ+KZVnr0xU0jYj4XqnCSsBQJHS/FpYkJgZSAN7/4NmPlgMvQXIGW8a3BcFaGRKe8LuGpqM2E4oaX0Q=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jnuud29daaEoZNEp80dxUDLyUcwLr+g6SruHPyyWerOe7J10JE1ihJNkDlXLT7T49xdBGYRQlRkuNGwRfZWx5A=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-2ZUNh7yaAMUwAOK8oFEO28qXqPFrWPGGD0KHK1Gp97Th9XuVZniMLtbkbrFtDRh15+PVj7MyrG0N967W7rLr1A=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
"@opentui/keymap": ["@opentui/keymap@0.2.14", "", { "dependencies": { "@opentui/core": "0.2.14" }, "peerDependencies": { "@opentui/react": "0.2.14", "@opentui/solid": "0.2.14", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Jd4F3S98D8bJcr41jk7KcsFwwQTA0GCNKb+LoDMkPwv00k+NV6XcrXPB3QlNeM/JrVbe55pyGaT/ynZklGYHRw=="], "@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="],
"@opentui/solid": ["@opentui/solid@0.2.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.14", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-tSWiiwdh/J+crkjwHgd26HXAlJHYocwkN/eUoqSH3+Y/uO3c3qRnbzQz7zzds+j4XDQU/8e9QcZshYLvQT9c7w=="], "@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2974,9 +2993,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="], "drizzle-kit": ["drizzle-kit@1.0.0-rc.2", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TRxUmj1wDA2QCt3GvuhfamvIa66wJ7+MzSxBMKkpRtYScjHTumT9BE+x6daSzuEacSrPEuUH5/cW1uo5RkoPIg=="],
"drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="], "drizzle-orm": ["drizzle-orm@1.0.0-rc.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-UXYDkbplF5wX0hwxll+80QhEwUvAJLBu+tAK/d4fna18kLE6VuliAzufF/ieDEIJeSnLRYgtmsXD6x1Xuy1kIg=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -2990,7 +3009,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.65", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw=="], "effect": ["effect@4.0.0-beta.66", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -5678,6 +5697,8 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
+9 -2
View File
@@ -2,6 +2,7 @@ import { SECRET } from "./secret"
import { domain } from "./stage" import { domain } from "./stage"
const description = "Managed by SST (Don't edit in Honeycomb UI)" const description = "Managed by SST (Don't edit in Honeycomb UI)"
const alertsDisabled = $app.stage !== "production"
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", { const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`, name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
@@ -79,7 +80,7 @@ IF(
filters, filters,
}, },
], ],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 200), DIV($FAILED, $TOTAL), 0)" }], formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 150), DIV($FAILED, $TOTAL), 0)" }],
timeRange: 900, timeRange: 900,
}).json }).json
} }
@@ -122,7 +123,7 @@ const providerHttpErrorsQuery = () => {
}, },
], ],
formulas: [ formulas: [
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 150), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
], ],
timeRange: 900, timeRange: 900,
}).json }).json
@@ -159,6 +160,7 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
name: "Increased Model HTTP Errors [Go]", name: "Increased Model HTTP Errors [Go]",
description, description,
disabled: alertsDisabled,
queryJson: modelHttpErrorsQuery("go"), queryJson: modelHttpErrorsQuery("go"),
alertType: "on_change", alertType: "on_change",
frequency: 300, frequency: 300,
@@ -178,6 +180,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
name: "Increased Model HTTP Errors [Zen]", name: "Increased Model HTTP Errors [Zen]",
description, description,
disabled: alertsDisabled,
queryJson: modelHttpErrorsQuery("zen"), queryJson: modelHttpErrorsQuery("zen"),
alertType: "on_change", alertType: "on_change",
frequency: 300, frequency: 300,
@@ -197,6 +200,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
new honeycomb.Trigger("LowModelTpsGo", { new honeycomb.Trigger("LowModelTpsGo", {
name: "Low Model TPS [Go]", name: "Low Model TPS [Go]",
description, description,
disabled: alertsDisabled,
queryJson: modelLowTpsQuery("go"), queryJson: modelLowTpsQuery("go"),
alertType: "on_change", alertType: "on_change",
frequency: 600, frequency: 600,
@@ -216,6 +220,7 @@ new honeycomb.Trigger("LowModelTpsGo", {
new honeycomb.Trigger("LowModelTpsZen", { new honeycomb.Trigger("LowModelTpsZen", {
name: "Low Model TPS [Zen]", name: "Low Model TPS [Zen]",
description, description,
disabled: alertsDisabled,
queryJson: modelLowTpsQuery("zen"), queryJson: modelLowTpsQuery("zen"),
alertType: "on_change", alertType: "on_change",
frequency: 600, frequency: 600,
@@ -235,6 +240,7 @@ new honeycomb.Trigger("LowModelTpsZen", {
new honeycomb.Trigger("IncreasedProviderHttpErrors", { new honeycomb.Trigger("IncreasedProviderHttpErrors", {
name: "Increased Provider HTTP Errors", name: "Increased Provider HTTP Errors",
description, description,
disabled: alertsDisabled,
queryJson: providerHttpErrorsQuery(), queryJson: providerHttpErrorsQuery(),
alertType: "on_change", alertType: "on_change",
frequency: 300, frequency: 300,
@@ -254,6 +260,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrors", {
new honeycomb.Trigger("IncreasedFreeTierRequests", { new honeycomb.Trigger("IncreasedFreeTierRequests", {
name: "Increased Free Tier Requests", name: "Increased Free Tier Requests",
description, description,
disabled: alertsDisabled,
queryJson: honeycomb.getQuerySpecificationOutput({ queryJson: honeycomb.getQuerySpecificationOutput({
calculations: [{ op: "COUNT" }], calculations: [{ op: "COUNT" }],
filters: [ filters: [
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-FI1mX42vJuYdUDdWevlfHz+OcYkDn/I/HUbHE/jdQvs=", "x86_64-linux": "sha256-kCSAVPQgJROcvnnwf0Cn6PuYL25hYgTasJeBJlmnFgQ=",
"aarch64-linux": "sha256-3CQzzKnh/4Zf5vyn56yR5P3ULsW7K7Fr8/RQpekEJDk=", "aarch64-linux": "sha256-prY27Ek2QhW+4OvBJ3bHHkUDoLTA4mD3KQmOQqSbAuo=",
"aarch64-darwin": "sha256-XPDVHMxlPpXlf43BRqNnwF809unk6iE8tvd0o92d0/w=", "aarch64-darwin": "sha256-0yIqnnjreVHTgGZLrKFpT9Cc2B2LNfmYcRByaCu7tiU=",
"x86_64-darwin": "sha256-dFXTi13RSgL62lMsep1EoE/KSEPF7Oh31PVdxW1tkzg=" "x86_64-darwin": "sha256-n+urvMRozB9nO5D3qyCweSa5HExFk1YGEzOt2445LEE="
} }
} }
+9 -8
View File
@@ -28,16 +28,17 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/opentelemetry": "4.0.0-beta.65", "@effect/opentelemetry": "4.0.0-beta.66",
"@effect/platform-node": "4.0.0-beta.65", "@effect/platform-node": "4.0.0-beta.66",
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.14", "@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.14", "@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.14", "@opentui/solid": "0.2.15",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
@@ -53,9 +54,9 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.65", "effect": "4.0.0-beta.66",
"ai": "6.0.168", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.15.5", "version": "1.15.6",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.15.5", "version": "1.15.6",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -10,6 +10,7 @@ import {
IconAnthropic, IconAnthropic,
IconArcee, IconArcee,
IconGemini, IconGemini,
IconDeepSeek,
IconMiniMax, IconMiniMax,
IconMoonshotAI, IconMoonshotAI,
IconNvidia, IconNvidia,
@@ -27,6 +28,7 @@ const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic" if (modelId.startsWith("claude")) return "Anthropic"
if (modelId.startsWith("gpt")) return "OpenAI" if (modelId.startsWith("gpt")) return "OpenAI"
if (modelId.startsWith("gemini")) return "Google" if (modelId.startsWith("gemini")) return "Google"
if (modelId.startsWith("deepseek")) return "DeepSeek"
if (modelId.startsWith("kimi")) return "Moonshot AI" if (modelId.startsWith("kimi")) return "Moonshot AI"
if (modelId.startsWith("glm")) return "Z.ai" if (modelId.startsWith("glm")) return "Z.ai"
if (modelId.startsWith("qwen")) return "Alibaba" if (modelId.startsWith("qwen")) return "Alibaba"
@@ -47,7 +49,19 @@ const getModelsInfo = query(async (workspaceID: string) => {
.filter(([id, _model]) => !id.startsWith("alpha-")) .filter(([id, _model]) => !id.startsWith("alpha-"))
.filter(([id, _model]) => !id.endsWith(":global")) .filter(([id, _model]) => !id.endsWith(":global"))
.sort(([idA, modelA], [idB, modelB]) => { .sort(([idA, modelA], [idB, modelB]) => {
const priority = ["big-pickle", "minimax", "grok", "claude", "gpt", "gemini"] const priority = [
"big-pickle",
"claude",
"gpt",
"gemini",
"deepseek",
"glm",
"kimi",
"qwen",
"grok",
"minimax",
"mimo",
]
const getPriority = (id: string) => { const getPriority = (id: string) => {
const index = priority.findIndex((p) => id.startsWith(p)) const index = priority.findIndex((p) => id.startsWith(p))
return index === -1 ? Infinity : index return index === -1 ? Infinity : index
@@ -136,6 +150,8 @@ export function ModelSection() {
return <IconAnthropic width={16} height={16} /> return <IconAnthropic width={16} height={16} />
case "Google": case "Google":
return <IconGemini width={16} height={16} /> return <IconGemini width={16} height={16} />
case "DeepSeek":
return <IconDeepSeek width={16} height={16} />
case "Moonshot AI": case "Moonshot AI":
return <IconMoonshotAI width={16} height={16} /> return <IconMoonshotAI width={16} height={16} />
case "Z.ai": case "Z.ai":
@@ -13,7 +13,7 @@ export function createRateLimiter(
if (!zenApiKey) return if (!zenApiKey) return
const dict = i18n(localeFromRequest(request)) const dict = i18n(localeFromRequest(request))
const LIMIT = rateLimit ?? 500 const LIMIT = rateLimit ?? 1000
const yyyyMMddHHmm = new Date(Date.now()) const yyyyMMddHHmm = new Date(Date.now())
.toISOString() .toISOString()
.replace(/[^0-9]/g, "") .replace(/[^0-9]/g, "")
@@ -0,0 +1,13 @@
CREATE TABLE `referral_code` (
`workspace_id` varchar(30) PRIMARY KEY,
`code` varchar(10) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
CONSTRAINT `code` UNIQUE INDEX(`code`)
);
--> statement-breakpoint
DROP INDEX `referral_invitee_account_id` ON `referral`;--> statement-breakpoint
DROP INDEX `referral_code` ON `workspace`;--> statement-breakpoint
CREATE UNIQUE INDEX `invitee_account_id` ON `referral` (`invitee_account_id`);--> statement-breakpoint
ALTER TABLE `workspace` DROP COLUMN `referral_code`;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.15.5", "version": "1.15.6",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+21 -29
View File
@@ -3,7 +3,7 @@ import { and, asc, eq, isNull, sql, Database } from "./drizzle"
import { Actor } from "./actor" import { Actor } from "./actor"
import { Identifier } from "./identifier" import { Identifier } from "./identifier"
import { LiteTable } from "./schema/billing.sql" import { LiteTable } from "./schema/billing.sql"
import { ReferralRewardTable, ReferralTable } from "./schema/referral.sql" import { ReferralCodeTable, ReferralRewardTable, ReferralTable } from "./schema/referral.sql"
import { AuthTable } from "./schema/auth.sql" import { AuthTable } from "./schema/auth.sql"
import { UserTable } from "./schema/user.sql" import { UserTable } from "./schema/user.sql"
import { WorkspaceTable } from "./schema/workspace.sql" import { WorkspaceTable } from "./schema/workspace.sql"
@@ -26,38 +26,29 @@ export namespace Referral {
} }
function generateCode() { function generateCode() {
return ulid().slice(-CODE_LENGTH) return ulid().slice(-CODE_LENGTH).toUpperCase()
} }
async function ensureCode(workspaceID = Actor.workspace()) { async function ensureCode(workspaceID = Actor.workspace()) {
return Database.transaction(async (tx) => { return Database.use(async (db) => {
const existing = await tx const existing = await db
.select({ code: WorkspaceTable.referralCode }) .select({ code: ReferralCodeTable.code })
.from(WorkspaceTable) .from(ReferralCodeTable)
.where(and(eq(WorkspaceTable.id, workspaceID), isNull(WorkspaceTable.timeDeleted))) .where(eq(ReferralCodeTable.workspaceID, workspaceID))
.then((rows) => rows[0]) .then((rows) => rows[0])
if (!existing) throw new Error("Workspace not found") if (existing) return { code: existing.code }
if (existing.code) return { code: existing.code }
for (const _ of Array.from({ length: 5 })) { await db.insert(ReferralCodeTable).ignore().values({
await tx workspaceID,
.update(WorkspaceTable) code: generateCode(),
.set({ referralCode: generateCode() }) })
.where(
and(
eq(WorkspaceTable.id, workspaceID),
isNull(WorkspaceTable.referralCode),
isNull(WorkspaceTable.timeDeleted),
),
)
const created = await tx const created = await db
.select({ code: WorkspaceTable.referralCode }) .select({ code: ReferralCodeTable.code })
.from(WorkspaceTable) .from(ReferralCodeTable)
.where(and(eq(WorkspaceTable.id, workspaceID), isNull(WorkspaceTable.timeDeleted))) .where(eq(ReferralCodeTable.workspaceID, workspaceID))
.then((rows) => rows[0]) .then((rows) => rows[0])
if (created?.code) return { code: created.code } if (created) return { code: created.code }
}
throw new Error("Failed to generate referral code") throw new Error("Failed to generate referral code")
}) })
@@ -300,9 +291,10 @@ export namespace Referral {
return Database.transaction(async (tx) => { return Database.transaction(async (tx) => {
const code = await tx const code = await tx
.select({ workspaceID: WorkspaceTable.id }) .select({ workspaceID: ReferralCodeTable.workspaceID })
.from(WorkspaceTable) .from(ReferralCodeTable)
.where(and(eq(WorkspaceTable.referralCode, referralCode), isNull(WorkspaceTable.timeDeleted))) .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, ReferralCodeTable.workspaceID))
.where(and(eq(ReferralCodeTable.code, referralCode), isNull(WorkspaceTable.timeDeleted)))
.then((rows) => rows[0]) .then((rows) => rows[0])
if (!code) throw new Error("Referral code invalid") if (!code) throw new Error("Referral code invalid")
@@ -1,7 +1,17 @@
import { bigint, mysqlTable, primaryKey, uniqueIndex } from "drizzle-orm/mysql-core" import { bigint, mysqlTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types" import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql" import { workspaceIndexes } from "./workspace.sql"
export const ReferralCodeTable = mysqlTable(
"referral_code",
{
workspaceID: ulid("workspace_id").notNull(),
code: varchar("code", { length: 10 }).notNull(),
...timestamps,
},
(table) => [primaryKey({ columns: [table.workspaceID] }), uniqueIndex("code").on(table.code)],
)
export const ReferralTable = mysqlTable( export const ReferralTable = mysqlTable(
"referral", "referral",
{ {
@@ -9,7 +19,7 @@ export const ReferralTable = mysqlTable(
...timestamps, ...timestamps,
inviteeAccountID: ulid("invitee_account_id").notNull(), inviteeAccountID: ulid("invitee_account_id").notNull(),
}, },
(table) => [...workspaceIndexes(table), uniqueIndex("referral_invitee_account_id").on(table.inviteeAccountID)], (table) => [...workspaceIndexes(table), uniqueIndex("invitee_account_id").on(table.inviteeAccountID)],
) )
export const ReferralRewardTable = mysqlTable( export const ReferralRewardTable = mysqlTable(
@@ -6,11 +6,10 @@ export const WorkspaceTable = mysqlTable(
{ {
id: ulid("id").notNull().primaryKey(), id: ulid("id").notNull().primaryKey(),
slug: varchar("slug", { length: 255 }), slug: varchar("slug", { length: 255 }),
referralCode: varchar("referral_code", { length: 10 }),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
...timestamps, ...timestamps,
}, },
(table) => [uniqueIndex("slug").on(table.slug), uniqueIndex("referral_code").on(table.referralCode)], (table) => [uniqueIndex("slug").on(table.slug)],
) )
export function workspaceIndexes(table: any) { export function workspaceIndexes(table: any) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.15.5", "version": "1.15.6",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.5", "version": "1.15.6",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+319
View File
@@ -0,0 +1,319 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
import { EventV2 } from "./event"
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("AccountV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AccountV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AccountV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Info extends Schema.Class<Info>("AccountV2.Info")({
id: ID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AccountV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type Error = FileWriteError
export const Event = {
Added: EventV2.define({
type: "account.added",
schema: {
account: Info,
},
}),
Removed: EventV2.define({
type: "account.removed",
schema: {
account: Info,
},
}),
Switched: EventV2.define({
type: "account.switched",
schema: {
serviceID: ServiceID,
from: Schema.optional(ID),
to: Schema.optional(ID),
},
}),
}
interface Writable {
version: 2
accounts: Record<string, Info>
active: Record<string, ID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Info> = {}
const active: Record<string, ID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const account = ID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Info({
id: account,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = account
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
readonly all: () => Effect.Effect<Info[], Error>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
}) => Effect.Effect<Info | undefined, Error>
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
readonly remove: (id: ID) => Effect.Effect<void, Error>
readonly activate: (id: ID) => Effect.Effect<void, Error>
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const events = yield* EventV2.Service
const file = path.join(global.data, "account.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(
yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))),
)
const activate = Effect.fn("AccountV2.activate")(function* (id: ID) {
const data = yield* SynchronizedRef.get(state)
const account = data.accounts[id]
if (!account) return
const activated = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const nextAccount = data.accounts[id]
if (!nextAccount) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } }
yield* write(next)
return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const
}),
)
if (activated) yield* events.publish(Event.Switched, activated)
})
const result: Interface = {
get: Effect.fn("AccountV2.get")(function* (id) {
return (yield* SynchronizedRef.get(state)).accounts[id]
}),
all: Effect.fn("AccountV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AccountV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AccountV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AccountV2.add")(function* (input) {
const id = ID.make(Identifier.ascending())
const account = new Info({
id,
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const added = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active: { ...data.active, [account.serviceID]: account.id },
}
yield* write(next)
return [
{
account,
switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id },
},
next,
] as const
}),
)
yield* events.publish(Event.Added, { account: added.account })
yield* events.publish(Event.Switched, added.switched)
return added.account
}),
update: Effect.fn("AccountV2.update")(function* (id, updates) {
const existing = (yield* SynchronizedRef.get(state)).accounts[id]
if (!existing) return
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
if (!data.accounts[id]) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[id]: new Info({
id,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AccountV2.remove")(function* (id) {
const removed = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
const removed = accounts[id]
if (!removed) return [undefined, data] as const
const wasActive = active[removed.serviceID] === id
delete accounts[id]
const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID)
if (wasActive) {
if (replacement) active[removed.serviceID] = replacement.id
else delete active[removed.serviceID]
}
const next = { ...data, accounts, active }
yield* write(next)
return [
{
account: removed,
switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined,
},
next,
] as const
}),
)
if (removed) {
yield* events.publish(Event.Removed, { account: removed.account })
if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
}
}),
activate,
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export * as AccountV2 from "./account"
+147
View File
@@ -0,0 +1,147 @@
export * as AgentV2 from "./agent"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export type ID = typeof ID.Type
export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" })
export type Mode = typeof Mode.Type
export const Info = Schema.Struct({
name: ID,
description: Schema.optional(Schema.String),
mode: Mode,
hidden: Schema.Boolean.pipe(Schema.optional),
color: Schema.String.pipe(Schema.optional),
permission: PermissionV2.Ruleset,
model: ModelV2.Ref.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
options: ProviderV2.Options.pipe(Schema.optional),
steps: Schema.Int.pipe(Schema.optional),
}).annotate({ identifier: "AgentV2.Info" })
export type Info = typeof Info.Type
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("AgentV2.NotFound", {
agent: ID,
}) {}
export class InvalidDefaultError extends Schema.TaggedErrorClass<InvalidDefaultError>()("AgentV2.InvalidDefault", {
agent: ID,
reason: Schema.Literals(["missing", "subagent", "hidden"]),
}) {}
export class NoDefaultError extends Schema.TaggedErrorClass<NoDefaultError>()("AgentV2.NoDefault", {}) {}
export interface Interface {
readonly get: (agent: ID) => Effect.Effect<Info, NotFoundError>
readonly list: () => Effect.Effect<Info[]>
readonly update: (agent: ID, fn: (agent: Draft<Info>) => void) => Effect.Effect<void>
readonly remove: (agent: ID) => Effect.Effect<void>
readonly defaultInfo: () => Effect.Effect<Info, InvalidDefaultError | NoDefaultError>
readonly defaultAgent: () => Effect.Effect<ID, InvalidDefaultError | NoDefaultError>
readonly setDefault: (agent: ID) => Effect.Effect<void, NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
let agents = HashMap.empty<ID, Info>()
let defaultAgent: ID | undefined
const result: Interface = {
get: Effect.fn("AgentV2.get")(function* (agent) {
const match = HashMap.get(agents, agent)
if (!match.valueOrUndefined) return yield* new NotFoundError({ agent })
return match.value
}),
list: Effect.fn("AgentV2.list")(function* () {
return pipe(
HashMap.toValues(agents),
Array.sortWith((agent) => agent.name, Order.String),
)
}),
update: Effect.fnUntraced(function* (agent, fn) {
const next = produce(
HashMap.get(agents, agent).pipe(
Option.getOrElse(
() =>
({
name: agent,
mode: "all",
permission: [],
options: {
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
},
}) satisfies Info,
),
),
fn,
)
const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false })
if (updated.cancel) return
agents = HashMap.set(agents, agent, { ...updated.agent, name: agent })
}),
remove: Effect.fn("AgentV2.remove")(function* (agent) {
const existing = Option.getOrUndefined(HashMap.get(agents, agent))
if (!existing) return
if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return
agents = HashMap.remove(agents, agent)
if (defaultAgent === agent) defaultAgent = undefined
}),
defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () {
const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent })
const selected = updated.agent
if (selected) {
const agent = yield* result
.get(selected)
.pipe(
Effect.catchTag("AgentV2.NotFound", () =>
Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })),
),
)
if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" })
if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" })
return agent
}
const visible = pipe(
yield* result.list(),
Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true),
)
if (Option.isSome(visible)) return visible.value
return yield* new NoDefaultError()
}),
defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () {
return (yield* result.defaultInfo()).name
}),
setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) {
yield* result.get(agent)
defaultAgent = agent
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
-264
View File
@@ -1,264 +0,0 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const AccountID = Schema.String.pipe(
Schema.brand("AccountID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type AccountID = typeof AccountID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("AuthV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AuthV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AuthV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Account extends Schema.Class<Account>("AuthV2.Account")({
id: AccountID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class AuthFileWriteError extends Schema.TaggedErrorClass<AuthFileWriteError>()("AuthV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type AuthError = AuthFileWriteError
interface Writable {
version: 2
accounts: Record<string, Account>
active: Record<string, AccountID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Account> = {}
const active: Record<string, AccountID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const accountID = AccountID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Account({
id: accountID,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = accountID
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (accountID: AccountID) => Effect.Effect<Account | undefined, AuthError>
readonly all: () => Effect.Effect<Account[], AuthError>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
active?: boolean
}) => Effect.Effect<Account, AuthError>
readonly update: (
accountID: AccountID,
updates: Partial<Pick<Account, "description" | "credential">>,
) => Effect.Effect<void, AuthError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly activate: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly active: (serviceID: ServiceID) => Effect.Effect<Account | undefined, AuthError>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Account[], AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const file = path.join(global.data, "auth-v2.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, AuthError> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(yield* load())
const result: Interface = {
get: Effect.fn("AuthV2.get")(function* (accountID) {
return (yield* SynchronizedRef.get(state)).accounts[accountID]
}),
all: Effect.fn("AuthV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AuthV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AuthV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AuthV2.add")(function* (input) {
return yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = new Account({
id: AccountID.make(Identifier.ascending()),
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active:
(input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID))
? { ...data.active, [input.serviceID]: account.id }
: data.active,
}
yield* write(next)
return [account, next] as const
}),
)
}),
update: Effect.fn("AuthV2.update")(function* (accountID, updates) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const existing = data.accounts[accountID]
if (!existing) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[accountID]: new Account({
id: accountID,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AuthV2.remove")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID)
delete active[accounts[accountID].serviceID]
delete accounts[accountID]
const next = { ...data, accounts, active }
yield* write(next)
return [undefined, next] as const
}),
)
}),
activate: Effect.fn("AuthV2.activate")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = data.accounts[accountID]
if (!account) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } }
yield* write(next)
return [undefined, next] as const
}),
)
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))
export * as AuthV2 from "./auth"
+141 -53
View File
@@ -1,6 +1,6 @@
export * as Catalog from "./catalog" export * as Catalog from "./catalog"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect" import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect"
import { produce, type Draft } from "immer" import { produce, type Draft } from "immer"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { PluginV2 } from "./plugin" import { PluginV2 } from "./plugin"
@@ -8,9 +8,9 @@ import { ProviderV2 } from "./provider"
import { Location } from "./location" import { Location } from "./location"
import { EventV2 } from "./event" import { EventV2 } from "./event"
type ProviderRecord = { export type ProviderRecord = {
provider: ProviderV2.Info provider: ProviderV2.Info
models: HashMap.HashMap<ModelV2.ID, ModelV2.Info> models: Map<ModelV2.ID, ModelV2.Info>
} }
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()( export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
@@ -34,10 +34,26 @@ export const Event = {
}), }),
} }
export type Context = {
data: readonly ProviderRecord[]
updateProvider: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
updateModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
provider: {
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID) => void
}
model: {
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
}
}
export type Loader = (update: (ctx: Context) => void) => Effect.Effect<void>
export interface Interface { export interface Interface {
readonly loader: () => Effect.Effect<Loader, never, Scope.Scope>
readonly provider: { readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError> readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => Effect.Effect<void>
readonly all: () => Effect.Effect<ProviderV2.Info[]> readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]> readonly available: () => Effect.Effect<ProviderV2.Info[]>
} }
@@ -46,11 +62,6 @@ export interface Interface {
providerID: ProviderV2.ID, providerID: ProviderV2.ID,
modelID: ModelV2.ID, modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError> ) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly update: (
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
fn: (model: Draft<ModelV2.Info>) => void,
) => Effect.Effect<void, ProviderNotFoundError>
readonly all: () => Effect.Effect<ModelV2.Info[]> readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]> readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>> readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
@@ -69,9 +80,11 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
yield* Location.Service yield* Location.Service
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>() let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
let loaders: { update: (ctx: Context) => void }[] = []
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const resolve = (model: ModelV2.Info) => { const resolve = (model: ModelV2.Info) => {
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
@@ -112,29 +125,127 @@ export const layer = Layer.effect(
return match.value return match.value
} }
const normalizeEndpoint = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
if (item.endpoint.type !== "aisdk" || typeof item.options.aisdk.provider.baseURL !== "string") return
item.endpoint.url = item.options.aisdk.provider.baseURL
delete item.options.aisdk.provider.baseURL
}
const clone = (input: HashMap.HashMap<ProviderV2.ID, ProviderRecord>) =>
HashMap.fromIterable(
HashMap.toEntries(input).map(([key, value]) => [key, { ...value, models: new Map(value.models) }] as const),
)
const context = (draft: {
records: HashMap.HashMap<ProviderV2.ID, ProviderRecord>
data: ProviderRecord[]
}): Context => {
const result: Context = {
data: draft.data,
updateProvider: (providerID, fn) => result.provider.update(providerID, fn),
updateModel: (providerID, modelID, fn) => result.model.update(providerID, modelID, fn),
provider: {
update: (providerID, fn) => {
const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
fn(draft)
normalizeEndpoint(draft)
})
const next = {
provider,
models: current?.models ?? new Map<ModelV2.ID, ModelV2.Info>(),
}
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index === -1) draft.data.push(next)
else draft.data[index] = next
},
remove: (providerID) => {
draft.records = HashMap.remove(draft.records, providerID)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index !== -1) draft.data.splice(index, 1)
},
},
model: {
update: (providerID, modelID, fn) => {
const current = Option.getOrThrow(HashMap.get(draft.records, providerID))
const model = produce(current.models.get(modelID) ?? ModelV2.Info.empty(providerID, modelID), (draft) => {
fn(draft)
normalizeEndpoint(draft)
})
const next = {
provider: current.provider,
models: new Map(current.models).set(modelID, new ModelV2.Info({ ...model, id: modelID, providerID })),
}
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index === -1) draft.data.push(next)
else draft.data[index] = next
},
remove: (providerID, modelID) => {
const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
if (!current) return
const next = {
provider: current.provider,
models: new Map(current.models),
}
next.models.delete(modelID)
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index !== -1) draft.data[index] = next
},
},
}
return result
}
const transform = Effect.fn("CatalogV2.transform")(function* () {
const draft = { records: clone(records), data: HashMap.toValues(records) }
yield* plugin.trigger("catalog.transform", context(draft), {})
records = draft.records
})
const rebuild = Effect.fn("CatalogV2.rebuild")(function* () {
const draft = { records: HashMap.empty<ProviderV2.ID, ProviderRecord>(), data: [] as ProviderRecord[] }
for (const loader of loaders) loader.update(context(draft))
yield* plugin.trigger("catalog.transform", context(draft), {})
records = draft.records
})
yield* plugin.added().pipe(
Stream.runForEach((id) =>
Effect.gen(function* () {
const draft = { records: clone(records), data: HashMap.toValues(records) }
yield* plugin.triggerFor(id, "catalog.transform", context(draft), {})
records = draft.records
}),
),
Effect.forkIn(scope, { startImmediately: true }),
)
const result: Interface = { const result: Interface = {
loader: Effect.fn("CatalogV2.loader")(function* () {
const loader = { update: (_ctx: Context) => {} }
loaders = [...loaders, loader]
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
loaders = loaders.filter((item) => item !== loader)
}).pipe(Effect.andThen(rebuild())),
)
return Effect.fnUntraced(function* (update) {
loader.update = update
yield* rebuild()
})
}),
provider: { provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID) const record = yield* getRecord(providerID)
return record.provider return record.provider
}), }),
update: Effect.fnUntraced(function* (providerID, fn) {
const current = Option.getOrUndefined(HashMap.get(records, providerID))
const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
fn(draft)
if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
draft.endpoint.url = draft.options.aisdk.provider.baseURL
delete draft.options.aisdk.provider.baseURL
}
})
const updated = yield* plugin.trigger("provider.update", {}, { provider, cancel: false })
records = HashMap.set(records, providerID, {
provider: updated.provider,
models: current?.models ?? HashMap.empty<ModelV2.ID, ModelV2.Info>(),
})
}),
all: Effect.fn("CatalogV2.provider.all")(function* () { all: Effect.fn("CatalogV2.provider.all")(function* () {
return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider) return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider)
}), }),
@@ -149,39 +260,16 @@ export const layer = Layer.effect(
model: { model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) { get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
const record = yield* getRecord(providerID) const record = yield* getRecord(providerID)
const model = Option.getOrUndefined(HashMap.get(record.models, modelID)) const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return resolve(model) return resolve(model)
}), }),
update: Effect.fnUntraced(function* (providerID, modelID, fn) {
const record = yield* getRecord(providerID)
const model = produce(
HashMap.get(record.models, modelID).pipe(Option.getOrElse(() => ModelV2.Info.empty(providerID, modelID))),
(draft) => {
fn(draft)
if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
draft.endpoint.url = draft.options.aisdk.provider.baseURL
delete draft.options.aisdk.provider.baseURL
}
},
)
const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
if (updated.cancel) return
const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID })
records = HashMap.set(records, providerID, {
provider: record.provider,
models: HashMap.set(record.models, modelID, next),
})
yield* events.publish(Event.ModelUpdated, { model: resolve(next) })
return
}),
all: Effect.fn("CatalogV2.model.all")(function* () { all: Effect.fn("CatalogV2.model.all")(function* () {
return pipe( return pipe(
records, records,
HashMap.toValues, HashMap.toValues,
Array.flatMap((record) => HashMap.toValues(record.models)), Array.flatMap((record) => globalThis.Array.from(record.models.values())),
Array.map(resolve), Array.map(resolve),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
) )
@@ -217,12 +305,12 @@ export const layer = Layer.effect(
if (!record) return Option.none<ModelV2.Info>() if (!record) return Option.none<ModelV2.Info>()
if (providerID === ProviderV2.ID.opencode) { if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = Option.getOrUndefined(HashMap.get(record.models, ModelV2.ID.make("gpt-5-nano"))) const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano)) if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano))
} }
const candidates = pipe( const candidates = pipe(
HashMap.toValues(record.models), globalThis.Array.from(record.models.values()),
Array.filter( Array.filter(
(model) => (model) =>
model.providerID === providerID && model.providerID === providerID &&
@@ -266,4 +354,4 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
+2 -2
View File
@@ -1,3 +1,5 @@
export * as EventV2 from "./event"
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Location } from "./location" import { Location } from "./location"
import { withStatics } from "./schema" import { withStatics } from "./schema"
@@ -153,5 +155,3 @@ export const layer = Layer.effect(
) )
export const defaultLayer = layer export const defaultLayer = layer
export * as EventV2 from "./event"
+5 -4
View File
@@ -4,9 +4,10 @@ import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot" import { PluginBoot } from "./plugin/boot"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", { export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => { lookup: (ref: Location.Ref) =>
const location = Layer.succeed(Location.Service, Location.Service.of(ref)) Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(location)) Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]),
}, ),
idleTimeToLive: "5 minutes", idleTimeToLive: "5 minutes",
dependencies: [],
}) {} }) {}
+12 -1
View File
@@ -7,6 +7,7 @@ import { Flock } from "./util/flock"
import { Hash } from "./util/hash" import { Hash } from "./util/hash"
import { AppFileSystem } from "./filesystem" import { AppFileSystem } from "./filesystem"
import { InstallationChannel, InstallationVersion } from "./installation/version" import { InstallationChannel, InstallationVersion } from "./installation/version"
import { EventV2 } from "./event"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -105,6 +106,13 @@ export const Provider = Schema.Struct({
export type Provider = Schema.Schema.Type<typeof Provider> export type Provider = Schema.Schema.Type<typeof Provider>
export const Event = {
Refreshed: EventV2.define({
type: "models-dev.refreshed",
schema: {},
}),
}
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
export interface Interface { export interface Interface {
@@ -118,6 +126,7 @@ export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const events = yield* EventV2.Service
const http = HttpClient.filterStatusOk( const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe( (yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({ HttpClient.retryTransient({
@@ -197,6 +206,7 @@ export const layer = Layer.effect(
if (!force && (yield* fresh())) return if (!force && (yield* fresh())) return
yield* fetchAndWrite() yield* fetchAndWrite()
yield* invalidate yield* invalidate
yield* events.publish(Event.Refreshed, {})
}), }),
).pipe( ).pipe(
Effect.tapCause((cause) => Effect.tapCause((cause) =>
@@ -215,9 +225,10 @@ export const layer = Layer.effect(
}), }),
) )
export const defaultLayer: Layer.Layer<Service> = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(EventV2.defaultLayer),
) )
export * as ModelsDev from "./models-dev" export * as ModelsDev from "./models-dev"
+45
View File
@@ -0,0 +1,45 @@
export * as PermissionV2 from "./permission"
import { Schema } from "effect"
import { Wildcard } from "./util/wildcard"
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Action" })
export type Action = typeof Action.Type
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "PermissionV2.Rule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
export type Ruleset = typeof Ruleset.Type
const EDIT_TOOLS = ["edit", "write", "apply_patch"]
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
}
)
}
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return new Set(
tools.filter((tool) => {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
)
}
+63 -18
View File
@@ -2,27 +2,26 @@ export * as PluginV2 from "./plugin"
import { createDraft, finishDraft, type Draft } from "immer" import { createDraft, finishDraft, type Draft } from "immer"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
import { type ProviderV2 } from "./provider" import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import type { ModelV2 } from "./model" import type { ModelV2 } from "./model"
import type { AgentV2 } from "./agent"
import type { Catalog } from "./catalog"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
export type ID = typeof ID.Type export type ID = typeof ID.Type
type HookSpec = { type HookSpec = {
"provider.update": { "catalog.transform": {
input: {} input: Catalog.Context
output: { output: {}
provider: ProviderV2.Info
cancel: boolean
} }
"account.switched": {
input: {
serviceID: import("./account").AccountV2.ServiceID
from?: import("./account").AccountV2.ID
to?: import("./account").AccountV2.ID
} }
"model.update": { output: {}
input: {}
output: {
model: ModelV2.Info
cancel: boolean
}
} }
"aisdk.language": { "aisdk.language": {
input: { input: {
@@ -44,6 +43,27 @@ type HookSpec = {
sdk?: any sdk?: any
} }
} }
"agent.update": {
input: {}
output: {
agent: AgentV2.Info
cancel: boolean
}
}
"agent.remove": {
input: {
agent: AgentV2.Info
}
output: {
cancel: boolean
}
}
"agent.default": {
input: {}
output: {
agent?: AgentV2.ID
}
}
} }
export type Hooks = { export type Hooks = {
@@ -61,15 +81,25 @@ export type HookFunctions = {
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"] export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"] export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
export type Effect = Effect.Effect<HookFunctions | void, never, never> export type Effect<R = never> = Effect.Effect<HookFunctions | void, never, R | Scope.Scope>
export function define<R>(input: { id: ID; effect: Effect.Effect<HookFunctions | void, never, R> }) { export function define<R>(input: { id: ID; effect: Effect.Effect<HookFunctions | void, never, R> }) {
return input return input
} }
export interface Interface { export interface Interface {
readonly add: (input: { id: ID; effect: Effect }) => Effect.Effect<void> readonly add: (input: {
id: ID
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
}) => Effect.Effect<void, never, never>
readonly remove: (id: ID) => Effect.Effect<void> readonly remove: (id: ID) => Effect.Effect<void>
readonly added: () => Stream.Stream<ID>
readonly triggerFor: <Name extends keyof Hooks>(
id: ID,
name: Name,
input: HookInput<Name>,
output: HookOutput<Name>,
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
readonly trigger: <Name extends keyof Hooks>( readonly trigger: <Name extends keyof Hooks>(
name: Name, name: Name,
input: HookInput<Name>, input: HookInput<Name>,
@@ -85,21 +115,33 @@ export const layer = Layer.effect(
let hooks: { let hooks: {
id: ID id: ID
hooks: HookFunctions hooks: HookFunctions
scope: Scope.Closeable
}[] = [] }[] = []
const added = yield* PubSub.unbounded<ID>()
yield* Effect.addFinalizer(() => PubSub.shutdown(added))
const svc = Service.of({ const svc = Service.of({
add: Effect.fn("Plugin.add")(function* (input) { add: Effect.fn("Plugin.add")(function* (input) {
const result = yield* input.effect const existing = hooks.find((item) => item.id === input.id)
if (!result) return if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
const scope = yield* Scope.make()
const result = yield* input.effect.pipe(Scope.provide(scope))
hooks = [ hooks = [
...hooks.filter((item) => item.id !== input.id), ...hooks.filter((item) => item.id !== input.id),
{ {
id: input.id, id: input.id,
hooks: result, hooks: result ?? {},
scope,
}, },
] ]
yield* PubSub.publish(added, input.id)
}), }),
added: () => Stream.fromPubSub(added),
trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
return yield* svc.triggerFor(ID.make("*"), name, input, output)
}),
triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) {
const draftEntries = new Map<string, ReturnType<typeof createDraft>>() const draftEntries = new Map<string, ReturnType<typeof createDraft>>()
const event = { const event = {
...input, ...input,
@@ -114,6 +156,7 @@ export const layer = Layer.effect(
} }
for (const item of hooks) { for (const item of hooks) {
if (id !== ID.make("*") && item.id !== id) continue
const match = item.hooks[name] const match = item.hooks[name]
if (!match) continue if (!match) continue
yield* match(event as any).pipe( yield* match(event as any).pipe(
@@ -133,7 +176,9 @@ export const layer = Layer.effect(
return event as any return event as any
}), }),
remove: Effect.fn("Plugin.remove")(function* (id) { remove: Effect.fn("Plugin.remove")(function* (id) {
const existing = hooks.find((item) => item.id === id)
hooks = hooks.filter((item) => item.id !== id) hooks = hooks.filter((item) => item.id !== id)
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
}), }),
}) })
return svc return svc
+41
View File
@@ -0,0 +1,41 @@
import { Effect, Scope, Stream } from "effect"
import { AccountV2 } from "../account"
import { EventV2 } from "../event"
import { PluginV2 } from "../plugin"
export const AccountPlugin = PluginV2.define({
id: PluginV2.ID.make("account"),
effect: Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
yield* events.subscribe(AccountV2.Event.Switched).pipe(
Stream.runForEach((event) =>
PluginV2.Service.use((plugin) => plugin.trigger("account.switched", event.data, {})).pipe(Effect.asVoid),
),
Effect.forkIn(scope, { startImmediately: true }),
)
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie)
if (!account) continue
evt.provider.update(item.provider.id, (provider) => {
provider.enabled = {
via: "account",
service: account.serviceID,
}
if (account.credential.type === "api") {
provider.options.aisdk.provider.apiKey = account.credential.key
Object.assign(provider.options.aisdk.provider, account.credential.metadata ?? {})
}
if (account.credential.type === "oauth") provider.options.aisdk.provider.apiKey = account.credential.access
})
}
}),
"account.switched": Effect.fn(function* () {}),
}
}),
})
-27
View File
@@ -1,27 +0,0 @@
import { Effect } from "effect"
import { AuthV2 } from "../auth"
import { PluginV2 } from "../plugin"
export const AuthPlugin = PluginV2.define({
id: PluginV2.ID.make("auth"),
effect: Effect.gen(function* () {
const auth = yield* AuthV2.Service
return {
"provider.update": Effect.fn(function* (evt) {
const account = yield* auth.active(AuthV2.ServiceID.make(evt.provider.id)).pipe(Effect.orDie)
if (!account) return
evt.provider.enabled = {
via: "auth",
service: account.serviceID,
}
if (account.credential.type === "api") {
evt.provider.options.aisdk.provider.apiKey = account.credential.key
Object.assign(evt.provider.options.aisdk.provider, account.credential.metadata ?? {})
}
if (account.credential.type === "oauth") {
evt.provider.options.aisdk.provider.apiKey = account.credential.access
}
}),
}
}),
})
+21 -11
View File
@@ -1,18 +1,22 @@
export * as PluginBoot from "./boot" export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer } from "effect" import { Context, Deferred, Effect, Layer, Scope } from "effect"
import { AuthV2 } from "../auth" import { AccountV2 } from "../account"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog" import { Catalog } from "../catalog"
import { EventV2 } from "../event"
import { Npm } from "../npm" import { Npm } from "../npm"
import { PluginV2 } from "../plugin" import { PluginV2 } from "../plugin"
import { AuthPlugin } from "./auth" import { AccountPlugin } from "./account"
import { EnvPlugin } from "./env" import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev" import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider" import { ProviderPlugins } from "./provider"
type Plugin = { type Plugin = {
id: PluginV2.ID id: PluginV2.ID
effect: Effect.Effect<PluginV2.HookFunctions | void, never, Catalog.Service | AuthV2.Service | Npm.Service> effect: PluginV2.Effect<
Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service
>
} }
export interface Interface { export interface Interface {
@@ -21,14 +25,15 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Service | AuthV2.Service | Npm.Service> = export const layer = Layer.effect(
Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* AgentV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service const accounts = yield* AccountV2.Service
const npm = yield* Npm.Service const npm = yield* Npm.Service
const events = yield* EventV2.Service
const done = yield* Deferred.make<void>() const done = yield* Deferred.make<void>()
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
@@ -36,15 +41,18 @@ export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Servi
id: input.id, id: input.id,
effect: input.effect.pipe( effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog), Effect.provideService(Catalog.Service, catalog),
Effect.provideService(AuthV2.Service, auth), Effect.provideService(AgentV2.Service, agent),
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Npm.Service, npm), Effect.provideService(Npm.Service, npm),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
), ),
}) })
}) })
const boot = Effect.gen(function* () { const boot = Effect.gen(function* () {
yield* add(EnvPlugin) yield* add(EnvPlugin)
yield* add(AuthPlugin) yield* add(AccountPlugin)
for (const item of ProviderPlugins) { for (const item of ProviderPlugins) {
yield* add(item) yield* add(item)
} }
@@ -61,11 +69,13 @@ export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Servi
wait: () => Deferred.await(done), wait: () => Deferred.await(done),
}) })
}), }),
) )
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(AgentV2.defaultLayer),
Layer.provide(Catalog.defaultLayer), Layer.provide(Catalog.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(PluginV2.defaultLayer), Layer.provide(PluginV2.defaultLayer),
Layer.provide(Layer.orDie(AuthV2.defaultLayer)), Layer.provide(AccountV2.defaultLayer),
Layer.provide(Npm.defaultLayer), Layer.provide(Npm.defaultLayer),
) )
+8 -4
View File
@@ -5,13 +5,17 @@ export const EnvPlugin = PluginV2.define({
id: PluginV2.ID.make("env"), id: PluginV2.ID.make("env"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
const key = evt.provider.env.find((item) => process.env[item]) for (const item of evt.data) {
if (!key) return const key = item.provider.env.find((env) => process.env[env])
evt.provider.enabled = { if (!key) continue
evt.provider.update(item.provider.id, (provider) => {
provider.enabled = {
via: "env", via: "env",
name: key, name: key,
} }
})
}
}), }),
} }
}), }),
+18 -6
View File
@@ -1,5 +1,6 @@
import { DateTime, Effect } from "effect" import { DateTime, Effect, Scope, Stream } from "effect"
import { Catalog } from "../catalog" import { Catalog } from "../catalog"
import { EventV2 } from "../event"
import { ModelV2 } from "../model" import { ModelV2 } from "../model"
import { ModelsDev } from "../models-dev" import { ModelsDev } from "../models-dev"
import { PluginV2 } from "../plugin" import { PluginV2 } from "../plugin"
@@ -54,9 +55,15 @@ export const ModelsDevPlugin = PluginV2.define({
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const modelsDev = yield* ModelsDev.Service const modelsDev = yield* ModelsDev.Service
for (const item of Object.values(yield* modelsDev.get())) { const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const load = yield* catalog.loader()
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
const data = yield* modelsDev.get()
yield* load((catalog) => {
for (const item of Object.values(data)) {
const providerID = ProviderV2.ID.make(item.id) const providerID = ProviderV2.ID.make(item.id)
yield* catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, (provider) => {
provider.name = item.name provider.name = item.name
provider.env = [...item.env] provider.env = [...item.env]
provider.endpoint = item.npm provider.endpoint = item.npm
@@ -72,8 +79,7 @@ export const ModelsDevPlugin = PluginV2.define({
for (const model of Object.values(item.models)) { for (const model of Object.values(item.models)) {
const modelID = ModelV2.ID.make(model.id) const modelID = ModelV2.ID.make(model.id)
yield* catalog.model catalog.model.update(providerID, modelID, (draft) => {
.update(providerID, modelID, (draft) => {
draft.name = model.name draft.name = model.name
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
draft.endpoint = model.provider?.npm draft.endpoint = model.provider?.npm
@@ -101,8 +107,14 @@ export const ModelsDevPlugin = PluginV2.define({
output: model.limit.output, output: model.limit.output,
} }
}) })
.pipe(Effect.orDie)
} }
} }
})
})
yield* refresh()
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => refresh()),
Effect.forkIn(scope, { startImmediately: true }),
)
}).pipe(Effect.provide(ModelsDev.defaultLayer)), }).pipe(Effect.provide(ModelsDev.defaultLayer)),
}) })
@@ -50,14 +50,19 @@ export const AmazonBedrockPlugin = PluginV2.define({
id: PluginV2.ID.make("amazon-bedrock"), id: PluginV2.ID.make("amazon-bedrock"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.amazonBedrock) return for (const item of evt.data) {
if (evt.provider.endpoint.type !== "aisdk") return if (item.provider.endpoint.type !== "aisdk") continue
if (typeof evt.provider.options.aisdk.provider.endpoint !== "string") return if (item.provider.endpoint.package !== "@ai-sdk/amazon-bedrock") continue
evt.provider.update(item.provider.id, (provider) => {
if (provider.endpoint.type !== "aisdk") return
if (typeof provider.options.aisdk.provider.endpoint !== "string") return
// The AI SDK expects a base URL, but users configure Bedrock private/VPC // The AI SDK expects a base URL, but users configure Bedrock private/VPC
// endpoints as `endpoint`; move it into the catalog endpoint URL once. // endpoints as `endpoint`; move it into the catalog endpoint URL once.
evt.provider.endpoint.url = evt.provider.options.aisdk.provider.endpoint provider.endpoint.url = provider.options.aisdk.provider.endpoint
delete evt.provider.options.aisdk.provider.endpoint delete provider.options.aisdk.provider.endpoint
})
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/amazon-bedrock") return if (evt.package !== "@ai-sdk/amazon-bedrock") return
@@ -1,15 +1,19 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const AnthropicPlugin = PluginV2.define({ export const AnthropicPlugin = PluginV2.define({
id: PluginV2.ID.make("anthropic"), id: PluginV2.ID.make("anthropic"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.anthropic) return for (const item of evt.data) {
evt.provider.options.headers["anthropic-beta"] = if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/anthropic") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["anthropic-beta"] =
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
})
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/anthropic") return if (evt.package !== "@ai-sdk/anthropic") return
+20 -8
View File
@@ -14,12 +14,18 @@ export const AzurePlugin = PluginV2.define({
id: PluginV2.ID.make("azure"), id: PluginV2.ID.make("azure"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.azure) return for (const item of evt.data) {
const configured = evt.provider.options.aisdk.provider.resourceName if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/azure") continue
const configured = item.provider.options.aisdk.provider.resourceName
const resourceName = const resourceName =
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
if (resourceName) evt.provider.options.aisdk.provider.resourceName = resourceName if (!resourceName) continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.aisdk.provider.resourceName = resourceName
})
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return if (evt.package !== "@ai-sdk/azure") return
@@ -49,11 +55,17 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({
id: PluginV2.ID.make("azure-cognitive-services"), id: PluginV2.ID.make("azure-cognitive-services"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("azure-cognitive-services")) return
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
if (resourceName) if (!resourceName) return
evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (!item.provider.id.includes("azure-cognitive-services")) continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
})
}
}), }),
"aisdk.language": Effect.fn(function* (evt) { "aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
@@ -1,14 +1,18 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const CerebrasPlugin = PluginV2.define({ export const CerebrasPlugin = PluginV2.define({
id: PluginV2.ID.make("cerebras"), id: PluginV2.ID.make("cerebras"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (ctx) {
if (evt.provider.id !== ProviderV2.ID.make("cerebras")) return for (const item of ctx.data) {
evt.provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/cerebras") continue
ctx.provider.update(item.provider.id, (provider) => {
provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
})
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cerebras") return if (evt.package !== "@ai-sdk/cerebras") return
@@ -45,7 +45,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
function gatewayConfig(options: Record<string, unknown>): GatewayConfig | undefined { function gatewayConfig(options: Record<string, unknown>): GatewayConfig | undefined {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
// AuthPlugin copies CLI prompt metadata into options. The prompt stores the // AccountPlugin copies CLI prompt metadata into options. The prompt stores the
// gateway as gatewayId, while older config examples may use gateway. // gateway as gatewayId, while older config examples may use gateway.
const gatewayId = const gatewayId =
process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway") process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway")
@@ -10,13 +10,15 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
id: PluginV2.ID.make("cloudflare-workers-ai"), id: PluginV2.ID.make("cloudflare-workers-ai"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== providerID) return const item = evt.data.find((record) => record.provider.id === providerID)
if (evt.provider.endpoint.type !== "aisdk") return if (!item) return
if (evt.provider.endpoint.url) return evt.provider.update(item.provider.id, (provider) => {
if (provider.endpoint.type !== "aisdk") return
const accountId = resolveAccountId(evt.provider.options.aisdk.provider) if (provider.endpoint.url) return
if (accountId) evt.provider.endpoint.url = workersEndpoint(accountId) const accountId = resolveAccountId(provider.options.aisdk.provider)
if (accountId) provider.endpoint.url = workersEndpoint(accountId)
})
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return if (evt.model.providerID !== providerID) return
@@ -15,9 +15,6 @@ export const GithubCopilotPlugin = PluginV2.define({
id: PluginV2.ID.make("github-copilot"), id: PluginV2.ID.make("github-copilot"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.githubCopilot) return
}),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/github-copilot") return if (evt.package !== "@ai-sdk/github-copilot") return
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
@@ -33,11 +30,14 @@ export const GithubCopilotPlugin = PluginV2.define({
? evt.sdk.responses(evt.model.apiID) ? evt.sdk.responses(evt.model.apiID)
: evt.sdk.chat(evt.model.apiID) : evt.sdk.chat(evt.model.apiID)
}), }),
"model.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.githubCopilot)
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// This chat-only alias conflicts with the Copilot GPT-5 Responses route, // This chat-only alias conflicts with the Copilot GPT-5 Responses route,
// so hide it only for Copilot rather than for every provider catalog. // so hide it only for Copilot rather than for every provider catalog.
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true model.enabled = false
})
}), }),
} }
}), }),
@@ -57,20 +57,26 @@ export const GoogleVertexPlugin = PluginV2.define({
id: PluginV2.ID.make("google-vertex"), id: PluginV2.ID.make("google-vertex"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.googleVertex) return for (const item of evt.data) {
const project = resolveProject(evt.provider.options.aisdk.provider) if (item.provider.endpoint.type !== "aisdk") continue
const location = String(resolveLocation(evt.provider.options.aisdk.provider))
if (project) evt.provider.options.aisdk.provider.project = project
evt.provider.options.aisdk.provider.location = location
if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.url) {
evt.provider.endpoint.url = replaceVertexVars(evt.provider.endpoint.url, project, location)
}
if ( if (
evt.provider.endpoint.type === "aisdk" && item.provider.endpoint.package !== "@ai-sdk/google-vertex" &&
evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible") !item.provider.endpoint.package.includes("@ai-sdk/openai-compatible")
) { )
evt.provider.options.aisdk.provider.fetch = authFetch(evt.provider.options.aisdk.provider.fetch) continue
const project = resolveProject(item.provider.options.aisdk.provider)
const location = String(resolveLocation(item.provider.options.aisdk.provider))
evt.provider.update(item.provider.id, (provider) => {
if (project) provider.options.aisdk.provider.project = project
provider.options.aisdk.provider.location = location
if (provider.endpoint.type === "aisdk" && provider.endpoint.url) {
provider.endpoint.url = replaceVertexVars(provider.endpoint.url, project, location)
}
if (provider.endpoint.type === "aisdk" && provider.endpoint.package.includes("@ai-sdk/openai-compatible")) {
provider.options.aisdk.provider.fetch = authFetch(provider.options.aisdk.provider.fetch)
}
})
} }
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
@@ -102,20 +108,25 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
id: PluginV2.ID.make("google-vertex-anthropic"), id: PluginV2.ID.make("google-vertex-anthropic"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("google-vertex-anthropic")) return for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/google-vertex/anthropic") continue
const project = const project =
evt.provider.options.aisdk.provider.project ?? item.provider.options.aisdk.provider.project ??
process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GOOGLE_CLOUD_PROJECT ??
process.env.GCP_PROJECT ?? process.env.GCP_PROJECT ??
process.env.GCLOUD_PROJECT process.env.GCLOUD_PROJECT
const location = const location =
evt.provider.options.aisdk.provider.location ?? item.provider.options.aisdk.provider.location ??
process.env.GOOGLE_CLOUD_LOCATION ?? process.env.GOOGLE_CLOUD_LOCATION ??
process.env.VERTEX_LOCATION ?? process.env.VERTEX_LOCATION ??
"global" "global"
if (project) evt.provider.options.aisdk.provider.project = project evt.provider.update(item.provider.id, (provider) => {
evt.provider.options.aisdk.provider.location = location if (project) provider.options.aisdk.provider.project = project
provider.options.aisdk.provider.location = location
})
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
+10 -5
View File
@@ -1,15 +1,20 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const KiloPlugin = PluginV2.define({ export const KiloPlugin = PluginV2.define({
id: PluginV2.ID.make("kilo"), id: PluginV2.ID.make("kilo"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("kilo")) return for (const item of evt.data) {
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.headers["X-Title"] = "opencode" if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.endpoint.url !== "https://api.kilo.ai/api/gateway") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.options.headers["X-Title"] = "opencode"
})
}
}), }),
} }
}), }),
@@ -1,17 +1,22 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const LLMGatewayPlugin = PluginV2.define({ export const LLMGatewayPlugin = PluginV2.define({
id: PluginV2.ID.make("llmgateway"), id: PluginV2.ID.make("llmgateway"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("llmgateway")) return for (const item of evt.data) {
if (evt.provider.enabled === false) return if (item.provider.enabled === false) continue
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.headers["X-Title"] = "opencode" if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
evt.provider.options.headers["X-Source"] = "opencode" if (item.provider.endpoint.url !== "https://api.llmgateway.io/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.options.headers["X-Title"] = "opencode"
provider.options.headers["X-Source"] = "opencode"
})
}
}), }),
} }
}), }),
+11 -6
View File
@@ -1,16 +1,21 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const NvidiaPlugin = PluginV2.define({ export const NvidiaPlugin = PluginV2.define({
id: PluginV2.ID.make("nvidia"), id: PluginV2.ID.make("nvidia"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return for (const item of evt.data) {
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.headers["X-Title"] = "opencode" if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
evt.provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode" if (item.provider.endpoint.url !== "https://integrate.api.nvidia.com/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.options.headers["X-Title"] = "opencode"
provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
})
}
}), }),
} }
}), }),
+10 -4
View File
@@ -16,11 +16,17 @@ export const OpenAIPlugin = PluginV2.define({
if (evt.model.providerID !== ProviderV2.ID.openai) return if (evt.model.providerID !== ProviderV2.ID.openai) return
evt.language = evt.sdk.responses(evt.model.apiID) evt.language = evt.sdk.responses(evt.model.apiID)
}), }),
"model.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.openai) return for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai") continue
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a // OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so remove it only from OpenAI's catalog. // chat-completions-only model, so hide it only from OpenAI's catalog.
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true model.enabled = false
})
}
}), }),
} }
}), }),
+15 -10
View File
@@ -7,20 +7,25 @@ export const OpencodePlugin = PluginV2.define({
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
let hasKey = false let hasKey = false
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.opencode) return const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.opencode)
if (!item) return
hasKey = Boolean( hasKey = Boolean(
process.env.OPENCODE_API_KEY || process.env.OPENCODE_API_KEY ||
evt.provider.env.some((item) => process.env[item]) || item.provider.env.some((env) => process.env[env]) ||
evt.provider.options.aisdk.provider.apiKey || item.provider.options.aisdk.provider.apiKey ||
(evt.provider.enabled && evt.provider.enabled.via === "auth"), (item.provider.enabled && item.provider.enabled.via === "account"),
) )
if (!hasKey) evt.provider.options.aisdk.provider.apiKey = "public" evt.provider.update(item.provider.id, (provider) => {
}), if (!hasKey) provider.options.aisdk.provider.apiKey = "public"
"model.update": Effect.fn(function* (evt) { })
if (evt.model.providerID !== ProviderV2.ID.opencode) return
if (hasKey) return if (hasKey) return
if (evt.model.cost.some((item) => item.input > 0)) evt.cancel = true for (const model of item.models.values()) {
if (!model.cost.some((cost) => cost.input > 0)) continue
evt.model.update(item.provider.id, model.id, (draft) => {
draft.enabled = false
})
}
}), }),
} }
}), }),
+17 -12
View File
@@ -1,29 +1,34 @@
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const OpenRouterPlugin = PluginV2.define({ export const OpenRouterPlugin = PluginV2.define({
id: PluginV2.ID.make("openrouter"), id: PluginV2.ID.make("openrouter"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.openrouter) return for (const item of evt.data) {
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.headers["X-Title"] = "opencode" if (item.provider.endpoint.package !== "@openrouter/ai-sdk-provider") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.options.headers["X-Title"] = "opencode"
})
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
if (!item.models.has(modelID)) continue
evt.model.update(item.provider.id, modelID, (model) => {
// These are OpenRouter-specific OpenAI chat aliases that do not work
// on the generic path. Keep custom providers with matching IDs untouched.
model.enabled = false
})
}
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return if (evt.package !== "@openrouter/ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
evt.sdk = mod.createOpenRouter(evt.options) evt.sdk = mod.createOpenRouter(evt.options)
}), }),
"model.update": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.openrouter) return
// These are OpenRouter-specific OpenAI chat aliases that do not work on
// the generic path. Keep custom providers with matching IDs untouched.
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
if (evt.model.id === ModelV2.ID.make("openai/gpt-5-chat")) evt.cancel = true
}),
} }
}), }),
}) })
+9 -4
View File
@@ -6,10 +6,15 @@ export const VercelPlugin = PluginV2.define({
id: PluginV2.ID.make("vercel"), id: PluginV2.ID.make("vercel"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("vercel")) return for (const item of evt.data) {
evt.provider.options.headers["http-referer"] = "https://opencode.ai/" if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.headers["x-title"] = "opencode" if (item.provider.endpoint.package !== "@ai-sdk/vercel") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["http-referer"] = "https://opencode.ai/"
provider.options.headers["x-title"] = "opencode"
})
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/vercel") return if (evt.package !== "@ai-sdk/vercel") return
+10 -5
View File
@@ -1,15 +1,20 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const ZenmuxPlugin = PluginV2.define({ export const ZenmuxPlugin = PluginV2.define({
id: PluginV2.ID.make("zenmux"), id: PluginV2.ID.make("zenmux"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("zenmux")) return for (const item of evt.data) {
evt.provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/" if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.headers["X-Title"] ??= "opencode" if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.endpoint.url !== "https://zenmux.ai/api/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/"
provider.options.headers["X-Title"] ??= "opencode"
})
}
}), }),
} }
}), }),
+1 -1
View File
@@ -86,7 +86,7 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
name: Schema.String, name: Schema.String,
}), }),
Schema.Struct({ Schema.Struct({
via: Schema.Literal("auth"), via: Schema.Literal("account"),
service: Schema.String, service: Schema.String,
}), }),
Schema.Struct({ Schema.Struct({
+14
View File
@@ -0,0 +1,14 @@
export * as Wildcard from "./wildcard"
export function match(input: string, pattern: string) {
const normalized = input.replaceAll("\\", "/")
let escaped = pattern
.replaceAll("\\", "/")
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")
if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?"
return new RegExp("^" + escaped + "$", process.platform === "win32" ? "si" : "s").test(normalized)
}
+284
View File
@@ -0,0 +1,284 @@
import path from "path"
import { describe, expect } from "bun:test"
import { produce } from "immer"
import { Effect, Fiber, Layer, Option, Stream } from "effect"
import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(PluginV2.defaultLayer)
function context(
records: { provider: ProviderV2.Info; models: Map<ModelV2.ID, ModelV2.Info> }[],
updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>,
): Catalog.Context {
return {
data: records,
updateProvider: (providerID, fn) => context(records, updates).provider.update(providerID, fn),
updateModel: (providerID, modelID, fn) => context(records, updates).model.update(providerID, modelID, fn),
provider: {
update: (providerID, fn) => {
const record = records.find((item) => item.provider.id === providerID)
const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn)
if (record) record.provider = provider
else records.push({ provider, models: new Map<ModelV2.ID, ModelV2.Info>() })
updates.push({
id: providerID,
enabled: provider.enabled,
apiKey:
typeof provider.options.aisdk.provider.apiKey === "string"
? provider.options.aisdk.provider.apiKey
: undefined,
})
},
remove: (providerID) => {
const index = records.findIndex((item) => item.provider.id === providerID)
if (index !== -1) records.splice(index, 1)
},
},
model: {
update: () => {},
remove: () => {},
},
}
}
function testLayer(dir: string) {
return AccountV2.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(
Global.layerWith({
data: dir,
cache: path.join(dir, "cache"),
config: path.join(dir, "config"),
state: path.join(dir, "state"),
tmp: path.join(dir, "tmp"),
bin: path.join(dir, "bin"),
log: path.join(dir, "log"),
repos: path.join(dir, "repos"),
}),
),
)
}
describe("AccountV2", () => {
it.live("emits account lifecycle events", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const eventSvc = yield* EventV2.Service
const addedFiber = yield* eventSvc
.subscribe(AccountV2.Event.Added)
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
const switchedFiber = yield* eventSvc
.subscribe(AccountV2.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
const removedFiber = yield* eventSvc
.subscribe(AccountV2.Event.Removed)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "raw-key" }),
})
expect(first).toBeDefined()
if (!first) return
expect(first.description).toBe("default")
expect(first.credential.type).toBe("api")
if (first.credential.type === "api") expect(first.credential.key).toBe("raw-key")
yield* accounts.update(first.id, { description: "keep" })
const updated = yield* accounts.get(first.id)
expect(updated?.description).toBe("keep")
expect(updated?.credential.type).toBe("api")
if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key")
const second = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
})
expect(second).toBeDefined()
if (!second) return
yield* accounts.remove(second.id)
const added = Array.from(yield* Fiber.join(addedFiber))
const switched = Array.from(yield* Fiber.join(switchedFiber))
const removed = Array.from(yield* Fiber.join(removedFiber))
expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id])
expect(switched.map((event) => event.data)).toEqual([
{ serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id },
{ serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id },
{ serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: first.id },
])
expect(removed[0]?.data.account.id).toBe(second.id)
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("always switches to newly created accounts", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const eventSvc = yield* EventV2.Service
const switchedFiber = yield* eventSvc
.subscribe(AccountV2.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }),
})
const second = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
})
const third = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "third-key" }),
})
expect(first).toBeDefined()
expect(second).toBeDefined()
expect(third).toBeDefined()
if (!first || !second || !third) return
expect((yield* accounts.active(AccountV2.ServiceID.make("provider")))?.id).toBe(third.id)
expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([
{ serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id },
{ serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id },
{ serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: third.id },
])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("account plugin refreshes providers on account lifecycle events", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const plugin = yield* PluginV2.Service
const records = [
{
provider: ProviderV2.Info.empty(ProviderV2.ID.make("provider")),
models: new Map<ModelV2.ID, ModelV2.Info>(),
},
]
const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = []
const catalog = Catalog.Service.of({
loader: () => Effect.die("unexpected catalog.loader"),
provider: {
get: () => Effect.die("unexpected provider.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.die("unexpected model.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
setDefault: () => Effect.die("unexpected model.setDefault"),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
})
const eventSvc = yield* EventV2.Service
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, eventSvc),
Effect.provideService(PluginV2.Service, plugin),
),
})
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }),
})
expect(first).toBeDefined()
if (!first) return
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
apiKey: "first-key",
},
])
updates.length = 0
const second = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
})
expect(second).toBeDefined()
if (!second) return
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
apiKey: "second-key",
},
])
updates.length = 0
yield* accounts.activate(first.id)
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
apiKey: "first-key",
},
])
updates.length = 0
yield* accounts.remove(first.id)
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
apiKey: "second-key",
},
])
updates.length = 0
yield* accounts.remove(second.id)
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
})
+81 -74
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect" import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
@@ -22,24 +22,24 @@ describe("CatalogV2", () => {
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
yield* catalog.provider.update(providerID, (provider) => { yield* load((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.endpoint = { provider.endpoint = {
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://default.example.com", url: "https://default.example.com",
} }
provider.options.aisdk.provider.baseURL = "https://override.example.com" provider.options.aisdk.provider.baseURL = "https://override.example.com"
}) }),
)
const provider = yield* catalog.provider.get(providerID) expect((yield* catalog.provider.get(providerID)).endpoint).toEqual({
expect(provider.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://override.example.com", url: "https://override.example.com",
}) })
expect(provider.options.aisdk.provider.baseURL).toBeUndefined()
}), }),
) )
@@ -48,56 +48,27 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model") const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
yield* catalog.provider.update(providerID, (provider) => { yield* load((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.endpoint = { provider.endpoint = {
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://provider.example.com", url: "https://provider.example.com",
} }
}) })
yield* catalog.model.update(providerID, modelID, (model) => { catalog.model.update(providerID, modelID, (model) => {
model.endpoint = { model.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://model.example.com" }
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://model.example.com",
}
model.options.aisdk.provider.baseURL = "https://override.example.com" model.options.aisdk.provider.baseURL = "https://override.example.com"
}) })
})
const model = yield* catalog.model.get(providerID, modelID) expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
expect(model.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://override.example.com", url: "https://override.example.com",
}) })
expect(model.options.aisdk.provider.baseURL).toBeUndefined()
}),
)
it.effect("publishes model updated events", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const fiber = yield* events
.subscribe(Catalog.Event.ModelUpdated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* catalog.provider.update(providerID, () => {})
yield* catalog.model.update(providerID, modelID, (model) => {
model.name = "Updated Model"
})
const event = Array.from(yield* Fiber.join(fiber))[0]
expect(event?.type).toBe("catalog.model.updated")
expect(event?.data.model.providerID).toBe(providerID)
expect(event?.data.model.id).toBe(modelID)
expect(event?.data.model.name).toBe("Updated Model")
expect(event?.location).toEqual({ directory: "test" })
}), }),
) )
@@ -106,19 +77,20 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model") const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
yield* catalog.provider.update(providerID, (provider) => { yield* load((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.endpoint = { provider.endpoint = {
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://provider.example.com", url: "https://provider.example.com",
} }
}) })
yield* catalog.model.update(providerID, modelID, () => {}) catalog.model.update(providerID, modelID, () => {})
})
const model = yield* catalog.model.get(providerID, modelID) expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
expect(model.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://provider.example.com", url: "https://provider.example.com",
@@ -126,58 +98,91 @@ describe("CatalogV2", () => {
}), }),
) )
it.effect("runs provider hooks after baseURL is normalized", () => it.effect("runs catalog transform hooks after baseURL is normalized", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const seen: unknown[] = [] const seen: unknown[] = []
const load = yield* catalog.loader()
yield* plugin.add({ yield* plugin.add({
id: PluginV2.ID.make("test"), id: PluginV2.ID.make("test"),
effect: Effect.succeed({ effect: Effect.succeed({
"provider.update": (evt) => "catalog.transform": (evt) =>
Effect.sync(() => { Effect.sync(() => {
seen.push(evt.provider.endpoint.type) const item = evt.data.find((record) => record.provider.id === providerID)
if (evt.provider.endpoint.type === "aisdk") seen.push(evt.provider.endpoint.url) if (!item) return
seen.push(evt.provider.options.aisdk.provider.baseURL) seen.push(item.provider.endpoint.type)
if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url)
seen.push(item?.provider.options.aisdk.provider.baseURL)
}), }),
}), }),
}) })
yield* catalog.provider.update(providerID, (provider) => { yield* load((catalog) =>
provider.endpoint = { catalog.provider.update(providerID, (provider) => {
type: "aisdk", provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
package: "@ai-sdk/openai-compatible",
}
provider.options.aisdk.provider.baseURL = "https://provider.example.com" provider.options.aisdk.provider.baseURL = "https://provider.example.com"
}) }),
)
expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined]) expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined])
}), }),
) )
it.effect("runs catalog transform when a plugin is added", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.name = "Before"
}),
)
yield* plugin.add({
id: PluginV2.ID.make("test-transform"),
effect: Effect.succeed({
"catalog.transform": (evt) =>
Effect.sync(() =>
evt.provider.update(providerID, (provider) => {
provider.name = "After"
}),
),
}),
})
yield* Effect.yieldNow
expect((yield* catalog.provider.get(providerID)).name).toBe("After")
}),
)
it.effect("resolves provider and model option merges", () => it.effect("resolves provider and model option merges", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model") const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
yield* catalog.provider.update(providerID, (provider) => { yield* load((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.options.headers.provider = "provider" provider.options.headers.provider = "provider"
provider.options.headers.shared = "provider" provider.options.headers.shared = "provider"
provider.options.body.provider = true provider.options.body.provider = true
provider.options.aisdk.provider.provider = true provider.options.aisdk.provider.provider = true
}) })
yield* catalog.model.update(providerID, modelID, (model) => { catalog.model.update(providerID, modelID, (model) => {
model.options.headers.model = "model" model.options.headers.model = "model"
model.options.headers.shared = "model" model.options.headers.shared = "model"
model.options.body.model = true model.options.body.model = true
model.options.aisdk.provider.model = true model.options.aisdk.provider.model = true
model.options.aisdk.request.request = true model.options.aisdk.request.request = true
}) })
})
const model = yield* catalog.model.get(providerID, modelID) const model = yield* catalog.model.get(providerID, modelID)
expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" }) expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
expect(model.options.body).toEqual({ provider: true, model: true }) expect(model.options.body).toEqual({ provider: true, model: true })
expect(model.options.aisdk.provider).toEqual({ provider: true, model: true }) expect(model.options.aisdk.provider).toEqual({ provider: true, model: true })
@@ -189,20 +194,21 @@ describe("CatalogV2", () => {
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
yield* catalog.provider.update(providerID, (provider) => { yield* load((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} } provider.enabled = { via: "custom", data: {} }
}) })
yield* catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
model.time.released = DateTime.makeUnsafe(1000) model.time.released = DateTime.makeUnsafe(1000)
}) })
yield* catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => { catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
model.time.released = DateTime.makeUnsafe(2000) model.time.released = DateTime.makeUnsafe(2000)
}) })
})
const model = yield* catalog.model.default() expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new")
expect(Option.getOrUndefined(model)?.id).toMatch("new")
}), }),
) )
@@ -210,24 +216,25 @@ describe("CatalogV2", () => {
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
yield* catalog.provider.update(providerID, () => {}) yield* load((catalog) => {
yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
model.capabilities.input = ["text"] model.capabilities.input = ["text"]
model.capabilities.output = ["text"] model.capabilities.output = ["text"]
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
model.time.released = DateTime.makeUnsafe(Date.now()) model.time.released = DateTime.makeUnsafe(Date.now())
}) })
yield* catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
model.capabilities.input = ["text"] model.capabilities.input = ["text"]
model.capabilities.output = ["text"] model.capabilities.output = ["text"]
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
model.time.released = DateTime.makeUnsafe(Date.now()) model.time.released = DateTime.makeUnsafe(Date.now())
}) })
})
const model = yield* catalog.model.small(providerID) expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
expect(Option.getOrUndefined(model)?.id).toMatch("expensive-mini")
}), }),
) )
}) })
@@ -111,7 +111,7 @@ describe("cross-spawn spawner", () => {
ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }), ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }),
), ),
) )
expect(out).toBe(tmp.path) expect(yield* Effect.promise(() => fs.realpath(out))).toBe(yield* Effect.promise(() => fs.realpath(tmp.path)))
}), }),
) )
@@ -119,7 +119,9 @@ describe("cross-spawn spawner", () => {
"fails for invalid cwd", "fails for invalid cwd",
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* Effect.exit( const exit = yield* Effect.exit(
ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(), ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.spawn(ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" })),
),
) )
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
}), }),
+2
View File
@@ -5,6 +5,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "@opencode-ai/core/models-dev" import { ModelsDev } from "@opencode-ai/core/models-dev"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect" import { it } from "./lib/effect"
import { rm, writeFile, utimes, mkdir } from "fs/promises" import { rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path" import path from "path"
@@ -92,6 +93,7 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
Layer.fresh(ModelsDev.layer).pipe( Layer.fresh(ModelsDev.layer).pipe(
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(EventV2.defaultLayer),
) )
const writeCache = (data: object, mtimeMs?: number) => const writeCache = (data: object, mtimeMs?: number) =>
@@ -1,7 +1,9 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
@@ -20,27 +22,30 @@ describe("AmazonBedrockPlugin", () => {
it.effect("moves endpoint option to endpoint URL", () => it.effect("moves endpoint option to endpoint URL", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AmazonBedrockPlugin) yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const bedrock = provider("amazon-bedrock", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
provider: provider("amazon-bedrock", {
options: { options: {
headers: {}, headers: {},
body: {}, body: {},
aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} }, aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} },
}, },
}), })
cancel: false, catalog.provider.update(bedrock.id, (item) => {
}, item.endpoint = bedrock.endpoint
) item.options = bedrock.options
expect(result.provider.endpoint).toEqual({ })
})
const result = yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)
expect(result.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "@ai-sdk/amazon-bedrock",
url: "https://bedrock.example", url: "https://bedrock.example",
}) })
expect(result.provider.options.aisdk.provider.endpoint).toBeUndefined() expect(result.options.aisdk.provider.endpoint).toBeUndefined()
}), }),
) )
@@ -1,37 +1,43 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider } from "./provider-helper" import { it, model, provider } from "./provider-helper"
describe("AnthropicPlugin", () => { describe("AnthropicPlugin", () => {
it.effect("applies legacy beta headers", () => it.effect("applies legacy beta headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AnthropicPlugin) yield* plugin.add(AnthropicPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("anthropic", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" },
provider: provider("anthropic", {
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
}), })
cancel: false, catalog.provider.update(item.id, (draft) => {
}, draft.endpoint = item.endpoint
) draft.options = item.options
expect(result.provider.options.headers["anthropic-beta"]).toBe( })
})
expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).options.headers["anthropic-beta"]).toBe(
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
) )
expect(result.provider.options.headers.Existing).toBe("1") expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).options.headers.Existing).toBe("1")
}), }),
) )
it.effect("ignores non-Anthropic providers", () => it.effect("ignores non-Anthropic providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AnthropicPlugin) yield* plugin.add(AnthropicPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.headers["anthropic-beta"]).toBeUndefined() yield* load((catalog) => catalog.provider.update(provider("openai").id, () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.headers["anthropic-beta"]).toBeUndefined()
}), }),
) )
@@ -1,7 +1,9 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
describe("AzureCognitiveServicesPlugin", () => { describe("AzureCognitiveServicesPlugin", () => {
@@ -9,20 +11,22 @@ describe("AzureCognitiveServicesPlugin", () => {
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzureCognitiveServicesPlugin) yield* plugin.add(AzureCognitiveServicesPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
{ provider: provider("azure-cognitive-services"), cancel: false }, item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
)
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
}) })
expect(result.provider.options.aisdk.provider.baseURL).toBe( })
"https://cognitive.cognitiveservices.azure.com/openai", const result = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
) expect(result.endpoint).toEqual({
expect(result.provider.options.aisdk.provider.resourceName).toBeUndefined() type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://cognitive.cognitiveservices.azure.com/openai",
})
expect(result.options.aisdk.provider.baseURL).toBeUndefined()
expect(result.options.aisdk.provider.resourceName).toBeUndefined()
}), }),
), ),
) )
@@ -31,17 +35,27 @@ describe("AzureCognitiveServicesPlugin", () => {
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzureCognitiveServicesPlugin) yield* plugin.add(AzureCognitiveServicesPlugin)
const azure = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const azure = provider("azure-cognitive-services", {
{ provider: provider("azure-cognitive-services"), cancel: false }, endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
) })
const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) const openai = provider("openai")
expect(azure.provider.options.aisdk.provider.baseURL).toBeUndefined() catalog.provider.update(azure.id, (item) => {
expect(azure.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" }) item.endpoint = azure.endpoint
expect(other.provider.options.aisdk.provider.baseURL).toBeUndefined() })
expect(other.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" }) catalog.provider.update(openai.id, (item) => {
item.endpoint = openai.endpoint
})
})
const azure = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
const openai = yield* catalog.provider.get(ProviderV2.ID.openai)
expect(azure.options.aisdk.provider.baseURL).toBeUndefined()
expect(azure.endpoint).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" })
expect(openai.options.aisdk.provider.baseURL).toBeUndefined()
expect(openai.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
}), }),
), ),
) )
@@ -1,22 +1,40 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth" import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth" import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) const itWithAccount = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(npmLayer),
),
)
describe("AzurePlugin", () => { describe("AzurePlugin", () => {
it.effect("resolves resourceName from env", () => it.effect("resolves resourceName from env", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin) yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") yield* load((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
}), }),
), ),
) )
@@ -25,25 +43,29 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin) yield* plugin.add(AzurePlugin)
const azure = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const azure = provider("azure", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
provider: provider("azure", {
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } }, options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
}), })
cancel: false, catalog.provider.update(azure.id, (item) => {
}, item.endpoint = azure.endpoint
item.options = azure.options
})
catalog.provider.update(ProviderV2.ID.openai, () => {})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe(
"from-config",
) )
const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.resourceName).toBeUndefined()
expect(azure.provider.options.aisdk.provider.resourceName).toBe("from-config")
expect(other.provider.options.aisdk.provider.resourceName).toBeUndefined()
}), }),
), ),
) )
itWithAuth.effect("prefers auth resourceName over env", () => itWithAccount.effect("prefers account resourceName over env", () =>
withEnv( withEnv(
{ {
AZURE_RESOURCE_NAME: "from-env", AZURE_RESOURCE_NAME: "from-env",
@@ -51,23 +73,36 @@ describe("AzurePlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service const accounts = yield* AccountV2.Service
yield* auth.create({ const catalog = yield* Catalog.Service
serviceID: AuthV2.ServiceID.make("azure"), const events = yield* EventV2.Service
credential: new AuthV2.ApiKeyCredential({ yield* accounts.create({
serviceID: AccountV2.ServiceID.make("azure"),
credential: new AccountV2.ApiKeyCredential({
type: "api", type: "api",
key: "key", key: "key",
metadata: { resourceName: "from-auth" }, metadata: { resourceName: "from-account" },
}), }),
active: true,
}) })
yield* plugin.add({ yield* plugin.add({
...AuthPlugin, ...AccountPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
}) })
yield* plugin.add(AzurePlugin) yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-auth") yield* load((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe(
"from-account",
)
}), }),
), ),
) )
@@ -76,18 +111,20 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin) yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const azure = provider("azure", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
provider: provider("azure", {
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } }, options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
}), })
cancel: false, catalog.provider.update(azure.id, (item) => {
}, item.endpoint = azure.endpoint
) item.options = azure.options
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") })
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
}), }),
), ),
) )
@@ -96,18 +133,20 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin) yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const azure = provider("azure", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
provider: provider("azure", {
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } }, options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } },
}), })
cancel: false, catalog.provider.update(azure.id, (item) => {
}, item.endpoint = azure.endpoint
) item.options = azure.options
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") })
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
}), }),
), ),
) )
@@ -1,7 +1,9 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider } from "./provider-helper" import { it, model, provider } from "./provider-helper"
const cerebrasOptions: Record<string, unknown>[] = [] const cerebrasOptions: Record<string, unknown>[] = []
@@ -20,27 +22,30 @@ describe("CerebrasPlugin", () => {
it.effect("applies the legacy integration header", () => it.effect("applies the legacy integration header", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CerebrasPlugin) yield* plugin.add(CerebrasPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
{ item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" }
provider: provider("cerebras", { item.options.headers.Existing = "1"
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, })
}), })
cancel: false, expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).options.headers).toEqual({
}, Existing: "1",
) "X-Cerebras-3rd-Party-Integration": "opencode",
expect(result.provider.options.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode" }) })
}), }),
) )
it.effect("ignores non-Cerebras providers", () => it.effect("ignores non-Cerebras providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CerebrasPlugin) yield* plugin.add(CerebrasPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("groq"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.headers).toEqual({}) yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).options.headers).toEqual({})
}), }),
) )
@@ -1,14 +1,26 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth" import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth" import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) const itWithAccount = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(npmLayer),
),
)
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
@@ -34,22 +46,25 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin) yield* plugin.add(CloudflareWorkersAIPlugin)
const updated = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
{ provider: provider("cloudflare-workers-ai"), cancel: false }, provider.endpoint = { type: "aisdk", package: "test-provider" }
}),
) )
const provider = yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))
const sdk = yield* plugin.trigger( const sdk = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("cloudflare-workers-ai", "@cf/model", { endpoint: updated.provider.endpoint }), model: model("cloudflare-workers-ai", "@cf/model", { endpoint: provider.endpoint }),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
}, },
{}, {},
) )
expect(updated.provider.endpoint).toEqual({ expect(provider.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1", url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1",
@@ -63,18 +78,15 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin) yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
{ provider.endpoint = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
provider: provider("cloudflare-workers-ai", {
endpoint: { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" },
}), }),
cancel: false,
},
) )
expect(result.provider.endpoint).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "test-provider",
url: "https://proxy.example/v1", url: "https://proxy.example/v1",
@@ -104,7 +116,7 @@ describe("CloudflareWorkersAIPlugin", () => {
), ),
) )
itWithAuth.effect("falls back to auth account metadata when account env is absent", () => itWithAccount.effect("falls back to account metadata when account env is absent", () =>
withEnv( withEnv(
{ {
CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_ACCOUNT_ID: undefined,
@@ -113,30 +125,37 @@ describe("CloudflareWorkersAIPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service const accounts = yield* AccountV2.Service
yield* auth.create({ const catalog = yield* Catalog.Service
serviceID: AuthV2.ServiceID.make("cloudflare-workers-ai"), const events = yield* EventV2.Service
credential: new AuthV2.ApiKeyCredential({ yield* accounts.create({
serviceID: AccountV2.ServiceID.make("cloudflare-workers-ai"),
credential: new AccountV2.ApiKeyCredential({
type: "api", type: "api",
key: "auth-key", key: "account-key",
metadata: { accountId: "auth-acct" }, metadata: { accountId: "account-acct" },
}), }),
active: true,
}) })
yield* plugin.add({ yield* plugin.add({
...AuthPlugin, ...AccountPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
}) })
yield* plugin.add(CloudflareWorkersAIPlugin) yield* plugin.add(CloudflareWorkersAIPlugin)
const updated = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
{ provider: provider("cloudflare-workers-ai"), cancel: false }, provider.endpoint = { type: "aisdk", package: "test-provider" }
}),
) )
expect(updated.provider.endpoint).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/auth-acct/ai/v1", url: "https://api.cloudflare.com/client/v4/accounts/account-acct/ai/v1",
}) })
}), }),
), ),
@@ -146,18 +165,16 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin) yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
{ provider.endpoint = { type: "aisdk", package: "test-provider" }
provider: provider("cloudflare-workers-ai", { provider.options.aisdk.provider.accountId = "configured-acct"
options: { headers: {}, body: {}, aisdk: { provider: { accountId: "configured-acct" }, request: {} } },
}), }),
cancel: false,
},
) )
expect(result.provider.endpoint).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1", url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model } from "./provider-helper" import { fakeSelectorSdk, it, model } from "./provider-helper"
describe("GithubCopilotPlugin", () => { describe("GithubCopilotPlugin", () => {
@@ -145,29 +147,35 @@ describe("GithubCopilotPlugin", () => {
}), }),
) )
it.effect("filters gpt-5-chat-latest before Copilot language selection", () => it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GithubCopilotPlugin) yield* plugin.add(GithubCopilotPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"model.update", yield* load((catalog) => {
{}, catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
{ model: model("github-copilot", "gpt-5-chat-latest"), cancel: false }, catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
) })
expect(result.cancel).toBe(true) expect(
(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
).toBe(false)
}), }),
) )
it.effect("does not filter gpt-5-chat-latest for non-Copilot providers", () => it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GithubCopilotPlugin) yield* plugin.add(GithubCopilotPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"model.update", yield* load((catalog) => {
{}, catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
{ model: model("custom-copilot", "gpt-5-chat-latest"), cancel: false }, catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
) })
expect(result.cancel).toBe(false) expect(
(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
).toBe(true)
}), }),
) )
@@ -1,11 +1,15 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth" import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth" import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { it, model, npmLayer, provider, withEnv } from "./provider-helper" import { it, model, npmLayer, withEnv } from "./provider-helper"
const gitlabSDKOptions: Record<string, unknown>[] = [] const gitlabSDKOptions: Record<string, unknown>[] = []
@@ -22,7 +26,15 @@ void mock.module("gitlab-ai-provider", () => ({
isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact", isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact",
})) }))
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) const itWithAccount = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(npmLayer),
),
)
describe("GitLabPlugin", () => { describe("GitLabPlugin", () => {
it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () => it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
@@ -141,7 +153,7 @@ describe("GitLabPlugin", () => {
}), }),
) )
itWithAuth.effect("uses active API auth token over GITLAB_TOKEN", () => itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () =>
withEnv( withEnv(
{ {
GITLAB_TOKEN: "env-token", GITLAB_TOKEN: "env-token",
@@ -150,33 +162,41 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gitlabSDKOptions.length = 0 gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service const accounts = yield* AccountV2.Service
yield* auth.create({ const catalog = yield* Catalog.Service
serviceID: AuthV2.ServiceID.make("gitlab"), const events = yield* EventV2.Service
credential: new AuthV2.ApiKeyCredential({ type: "api", key: "auth-token" }), yield* accounts.create({
active: true, serviceID: AccountV2.ServiceID.make("gitlab"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "account-token" }),
}) })
yield* plugin.add({ yield* plugin.add({
...AuthPlugin, ...AccountPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
}) })
yield* plugin.add(GitLabPlugin) yield* plugin.add(GitLabPlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false }) const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("gitlab", "claude"), model: model("gitlab", "claude"),
package: "gitlab-ai-provider", package: "gitlab-ai-provider",
options: updated.provider.options.aisdk.provider, options: provider.options.aisdk.provider,
}, },
{}, {},
) )
expect(gitlabSDKOptions[0].apiKey).toBe("auth-token") expect(gitlabSDKOptions[0].apiKey).toBe("account-token")
}), }),
), ),
) )
itWithAuth.effect("uses active OAuth access token when no API auth exists", () => itWithAccount.effect("uses active account OAuth access token when no API token exists", () =>
withEnv( withEnv(
{ {
GITLAB_TOKEN: undefined, GITLAB_TOKEN: undefined,
@@ -185,33 +205,41 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gitlabSDKOptions.length = 0 gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service const accounts = yield* AccountV2.Service
yield* auth.create({ const catalog = yield* Catalog.Service
serviceID: AuthV2.ServiceID.make("gitlab"), const events = yield* EventV2.Service
credential: new AuthV2.OAuthCredential({ yield* accounts.create({
serviceID: AccountV2.ServiceID.make("gitlab"),
credential: new AccountV2.OAuthCredential({
type: "oauth", type: "oauth",
refresh: "refresh-token", refresh: "refresh-token",
access: "oauth-token", access: "account-oauth-token",
expires: 9999999999999, expires: 9999999999999,
}), }),
active: true,
}) })
yield* plugin.add({ yield* plugin.add({
...AuthPlugin, ...AccountPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
}) })
yield* plugin.add(GitLabPlugin) yield* plugin.add(GitLabPlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false }) const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("gitlab", "claude"), model: model("gitlab", "claude"),
package: "gitlab-ai-provider", package: "gitlab-ai-provider",
options: updated.provider.options.aisdk.provider, options: provider.options.aisdk.provider,
}, },
{}, {},
) )
expect(gitlabSDKOptions[0].apiKey).toBe("oauth-token") expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token")
}), }),
), ),
) )
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
describe("GoogleVertexAnthropicPlugin", () => { describe("GoogleVertexAnthropicPlugin", () => {
it.effect("resolves legacy project and location env on provider update", () => it.effect("resolves legacy project and location env on provider update", () =>
@@ -18,14 +20,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexAnthropicPlugin) yield* plugin.add(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
{ provider: provider("google-vertex-anthropic"), cancel: false }, provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
}),
) )
expect(result.provider.options.aisdk.provider.project).toBe("cloud-project") const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))
expect(result.provider.options.aisdk.provider.location).toBe("cloud-location") expect(provider.options.aisdk.provider.project).toBe("cloud-project")
expect(provider.options.aisdk.provider.location).toBe("cloud-location")
}), }),
), ),
) )
@@ -34,23 +39,19 @@ describe("GoogleVertexAnthropicPlugin", () => {
withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () => withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexAnthropicPlugin) yield* plugin.add(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
{ provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
provider: provider("google-vertex-anthropic", { provider.options.aisdk.provider.project = "configured-project"
options: { provider.options.aisdk.provider.location = "configured-location"
headers: {},
body: {},
aisdk: { provider: { project: "configured-project", location: "configured-location" }, request: {} },
},
}), }),
cancel: false,
},
) )
expect(result.provider.options.aisdk.provider.project).toBe("configured-project") const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))
expect(result.provider.options.aisdk.provider.location).toBe("configured-location") expect(provider.options.aisdk.provider.project).toBe("configured-project")
expect(provider.options.aisdk.provider.location).toBe("configured-location")
}), }),
), ),
) )
@@ -1,8 +1,10 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
const vertexOptions: Record<string, any>[] = [] const vertexOptions: Record<string, any>[] = []
@@ -43,24 +45,22 @@ describe("GoogleVertexPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin) yield* plugin.add(GoogleVertexPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
{ provider.endpoint = {
provider: provider("google-vertex", {
endpoint: {
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
}, }
}), }),
cancel: false,
},
) )
expect(result.provider.options.aisdk.provider.project).toBe("google-cloud-project") const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
expect(result.provider.options.aisdk.provider.location).toBe("google-vertex-location") expect(provider.options.aisdk.provider.project).toBe("google-cloud-project")
expect(result.provider.endpoint).toEqual({ expect(provider.options.aisdk.provider.location).toBe("google-vertex-location")
expect(provider.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location", url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location",
@@ -84,21 +84,19 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
vertexOptions.length = 0 vertexOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin) yield* plugin.add(GoogleVertexPlugin)
const updated = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
{ provider.endpoint = {
provider: provider("google-vertex", {
endpoint: {
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
}, }
}), }),
cancel: false,
},
) )
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
@@ -111,8 +109,8 @@ describe("GoogleVertexPlugin", () => {
{}, {},
) )
expect(updated.provider.options.aisdk.provider.project).toBe("vertex-project") expect(provider.options.aisdk.provider.project).toBe("vertex-project")
expect(updated.provider.endpoint).toEqual({ expect(provider.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4", url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4",
@@ -136,29 +134,24 @@ describe("GoogleVertexPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin) yield* plugin.add(GoogleVertexPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
{ provider.endpoint = {
provider: provider("google-vertex", {
endpoint: {
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
}, }
options: { provider.options.aisdk.provider.project = "config-project"
headers: {}, provider.options.aisdk.provider.location = "global"
body: {},
aisdk: { provider: { project: "config-project", location: "global" }, request: {} },
},
}), }),
cancel: false,
},
) )
expect(result.provider.options.aisdk.provider.project).toBe("config-project") const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
expect(result.provider.options.aisdk.provider.location).toBe("global") expect(provider.options.aisdk.provider.project).toBe("config-project")
expect(result.provider.endpoint).toEqual({ expect(provider.options.aisdk.provider.location).toBe("global")
expect(provider.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global", url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global",
@@ -180,19 +173,18 @@ describe("GoogleVertexPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin) yield* plugin.add(GoogleVertexPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
{ provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex" }
provider: provider("google-vertex", { provider.options.aisdk.provider.project = "config-project"
options: { headers: {}, body: {}, aisdk: { provider: { project: "config-project" }, request: {} } },
}), }),
cancel: false,
},
) )
expect(result.provider.options.aisdk.provider.project).toBe("config-project") const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
expect(result.provider.options.aisdk.provider.location).toBe("us-central1") expect(provider.options.aisdk.provider.project).toBe("config-project")
expect(provider.options.aisdk.provider.location).toBe("us-central1")
}), }),
), ),
) )
+32 -1
View File
@@ -2,12 +2,16 @@ import { Npm } from "@opencode-ai/core/npm"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
import { expect } from "bun:test" import { expect } from "bun:test"
import { Effect, Layer, Option } from "effect" import { Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
export const npmLayer = Layer.succeed( export const npmLayer = Layer.succeed(
Npm.Service, Npm.Service,
@@ -18,7 +22,34 @@ export const npmLayer = Layer.succeed(
}), }),
) )
export const it = testEffect(Layer.mergeAll(PluginV2.defaultLayer, npmLayer)) export const catalogLayer = Layer.succeed(
Catalog.Service,
Catalog.Service.of({
loader: () => Effect.die("unexpected catalog.loader"),
provider: {
get: () => Effect.die("unexpected provider.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.die("unexpected model.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
setDefault: () => Effect.die("unexpected model.setDefault"),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
}),
)
export const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(npmLayer),
),
)
export function provider(providerID: string, options?: Partial<ProviderV2.Info>) { export function provider(providerID: string, options?: Partial<ProviderV2.Info>) {
return new ProviderV2.Info({ return new ProviderV2.Info({
+48 -38
View File
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper" import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("KiloPlugin", () => { describe("KiloPlugin", () => {
@@ -18,73 +20,81 @@ describe("KiloPlugin", () => {
it.effect("applies legacy referer headers only to kilo", () => it.effect("applies legacy referer headers only to kilo", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin) yield* plugin.add(KiloPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const kilo = provider("kilo", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
provider: provider("kilo", {
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}), })
cancel: false, catalog.provider.update(kilo.id, (draft) => {
}, draft.endpoint = kilo.endpoint
) draft.options = kilo.options
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) })
expect(result.provider.options.headers).toEqual({ catalog.provider.update(provider("openrouter").id, () => {})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect(ignored.provider.options.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
}), }),
) )
it.effect("uses the exact legacy Kilo header casing and set", () => it.effect("uses the exact legacy Kilo header casing and set", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin) yield* plugin.add(KiloPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("kilo"), cancel: false }) const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("kilo", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
})
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
})
expect(result.provider.options.headers).toEqual({ const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo"))
expect(result.options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect(result.provider.options.headers).not.toHaveProperty("http-referer") expect(result.options.headers).not.toHaveProperty("http-referer")
expect(result.provider.options.headers).not.toHaveProperty("x-title") expect(result.options.headers).not.toHaveProperty("x-title")
expect(result.provider.options.headers).not.toHaveProperty("X-Source") expect(result.options.headers).not.toHaveProperty("X-Source")
}), }),
) )
it.effect("uses the legacy provider-id guard instead of endpoint package matching", () => it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin) yield* plugin.add(KiloPlugin)
const matchingID = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const kilo = provider("kilo", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
provider: provider("kilo", { })
endpoint: { type: "aisdk", package: "not-kilo" }, catalog.provider.update(kilo.id, (draft) => {
}), draft.endpoint = kilo.endpoint
cancel: false, })
}, const custom = provider("custom-kilo", {
)
const matchingPackage = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("custom-kilo", {
endpoint: { type: "aisdk", package: "kilo" }, endpoint: { type: "aisdk", package: "kilo" },
}), })
cancel: false, catalog.provider.update(custom.id, (draft) => {
}, draft.endpoint = custom.endpoint
) })
})
expect(matchingID.provider.options.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect(matchingPackage.provider.options.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).options.headers).toEqual({})
}), }),
) )
}) })
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper" import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("LLMGatewayPlugin", () => { describe("LLMGatewayPlugin", () => {
@@ -18,46 +20,54 @@ describe("LLMGatewayPlugin", () => {
it.effect("applies legacy referer headers only to enabled llmgateway", () => it.effect("applies legacy referer headers only to enabled llmgateway", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin) yield* plugin.add(LLMGatewayPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const llmgateway = provider("llmgateway", {
{
provider: provider("llmgateway", {
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" }, enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}), })
cancel: false, catalog.provider.update(llmgateway.id, (draft) => {
}, draft.enabled = llmgateway.enabled
) draft.endpoint = llmgateway.endpoint
const ignored = yield* plugin.trigger( draft.options = llmgateway.options
"provider.update", })
{}, const openrouter = provider("openrouter", {
{
provider: provider("openrouter", {
enabled: { via: "env", name: "OPENROUTER_API_KEY" }, enabled: { via: "env", name: "OPENROUTER_API_KEY" },
}), })
cancel: false, catalog.provider.update(openrouter.id, (draft) => {
}, draft.enabled = openrouter.enabled
) })
expect(result.provider.options.headers).toEqual({ })
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-Source": "opencode", "X-Source": "opencode",
}) })
expect(ignored.provider.options.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
}), }),
) )
it.effect("does not apply legacy headers to a disabled llmgateway provider", () => it.effect("does not apply legacy headers to a disabled llmgateway provider", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin) yield* plugin.add(LLMGatewayPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("llmgateway"), cancel: false }) const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("llmgateway", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
})
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
})
expect(result.provider.enabled).toBe(false) expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
expect(result.provider.options.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({})
}), }),
) )
}) })
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper" import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("NvidiaPlugin", () => { describe("NvidiaPlugin", () => {
@@ -18,45 +20,48 @@ describe("NvidiaPlugin", () => {
it.effect("applies NVIDIA tracking headers only to nvidia", () => it.effect("applies NVIDIA tracking headers only to nvidia", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin) yield* plugin.add(NvidiaPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const nvidia = provider("nvidia", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
provider: provider("nvidia", {
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}), })
cancel: false, catalog.provider.update(nvidia.id, (draft) => {
}, draft.endpoint = nvidia.endpoint
) draft.options = nvidia.options
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) })
expect(result.provider.options.headers).toEqual({ catalog.provider.update(provider("openrouter").id, () => {})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode", "X-BILLING-INVOKE-ORIGIN": "OpenCode",
}) })
expect(ignored.provider.options.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
}), }),
) )
it.effect("adds billing origin for custom NVIDIA endpoints", () => it.effect("adds billing origin for custom NVIDIA endpoints", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin) yield* plugin.add(NvidiaPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("nvidia", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
provider: provider("nvidia", {
endpoint: { type: "aisdk", package: "test-provider", url: "http://localhost:8000/v1" },
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } }, options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
}), })
cancel: false, catalog.provider.update(item.id, (draft) => {
}, draft.endpoint = item.endpoint
) draft.options = item.options
})
})
expect(result.provider.options.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode", "X-BILLING-INVOKE-ORIGIN": "OpenCode",
@@ -67,23 +72,25 @@ describe("NvidiaPlugin", () => {
it.effect("preserves an explicit NVIDIA billing origin header", () => it.effect("preserves an explicit NVIDIA billing origin header", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin) yield* plugin.add(NvidiaPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("nvidia", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
provider: provider("nvidia", {
options: { options: {
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
body: {}, body: {},
aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} }, aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} },
}, },
}), })
cancel: false, catalog.provider.update(item.id, (draft) => {
}, draft.endpoint = item.endpoint
) draft.options = item.options
})
})
expect(result.provider.options.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
@@ -1,9 +1,11 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { fakeSelectorSdk, it, model } from "./provider-helper" import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, provider } from "./provider-helper"
describe("OpenAIPlugin", () => { describe("OpenAIPlugin", () => {
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
@@ -70,31 +72,39 @@ describe("OpenAIPlugin", () => {
}), }),
) )
it.effect("cancels gpt-5-chat-latest during model updates", () => it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenAIPlugin) yield* plugin.add(OpenAIPlugin)
const normal = yield* plugin.trigger("model.update", {}, { model: model("openai", "gpt-5"), cancel: false }) const load = yield* catalog.loader()
const filtered = yield* plugin.trigger( yield* load((catalog) => {
"model.update", const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } })
{}, catalog.provider.update(item.id, (draft) => {
{ model: model("openai", "gpt-5-chat-latest"), cancel: false }, draft.endpoint = item.endpoint
) })
expect(normal.cancel).toBe(false) catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
expect(filtered.cancel).toBe(true) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true)
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false)
}), }),
) )
it.effect("does not cancel gpt-5-chat-latest for non-OpenAI providers", () => it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenAIPlugin) yield* plugin.add(OpenAIPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"model.update", yield* load((catalog) => {
{}, const item = provider("custom-openai")
{ model: model("custom-openai", "gpt-5-chat-latest"), cancel: false }, catalog.provider.update(item.id, () => {})
) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
expect(result.cancel).toBe(false) })
expect(
(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
).toBe(true)
}), }),
) )
}) })
@@ -12,19 +12,23 @@ const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0,
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
describe("OpencodePlugin", () => { describe("OpencodePlugin", () => {
it.effect("uses a public key and cancels paid models without credentials", () => it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) const load = yield* catalog.loader()
const paid = yield* plugin.trigger( yield* load((catalog) => {
"model.update", const item = provider("opencode")
{}, catalog.provider.update(item.id, () => {})
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, const paid = model("opencode", "paid", { cost: cost(1) })
) catalog.model.update(item.id, paid.id, (draft) => {
expect(updated.provider.options.aisdk.provider.apiKey).toBe("public") draft.cost = [...paid.cost]
expect(paid.cancel).toBe(true) })
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
}), }),
), ),
) )
@@ -33,14 +37,19 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) const load = yield* catalog.loader()
const free = yield* plugin.trigger( yield* load((catalog) => {
"model.update", const item = provider("opencode")
{}, catalog.provider.update(item.id, () => {})
{ model: model("opencode", "free", { cost: cost(0) }), cancel: false }, const free = model("opencode", "free", { cost: cost(0) })
) catalog.model.update(item.id, free.id, (draft) => {
expect(free.cancel).toBe(false) draft.cost = [...free.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -49,14 +58,19 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) const load = yield* catalog.loader()
const outputOnly = yield* plugin.trigger( yield* load((catalog) => {
"model.update", const item = provider("opencode")
{}, catalog.provider.update(item.id, () => {})
{ model: model("opencode", "output-only", { cost: cost(0, 1) }), cancel: false }, const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
) catalog.model.update(item.id, outputOnly.id, (draft) => {
expect(outputOnly.cancel).toBe(false) draft.cost = [...outputOnly.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -65,15 +79,19 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: "secret" }, () => withEnv({ OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) const load = yield* catalog.loader()
const paid = yield* plugin.trigger( yield* load((catalog) => {
"model.update", const item = provider("opencode")
{}, catalog.provider.update(item.id, () => {})
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, const paid = model("opencode", "paid", { cost: cost(1) })
) catalog.model.update(item.id, paid.id, (draft) => {
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() draft.cost = [...paid.cost]
expect(paid.cancel).toBe(false) })
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -82,19 +100,21 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () => withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
{ provider: provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }), cancel: false }, catalog.provider.update(item.id, (draft) => {
) draft.env = [...item.env]
const paid = yield* plugin.trigger( })
"model.update", const paid = model("opencode", "paid", { cost: cost(1) })
{}, catalog.model.update(item.id, paid.id, (draft) => {
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, draft.cost = [...paid.cost]
) })
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() })
expect(paid.cancel).toBe(false) expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -103,12 +123,11 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("opencode", {
{
provider: provider("opencode", {
options: { options: {
headers: {}, headers: {},
body: {}, body: {},
@@ -117,17 +136,17 @@ describe("OpencodePlugin", () => {
request: {}, request: {},
}, },
}, },
}), })
cancel: false, catalog.provider.update(item.id, (draft) => {
}, draft.options = item.options
) })
const paid = yield* plugin.trigger( const paid = model("opencode", "paid", { cost: cost(1) })
"model.update", catalog.model.update(item.id, paid.id, (draft) => {
{}, draft.cost = [...paid.cost]
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, })
) })
expect(updated.provider.options.aisdk.provider.apiKey).toBe("configured") expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("configured")
expect(paid.cancel).toBe(false) expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -136,19 +155,21 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("opencode", { enabled: { via: "account", service: "opencode" } })
{ provider: provider("opencode", { enabled: { via: "auth", service: "opencode" } }), cancel: false }, catalog.provider.update(item.id, (draft) => {
) draft.enabled = item.enabled
const paid = yield* plugin.trigger( })
"model.update", const paid = model("opencode", "paid", { cost: cost(1) })
{}, catalog.model.update(item.id, paid.id, (draft) => {
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, draft.cost = [...paid.cost]
) })
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() })
expect(paid.cancel).toBe(false) expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -157,15 +178,19 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) const load = yield* catalog.loader()
const paid = yield* plugin.trigger( yield* load((catalog) => {
"model.update", const item = provider("openai")
{}, catalog.provider.update(item.id, () => {})
{ model: model("openai", "paid", { cost: cost(1) }), cancel: false }, const paid = model("openai", "paid", { cost: cost(1) })
) catalog.model.update(item.id, paid.id, (draft) => {
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() draft.cost = [...paid.cost]
expect(paid.cancel).toBe(false) })
})
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -175,19 +200,22 @@ describe("OpencodePlugin", () => {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.opencode const providerID = ProviderV2.ID.opencode
yield* catalog.provider.update(providerID, () => {}) const load = yield* catalog.loader()
yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { yield* load((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
model.capabilities.input = ["text"] model.capabilities.input = ["text"]
model.capabilities.output = ["text"] model.capabilities.output = ["text"]
model.cost = cost(1, 1) model.cost = [...cost(1, 1)]
model.time.released = DateTime.makeUnsafe(Date.now()) model.time.released = DateTime.makeUnsafe(Date.now())
}) })
yield* catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
model.capabilities.input = ["text"] model.capabilities.input = ["text"]
model.capabilities.output = ["text"] model.capabilities.output = ["text"]
model.cost = cost(10, 10) model.cost = [...cost(10, 10)]
model.time.released = DateTime.makeUnsafe(Date.now()) model.time.released = DateTime.makeUnsafe(Date.now())
}) })
})
const selected = yield* catalog.model.small(providerID) const selected = yield* catalog.model.small(providerID)
@@ -1,8 +1,11 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, model, provider } from "./provider-helper" import { expectPluginRegistered, it, model, provider } from "./provider-helper"
describe("OpenRouterPlugin", () => { describe("OpenRouterPlugin", () => {
@@ -18,24 +21,27 @@ describe("OpenRouterPlugin", () => {
it.effect("applies legacy referer headers only to openrouter", () => it.effect("applies legacy referer headers only to openrouter", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin) yield* plugin.add(OpenRouterPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const openrouter = provider("openrouter", {
{ endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
provider: provider("openrouter", {
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}), })
cancel: false, catalog.provider.update(openrouter.id, (item) => {
}, item.endpoint = openrouter.endpoint
) item.options = openrouter.options
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("nvidia"), cancel: false }) })
expect(result.provider.options.headers).toEqual({ catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).options.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect(ignored.provider.options.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({})
}), }),
) )
@@ -67,39 +73,50 @@ describe("OpenRouterPlugin", () => {
it.effect("filters OpenRouter's gpt-5 chat alias", () => it.effect("filters OpenRouter's gpt-5 chat alias", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin) yield* plugin.add(OpenRouterPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"model.update", yield* load((catalog) => {
{}, const openrouter = provider("openrouter", {
{ model: model("openrouter", "openai/gpt-5-chat"), cancel: false }, endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
) })
const regular = yield* plugin.trigger( catalog.provider.update(openrouter.id, (item) => {
"model.update", item.endpoint = openrouter.endpoint
{}, })
{ model: model("openrouter", "openai/gpt-5"), cancel: false }, catalog.provider.update(ProviderV2.ID.openai, () => {})
) for (const item of [
const ignored = yield* plugin.trigger( model("openrouter", "openai/gpt-5-chat"),
"model.update", model("openrouter", "openai/gpt-5"),
{}, model("openai", "openai/gpt-5-chat"),
{ model: model("openai", "openai/gpt-5-chat"), cancel: false }, ]) {
) catalog.model.update(item.providerID, item.id, () => {})
}
})
expect(result.cancel).toBe(true) expect(
expect(regular.cancel).toBe(false) (yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))).enabled,
expect(ignored.cancel).toBe(false) ).toBe(false)
expect(
(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled,
).toBe(true)
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(true)
}), }),
) )
it.effect("does not filter gpt-5-chat-latest for non-OpenRouter providers", () => it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin) yield* plugin.add(OpenRouterPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"model.update", yield* load((catalog) => {
{}, catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
{ model: model("custom-openrouter", "gpt-5-chat-latest"), cancel: false }, catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
) })
expect(result.cancel).toBe(false) expect(
(yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")))
.enabled,
).toBe(true)
}), }),
) )
}) })
@@ -1,25 +1,29 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider } from "./provider-helper" import { it, model, provider } from "./provider-helper"
describe("VercelPlugin", () => { describe("VercelPlugin", () => {
it.effect("applies legacy lower-case referer headers", () => it.effect("applies legacy lower-case referer headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin) yield* plugin.add(VercelPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("vercel", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/vercel" },
provider: provider("vercel", {
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
}), })
cancel: false, catalog.provider.update(item.id, (draft) => {
}, draft.endpoint = item.endpoint
) draft.options = item.options
expect(result.provider.options.headers).toEqual({ })
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).toEqual({
Existing: "1", Existing: "1",
"http-referer": "https://opencode.ai/", "http-referer": "https://opencode.ai/",
"x-title": "opencode", "x-title": "opencode",
@@ -30,10 +34,19 @@ describe("VercelPlugin", () => {
it.effect("does not add legacy upper-case referer headers", () => it.effect("does not add legacy upper-case referer headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin) yield* plugin.add(VercelPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("vercel"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.headers).not.toHaveProperty("HTTP-Referer") yield* load((catalog) => {
expect(result.provider.options.headers).not.toHaveProperty("X-Title") const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" } })
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).not.toHaveProperty(
"HTTP-Referer",
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).not.toHaveProperty("X-Title")
}), }),
) )
@@ -54,9 +67,11 @@ describe("VercelPlugin", () => {
it.effect("ignores non-Vercel providers", () => it.effect("ignores non-Vercel providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin) yield* plugin.add(VercelPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("gateway"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.headers).toEqual({}) yield* load((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).options.headers).toEqual({})
}), }),
) )
}) })
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper" import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("ZenmuxPlugin", () => { describe("ZenmuxPlugin", () => {
@@ -18,30 +20,41 @@ describe("ZenmuxPlugin", () => {
it.effect("applies the exact legacy Zenmux headers", () => it.effect("applies the exact legacy Zenmux headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin) yield* plugin.add(ZenmuxPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("zenmux"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) yield* load((catalog) => {
expect(Object.keys(result.provider.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) const item = provider("zenmux", {
expect(result.cancel).toBe(false) endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
})
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
})
const result = yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))
expect(result.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
expect(Object.keys(result.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
}), }),
) )
it.effect("merges legacy Zenmux headers with existing headers", () => it.effect("merges legacy Zenmux headers with existing headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin) yield* plugin.add(ZenmuxPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("zenmux", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
provider: provider("zenmux", {
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}), })
cancel: false, catalog.provider.update(item.id, (draft) => {
}, draft.endpoint = item.endpoint
) draft.options = item.options
})
})
expect(result.provider.options.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).options.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
@@ -52,23 +65,25 @@ describe("ZenmuxPlugin", () => {
it.effect("lets configured Zenmux legacy headers override defaults", () => it.effect("lets configured Zenmux legacy headers override defaults", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin) yield* plugin.add(ZenmuxPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("zenmux", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
provider: provider("zenmux", {
options: { options: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
body: {}, body: {},
aisdk: { provider: {}, request: {} }, aisdk: { provider: {}, request: {} },
}, },
}), })
cancel: false, catalog.provider.update(item.id, (draft) => {
}, draft.endpoint = item.endpoint
) draft.options = item.options
})
})
expect(result.provider.options.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).options.headers).toEqual({
"HTTP-Referer": "https://example.com/", "HTTP-Referer": "https://example.com/",
"X-Title": "custom-title", "X-Title": "custom-title",
}) })
@@ -78,23 +93,23 @@ describe("ZenmuxPlugin", () => {
it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () => it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin) yield* plugin.add(ZenmuxPlugin)
const ignored = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("openrouter", {
{
provider: provider("openrouter", {
options: { options: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
body: {}, body: {},
aisdk: { provider: {}, request: {} }, aisdk: { provider: {}, request: {} },
}, },
}), })
cancel: false, catalog.provider.update(item.id, (draft) => {
}, draft.options = item.options
) })
})
expect(ignored.provider.options.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({
"HTTP-Referer": "https://example.com/", "HTTP-Referer": "https://example.com/",
"X-Title": "custom-title", "X-Title": "custom-title",
}) })
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.15.5", "version": "1.15.6",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+19
View File
@@ -0,0 +1,19 @@
# Effect Drizzle SQLite
This package vendors a Drizzle Effect SQLite adapter for this repo.
- Keep this package generic: Drizzle + Effect + SQLite only.
- Do not add opencode-specific tables, paths, migrations, post-commit hooks, or domain storage APIs here.
- Runtime code should depend on generic `effect/unstable/sql/SqlClient`, not a specific SQLite driver.
- Concrete SQLite clients such as `@effect/sql-sqlite-bun` belong in tests or examples unless this package intentionally adds a driver-specific helper.
- Preserve Drizzle adapter naming and behavior where possible so this can be replaced by upstream `drizzle-orm/effect-sqlite` later.
- If touching copied Drizzle internals, compare with current `drizzle-orm@1.0.0-rc.2` declarations and runtime JS.
- If touching Effect APIs, verify against `/Users/kit/code/open-source/effect-smol`.
Useful entry points:
- `src/effect-sqlite/driver.ts`: creates the Effect-backed Drizzle database with `make` and `makeWithDefaults`.
- `src/effect-sqlite/session.ts`: adapts generic Effect `SqlClient` execution and transactions to Drizzle SQLite sessions.
- `src/sqlite-core/effect/*`: Effect-yieldable SQLite query builders.
- `src/internal/drizzle-utils.ts`: local typed shims for Drizzle runtime internals that RC2 does not expose in declarations.
- `examples/basic.ts`: minimal usage example with Bun SQLite.
@@ -0,0 +1,92 @@
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { eq } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Schema from "effect/Schema"
import { EffectDrizzleSqlite } from "../src"
const users = sqliteTable("users", {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
})
type User = typeof users.$inferSelect
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
const sqliteLayer = SqliteClient.layer({ filename: ":memory:", disableWAL: true })
class Database extends Context.Service<Database, DatabaseShape>()("@opencode/example/Database") {
static layer = Layer.effect(Database, makeDatabase).pipe(Layer.provide(sqliteLayer))
}
class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()("UserStoreError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
const mapStoreError = (message: string) => (cause: unknown) => new UserStoreError({ message, cause })
interface UserStoreShape {
migrate(): Effect.Effect<void, UserStoreError>
create(name: string): Effect.Effect<void, UserStoreError>
rename(from: string, to: string): Effect.Effect<void, UserStoreError>
list(): Effect.Effect<User[], UserStoreError>
}
class UserStore extends Context.Service<UserStore, UserStoreShape>()("@opencode/example/UserStore") {
static layer = Layer.effect(
UserStore,
Effect.gen(function* () {
const db = yield* Database
return UserStore.of({
migrate: Effect.fn("UserStore.migrate")(function* () {
yield* EffectDrizzleSqlite.migrate(db, { migrationsFolder: `${import.meta.dirname}/migrations` }).pipe(
Effect.mapError((cause) => new UserStoreError({ message: "Failed to migrate users", cause })),
)
}),
create: Effect.fn("UserStore.create")(function* (name: string) {
yield* db
.insert(users)
.values({ name })
.pipe(Effect.asVoid, Effect.mapError(mapStoreError("Failed to create user")))
}),
rename: Effect.fn("UserStore.rename")(function* (from: string, to: string) {
yield* db
.transaction(
Effect.fnUntraced(function* (tx) {
yield* tx.insert(users).values({ name: from })
yield* tx.update(users).set({ name: to }).where(eq(users.name, from))
}),
{ behavior: "immediate" },
)
.pipe(Effect.asVoid, Effect.mapError(mapStoreError("Failed to rename user")))
}),
list: Effect.fn("UserStore.list")(function* () {
return yield* db
.select()
.from(users)
.pipe(Effect.mapError(mapStoreError("Failed to list users")))
}),
})
}),
).pipe(Layer.provide(Database.layer))
}
const program = Effect.gen(function* () {
const userStore = yield* UserStore
yield* userStore.migrate()
yield* userStore.create("Ada")
yield* userStore.rename("Grace", "Grace Hopper")
return yield* userStore.list()
})
const rows = await Effect.runPromise(program.pipe(Effect.provide(UserStore.layer)))
console.log(rows)
@@ -0,0 +1,4 @@
CREATE TABLE users (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
name text NOT NULL
);
@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.5",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.ts",
"./effect-sqlite": "./src/effect-sqlite/index.ts",
"./effect-sqlite/migrator": "./src/effect-sqlite/migrator.ts",
"./sqlite-core/effect": "./src/sqlite-core/effect/index.ts"
},
"devDependencies": {
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:"
}
}
@@ -0,0 +1,77 @@
/* oxlint-disable */
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import { SqlClient } from "effect/unstable/sql/SqlClient"
import { EffectCache } from "drizzle-orm/cache/core/cache-effect"
import { EffectLogger } from "drizzle-orm/effect-core"
import { entityKind } from "drizzle-orm/entity"
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
import { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteEffectDatabase } from "../sqlite-core/effect/db"
import type { DrizzleConfig } from "drizzle-orm/utils"
import { jitCompatCheck } from "../internal/drizzle-utils"
import { type EffectSQLiteQueryEffectHKT, type EffectSQLiteRunResult, EffectSQLiteSession } from "./session"
export class EffectSQLiteDatabase<TRelations extends AnyRelations = EmptyRelations> extends SQLiteEffectDatabase<
EffectSQLiteQueryEffectHKT,
EffectSQLiteRunResult,
TRelations
> {
static override readonly [entityKind]: string = "EffectSQLiteDatabase"
}
export type EffectDrizzleSQLiteConfig<TRelations extends AnyRelations = EmptyRelations> = Omit<
DrizzleConfig<Record<string, never>, TRelations>,
"cache" | "logger" | "schema"
>
export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Default)
/**
* Creates an EffectSQLiteDatabase instance.
*
* Requires a generic Effect `SqlClient`, `EffectLogger`, and `EffectCache` services to be provided.
* Drizzle only depends on the generic `SqlClient`; install and provide a compatible SQLite provider such as
* `@effect/sql-sqlite-node`, `@effect/sql-sqlite-bun`, or another package that exposes `SqlClient`.
*
* @example
* ```ts
* import { SqliteClient } from '@effect/sql-sqlite-node';
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
* import * as Effect from 'effect/Effect';
*
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
* Effect.provide(SQLiteDrizzle.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
* );
* ```
*/
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
config: EffectDrizzleSQLiteConfig<TRelations> = {},
) {
const client = yield* SqlClient
const cache = yield* EffectCache
const logger = yield* EffectLogger
const dialect = new SQLiteAsyncDialect()
const relations = config.relations ?? ({} as TRelations)
const session = new EffectSQLiteSession(client, dialect, relations, {
logger,
cache,
useJitMappers: jitCompatCheck(config.jit),
})
const db = new EffectSQLiteDatabase(dialect, session, relations) as EffectSQLiteDatabase<TRelations> & {
$client: SqlClient
}
db.$client = client
db.$cache.invalidate = cache.onMutate
return db
})
/**
* Convenience function that creates an EffectSQLiteDatabase with `DefaultServices` already provided.
*/
export const makeWithDefaults = <TRelations extends AnyRelations = EmptyRelations>(
config: EffectDrizzleSQLiteConfig<TRelations> = {},
) => make(config).pipe(Effect.provide(DefaultServices))
@@ -0,0 +1,4 @@
/* oxlint-disable */
export { EffectLogger } from "drizzle-orm/effect-core"
export * from "./driver"
export * from "./session"
@@ -0,0 +1,14 @@
/* oxlint-disable */
import type { MigrationConfig } from "drizzle-orm/migrator"
import { readMigrationFiles } from "drizzle-orm/migrator"
import type { AnyRelations } from "drizzle-orm/relations"
import { migrate as coreMigrate } from "../sqlite-core/effect/session"
import type { EffectSQLiteDatabase } from "./driver"
export function migrate<TRelations extends AnyRelations>(
db: EffectSQLiteDatabase<TRelations>,
config: MigrationConfig,
) {
const migrations = readMigrationFiles(config)
return coreMigrate(migrations, db.session, config)
}
@@ -0,0 +1,214 @@
/* oxlint-disable */
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as Scope from "effect/Scope"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import type { SqlError } from "effect/unstable/sql/SqlError"
import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect"
import type { WithCacheConfig } from "drizzle-orm/cache/core/types"
import type { EffectDrizzleQueryError } from "drizzle-orm/effect-core/errors"
import type { EffectLoggerShape } from "drizzle-orm/effect-core/logger"
import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import type { AnyRelations } from "drizzle-orm/relations"
import type { RelationalQueryMapperConfig } from "drizzle-orm/relations"
import type { Query } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteEffectPreparedQuery, SQLiteEffectSession, SQLiteEffectTransaction } from "../sqlite-core/effect/session"
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
export interface EffectSQLiteQueryEffectHKT extends QueryEffectHKTBase {
readonly error: EffectDrizzleQueryError
readonly context: never
}
export type EffectSQLiteRunResult = readonly never[]
export interface EffectSQLiteSessionOptions {
logger: EffectLoggerShape
cache: EffectCacheShape
useJitMappers?: boolean
}
export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLiteEffectSession<
EffectSQLiteQueryEffectHKT,
EffectSQLiteRunResult,
TRelations
> {
static override readonly [entityKind]: string = "EffectSQLiteSession"
constructor(
private client: SqlClient,
dialect: SQLiteAsyncDialect,
protected relations: TRelations,
private options: EffectSQLiteSessionOptions,
) {
super(dialect)
}
override prepareQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown,
queryMetadata?: {
type: "select" | "update" | "delete" | "insert"
tables: string[]
},
cacheConfig?: WithCacheConfig,
): SQLiteEffectPreparedQuery<T, EffectSQLiteQueryEffectHKT> {
return new SQLiteEffectPreparedQuery<T, EffectSQLiteQueryEffectHKT>(
(params, method) => this.execute(query, params, method),
query,
this.options.logger,
this.options.cache,
queryMetadata,
cacheConfig,
fields,
executeMethod,
this.options.useJitMappers,
customResultMapper,
undefined,
undefined,
this.isInTransaction(),
)
}
override prepareRelationalQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper: (rows: Record<string, unknown>[], mapColumnValue?: (value: unknown) => unknown) => unknown,
config: RelationalQueryMapperConfig,
): SQLiteEffectPreparedQuery<T, EffectSQLiteQueryEffectHKT, true> {
return new SQLiteEffectPreparedQuery<T, EffectSQLiteQueryEffectHKT, true>(
(params, method) => this.execute(query, params, method),
query,
this.options.logger,
this.options.cache,
undefined,
undefined,
fields,
executeMethod,
this.options.useJitMappers,
customResultMapper,
true,
config,
this.isInTransaction(),
)
}
private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") {
const statement = this.client.unsafe(query.sql, params)
if (method === "values") return statement.values
if (method === "get") return statement.withoutTransform.pipe(Effect.map((rows) => rows[0]))
return statement.withoutTransform
}
private isInTransaction() {
return Effect.serviceOption(this.client.transactionService).pipe(Effect.map((option) => option._tag === "Some"))
}
private executeTransactionStatement(connection: Effect.Success<SqlClient["reserve"]>, query: string) {
return connection.executeUnprepared(query, [], undefined).pipe(Effect.asVoid)
}
private withTransaction<A, E, R>(effect: Effect.Effect<A, E, R>, config: SQLiteTransactionConfig | undefined) {
return Effect.uninterruptibleMask((restore) =>
Effect.withFiber<A, E | SqlError, R>((fiber) => {
const services = fiber.context
const connectionOption = Context.getOption(services, this.client.transactionService)
const connection: Effect.Effect<
readonly [Scope.Closeable | undefined, Effect.Success<SqlClient["reserve"]>],
SqlError
> =
connectionOption._tag === "Some"
? Effect.succeed([undefined, connectionOption.value[0]] as const)
: Scope.make().pipe(
Effect.flatMap((scope) =>
Scope.provide(this.client.reserve, scope).pipe(
Effect.map((connection) => [scope, connection] as const),
Effect.catch((error) =>
Scope.close(scope, Exit.fail(error)).pipe(Effect.andThen(Effect.fail(error))),
),
),
),
)
const id = connectionOption._tag === "Some" ? connectionOption.value[1] + 1 : 0
return connection.pipe(
Effect.flatMap(([scope, connection]) =>
this.executeTransactionStatement(
connection,
id === 0 ? `begin ${config?.behavior ?? "deferred"}` : `savepoint effect_sql_${id}`,
).pipe(
Effect.flatMap(() =>
Effect.provideContext(
restore(effect),
Context.add(services, this.client.transactionService, [connection, id]),
),
),
Effect.exit,
Effect.flatMap((exit) => {
const finalize = Exit.isSuccess(exit)
? id === 0
? this.executeTransactionStatement(connection, "commit").pipe(
// SQLite keeps the transaction open after deferred constraint commit failures.
Effect.catch((error) =>
this.executeTransactionStatement(connection, "rollback").pipe(
Effect.catch(() => Effect.void),
Effect.andThen(Effect.fail(error)),
),
),
)
: this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`)
: id === 0
? this.executeTransactionStatement(connection, "rollback")
: this.executeTransactionStatement(connection, `rollback to savepoint effect_sql_${id}`).pipe(
Effect.andThen(
this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`),
),
)
const scoped = scope === undefined ? finalize : Effect.ensuring(finalize, Scope.close(scope, exit))
return scoped.pipe(Effect.flatMap(() => exit))
}),
),
),
)
}),
)
}
override transaction<A, E, R>(
transaction: (tx: EffectSQLiteTransaction<TRelations>) => Effect.Effect<A, E, R>,
config?: SQLiteTransactionConfig,
): Effect.Effect<A, E | SqlError, R> {
const { dialect, relations } = this
return this.withTransaction(
Effect.gen({ self: this }, function* () {
const tx = new EffectSQLiteTransaction<TRelations>(dialect, this, relations)
return yield* transaction(tx)
}),
config,
)
}
}
export class EffectSQLiteTransaction<TRelations extends AnyRelations> extends SQLiteEffectTransaction<
EffectSQLiteQueryEffectHKT,
EffectSQLiteRunResult,
TRelations
> {
static override readonly [entityKind]: string = "EffectSQLiteTransaction"
override transaction: <A, E, R>(
transaction: (
tx: SQLiteEffectTransaction<EffectSQLiteQueryEffectHKT, EffectSQLiteRunResult, TRelations>,
) => Effect.Effect<A, E, R>,
) => Effect.Effect<A, SqlError | E, R> = (tx) => this.session.transaction(tx)
}
@@ -0,0 +1,6 @@
export { EffectLogger } from "drizzle-orm/effect-core"
export * from "./effect-sqlite/driver"
export * from "./effect-sqlite/session"
export { migrate } from "./effect-sqlite/migrator"
export * as EffectDrizzleSqlite from "."
@@ -0,0 +1,127 @@
/* oxlint-disable */
import { Column, getColumnTable } from "drizzle-orm/column"
import { is } from "drizzle-orm/entity"
import type { JoinNullability } from "drizzle-orm/query-builders/select.types"
import { Param, SQL } from "drizzle-orm/sql/sql"
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core/query-builders/update"
import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base"
import { Subquery } from "drizzle-orm/subquery"
import { Table, getTableName } from "drizzle-orm/table"
import type { UpdateSet } from "drizzle-orm/utils"
import { ViewBaseConfig } from "drizzle-orm/view-common"
const TableSymbol = (
Table as unknown as {
Symbol: { Columns: symbol; IsAlias: symbol; Name: symbol; BaseName: symbol }
}
).Symbol
export function getTableColumnsRuntime(table: SQLiteTable) {
return (table as unknown as Record<symbol, Record<string, Column>>)[TableSymbol.Columns]
}
export function getViewSelectedFieldsRuntime(view: SQLiteViewBase) {
return (view as unknown as Record<symbol, { selectedFields: Record<string, unknown>; name: string }>)[ViewBaseConfig]
}
export function jitCompatCheck(isEnabled: boolean | undefined) {
if (!isEnabled) return false
try {
return new Function("input", '"use strict"; return input;')(true) === true
} catch {
return false
}
}
export function orderSelectedFields<TColumn extends Column>(
fields: Record<string, unknown>,
pathPrefix?: string[],
): SelectedFieldsOrdered {
return Object.entries(fields).flatMap(([name, field]) => {
const path = pathPrefix ? [...pathPrefix, name] : [name]
if (is(field, Column) || is(field, SQL) || is(field, SQL.Aliased) || is(field, Subquery)) {
return [{ path, field }] as SelectedFieldsOrdered
}
if (is(field, Table)) return orderSelectedFields(getTableColumnsRuntime(field as SQLiteTable), path)
return orderSelectedFields(field as Record<string, unknown>, path)
}) as SelectedFieldsOrdered
}
export function mapUpdateSet<TTable extends SQLiteTable>(table: TTable, values: SQLiteUpdateSetSource<TTable>) {
const entries = Object.entries(values).filter(([, value]) => value !== undefined)
if (entries.length === 0) throw new Error("No values to set")
return Object.fromEntries(
entries.map(([key, value]) => [
key,
is(value, SQL) || is(value, Column) ? value : new Param(value, getTableColumnsRuntime(table)[key]),
]),
) as UpdateSet
}
export function mapResultRow(
columns: SelectedFieldsOrdered,
row: unknown[],
joinsNotNullableMap: Record<string, boolean> | undefined,
) {
const nullifyMap: Record<string, string | false> = {}
const result: Record<string, unknown> = {}
columns.forEach((column, columnIndex) => {
const decoder = (
is(column.field, Column)
? column.field
: is(column.field, SQL)
? (column.field as unknown as { decoder: { mapFromDriverValue(value: unknown): unknown } }).decoder
: is(column.field, Subquery)
? (column.field._.sql as unknown as { decoder: { mapFromDriverValue(value: unknown): unknown } }).decoder
: (column.field.sql as unknown as { decoder: { mapFromDriverValue(value: unknown): unknown } }).decoder
) as {
mapFromDriverValue(value: unknown): unknown
}
const rawValue = row[columnIndex]
const value = rawValue === null ? null : decoder.mapFromDriverValue(rawValue)
const objectName = column.path[0]
let node = result
column.path.forEach((pathChunk, pathChunkIndex) => {
if (pathChunkIndex === column.path.length - 1) {
node[pathChunk] = value
return
}
node[pathChunk] = (node[pathChunk] ?? {}) as Record<string, unknown>
node = node[pathChunk] as Record<string, unknown>
})
if (joinsNotNullableMap && is(column.field, Column) && column.path.length === 2 && objectName) {
const tableName = getTableName(getColumnTable(column.field))
nullifyMap[objectName] =
!(objectName in nullifyMap) && value === null
? tableName
: typeof nullifyMap[objectName] === "string" && nullifyMap[objectName] !== tableName
? false
: nullifyMap[objectName]
}
})
Object.entries(nullifyMap).forEach(([objectName, tableName]) => {
if (typeof tableName === "string" && !joinsNotNullableMap?.[tableName]) result[objectName] = null
})
return result
}
export function getTableLikeName(table: SQLiteTable | Subquery | SQLiteViewBase | SQL) {
if (is(table, Subquery)) return table._.alias
if (is(table, SQLiteViewBase)) return getViewSelectedFieldsRuntime(table).name
if (is(table, SQL)) return undefined
return (table as unknown as Record<symbol, string | boolean>)[
(table as unknown as Record<symbol, string | boolean>)[TableSymbol.IsAlias]
? TableSymbol.Name
: TableSymbol.BaseName
] as string
}
export type { JoinNullability }
@@ -0,0 +1,58 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import { SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import type { SQLiteView } from "drizzle-orm/sqlite-core/view"
import type { SQLiteEffectSession } from "./session"
function buildSQLiteEmbeddedCount(source: SQLiteTable | SQLiteView | SQL | SQLWrapper, filters?: SQL<unknown>) {
return sql<number>`(select count(*) from ${source}${sql.raw(" where ").if(filters)}${filters})`
}
function buildSQLiteCount(source: SQLiteTable | SQLiteView | SQL | SQLWrapper, filters?: SQL<unknown>) {
return sql<number>`select count(*) from ${source}${sql.raw(" where ").if(filters)}${filters}`
}
export interface SQLiteEffectCountBuilder<TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
extends SQL<number>,
SQLWrapper<number>,
Effect.Effect<number, TEffectHKT["error"], TEffectHKT["context"]> {}
export class SQLiteEffectCountBuilder<TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase> extends SQL<number> {
static override readonly [entityKind]: string = "SQLiteEffectCountBuilder"
private sql: SQL<number>
private session: SQLiteEffectSession<TEffectHKT, any, any>
constructor(params: {
source: SQLiteTable | SQLiteView | SQL | SQLWrapper
filters?: SQL<unknown>
session: SQLiteEffectSession<TEffectHKT, any, any>
}) {
super(buildSQLiteEmbeddedCount(params.source, params.filters).queryChunks)
this.session = params.session
this.sql = buildSQLiteCount(params.source, params.filters)
}
execute(placeholderValues?: Record<string, unknown>) {
return this.session
.prepareQuery<{
type: "async"
execute: number
run: unknown
all: unknown
get: unknown
values: unknown
}>(this.session.dialect.sqlToQuery(this.sql), undefined, "all", (rows) => {
const v = rows[0]?.[0]
if (typeof v === "number") return v
return v ? Number(v) : 0
})
.execute(placeholderValues)
}
}
applyEffectWrapper(SQLiteEffectCountBuilder)
@@ -0,0 +1,296 @@
/* oxlint-disable */
import { Effect } from "effect"
import type { SqlError } from "effect/unstable/sql/SqlError"
import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect"
import type { MutationOption } from "drizzle-orm/cache/core/cache"
import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import type { TypedQueryBuilder } from "drizzle-orm/query-builders/query-builder"
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
import { SelectionProxyHandler } from "drizzle-orm/selection-proxy"
import { type ColumnsSelection, type SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import { QueryBuilder } from "drizzle-orm/sqlite-core/query-builders/query-builder"
import type { SelectedFields } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import type { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base"
import { WithSubquery } from "drizzle-orm/subquery"
import type { WithBuilder } from "drizzle-orm/sqlite-core/subquery"
import { SQLiteEffectCountBuilder } from "./count"
import { SQLiteEffectDeleteBase } from "./delete"
import { SQLiteEffectInsertBuilder } from "./insert"
import { SQLiteEffectRelationalQueryBuilder } from "./query"
import { SQLiteEffectRaw } from "./raw"
import { SQLiteEffectSelectBuilder } from "./select"
import type { SQLiteEffectSelectBase } from "./select"
import type { SQLiteEffectSession, SQLiteEffectTransaction } from "./session"
import { SQLiteEffectUpdateBuilder } from "./update"
export class SQLiteEffectDatabase<
TEffectHKT extends QueryEffectHKTBase,
TRunResult,
TRelations extends AnyRelations = EmptyRelations,
> {
static readonly [entityKind]: string = "SQLiteEffectDatabase"
declare readonly _: {
readonly relations: TRelations
readonly session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>
}
query: {
[K in keyof TRelations]: SQLiteEffectRelationalQueryBuilder<TRelations, TRelations[K], TEffectHKT>
}
constructor(
/** @internal */
readonly dialect: SQLiteAsyncDialect,
/** @internal */
readonly session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>,
relations: TRelations,
readonly rowModeRQB?: boolean,
readonly forbidJsonb?: boolean,
) {
this._ = {
relations,
session,
}
this.query = {} as (typeof this)["query"]
for (const [tableName, relation] of Object.entries(relations)) {
;(this.query as SQLiteEffectDatabase<TEffectHKT, TRunResult, AnyRelations>["query"])[tableName] =
new SQLiteEffectRelationalQueryBuilder(
relations,
relations[relation.name]!.table as SQLiteTable,
relation,
dialect,
session,
rowModeRQB,
forbidJsonb,
)
}
this.$cache = {
invalidate: (_params: MutationOption) => Effect.void,
}
}
$with: WithBuilder = (alias: string, selection?: ColumnsSelection) => {
const self = this
const as = (
qb:
| TypedQueryBuilder<ColumnsSelection | undefined>
| SQL
| ((qb: QueryBuilder) => TypedQueryBuilder<ColumnsSelection | undefined> | SQL),
) => {
if (typeof qb === "function") {
qb = qb(new QueryBuilder(self.dialect))
}
return new Proxy(
new WithSubquery(
qb.getSQL(),
selection ??
(("getSelectedFields" in qb
? ((qb as { getSelectedFields(): SelectedFields | undefined }).getSelectedFields() ?? {})
: {}) as SelectedFields),
alias,
true,
),
new SelectionProxyHandler({ alias, sqlAliasedBehavior: "alias", sqlBehavior: "error" }),
)
}
return { as }
}
$cache: { invalidate: EffectCacheShape["onMutate"] }
$count(source: SQLiteTable | SQLiteViewBase | SQL | SQLWrapper, filters?: SQL<unknown>) {
return new SQLiteEffectCountBuilder({ source, filters, session: this.session })
}
with(...queries: WithSubquery[]) {
const self = this
function select(): SQLiteEffectSelectBuilder<undefined, TRunResult, TEffectHKT>
function select<TSelection extends SelectedFields>(
fields: TSelection,
): SQLiteEffectSelectBuilder<TSelection, TRunResult, TEffectHKT>
function select(
fields?: SelectedFields,
): SQLiteEffectSelectBuilder<SelectedFields | undefined, TRunResult, TEffectHKT> {
return new SQLiteEffectSelectBuilder({
fields: fields ?? undefined,
session: self.session,
dialect: self.dialect,
withList: queries,
})
}
function selectDistinct(): SQLiteEffectSelectBuilder<undefined, TRunResult, TEffectHKT>
function selectDistinct<TSelection extends SelectedFields>(
fields: TSelection,
): SQLiteEffectSelectBuilder<TSelection, TRunResult, TEffectHKT>
function selectDistinct(
fields?: SelectedFields,
): SQLiteEffectSelectBuilder<SelectedFields | undefined, TRunResult, TEffectHKT> {
return new SQLiteEffectSelectBuilder({
fields: fields ?? undefined,
session: self.session,
dialect: self.dialect,
withList: queries,
distinct: true,
})
}
function update<TTable extends SQLiteTable>(
table: TTable,
): SQLiteEffectUpdateBuilder<TTable, TRunResult, TEffectHKT> {
return new SQLiteEffectUpdateBuilder(table, self.session, self.dialect, queries)
}
function insert<TTable extends SQLiteTable>(
into: TTable,
): SQLiteEffectInsertBuilder<TTable, TRunResult, TEffectHKT> {
return new SQLiteEffectInsertBuilder(into, self.session, self.dialect, queries)
}
function delete_<TTable extends SQLiteTable>(
from: TTable,
): SQLiteEffectDeleteBase<TTable, TRunResult, undefined, false, never, TEffectHKT> {
return new SQLiteEffectDeleteBase(from, self.session, self.dialect, queries)
}
return { select, selectDistinct, update, insert, delete: delete_ }
}
select(): SQLiteEffectSelectBuilder<undefined, TRunResult, TEffectHKT>
select<TSelection extends SelectedFields>(
fields: TSelection,
): SQLiteEffectSelectBuilder<TSelection, TRunResult, TEffectHKT>
select(fields?: SelectedFields): SQLiteEffectSelectBuilder<SelectedFields | undefined, TRunResult, TEffectHKT> {
return new SQLiteEffectSelectBuilder({ fields: fields ?? undefined, session: this.session, dialect: this.dialect })
}
selectDistinct(): SQLiteEffectSelectBuilder<undefined, TRunResult, TEffectHKT>
selectDistinct<TSelection extends SelectedFields>(
fields: TSelection,
): SQLiteEffectSelectBuilder<TSelection, TRunResult, TEffectHKT>
selectDistinct(
fields?: SelectedFields,
): SQLiteEffectSelectBuilder<SelectedFields | undefined, TRunResult, TEffectHKT> {
return new SQLiteEffectSelectBuilder({
fields: fields ?? undefined,
session: this.session,
dialect: this.dialect,
distinct: true,
})
}
update<TTable extends SQLiteTable>(table: TTable): SQLiteEffectUpdateBuilder<TTable, TRunResult, TEffectHKT> {
return new SQLiteEffectUpdateBuilder(table, this.session, this.dialect)
}
insert<TTable extends SQLiteTable>(into: TTable): SQLiteEffectInsertBuilder<TTable, TRunResult, TEffectHKT> {
return new SQLiteEffectInsertBuilder(into, this.session, this.dialect)
}
delete<TTable extends SQLiteTable>(
from: TTable,
): SQLiteEffectDeleteBase<TTable, TRunResult, undefined, false, never, TEffectHKT> {
return new SQLiteEffectDeleteBase(from, this.session, this.dialect)
}
private raw<TResult>(
query: SQLWrapper | string,
action: "all" | "get" | "run" | "values",
execute: (query: SQL) => Effect.Effect<TResult, TEffectHKT["error"], TEffectHKT["context"]>,
): SQLiteEffectRaw<TResult, TEffectHKT> {
const sequel = typeof query === "string" ? sql.raw(query) : query.getSQL()
return new SQLiteEffectRaw(
() => execute(sequel),
() => sequel,
action,
this.dialect,
(result) => result,
)
}
run(query: SQLWrapper | string): SQLiteEffectRaw<TRunResult, TEffectHKT> {
return this.raw(query, "run", (sequel) => this.session.run(sequel))
}
all<T = unknown>(query: SQLWrapper | string): SQLiteEffectRaw<T[], TEffectHKT> {
return this.raw(query, "all", (sequel) => this.session.all(sequel))
}
get<T = unknown>(query: SQLWrapper | string): SQLiteEffectRaw<T | undefined, TEffectHKT> {
return this.raw(query, "get", (sequel) => this.session.get(sequel))
}
values<T extends unknown[] = unknown[]>(query: SQLWrapper | string): SQLiteEffectRaw<T[], TEffectHKT> {
return this.raw(query, "values", (sequel) => this.session.values(sequel))
}
transaction: <A, E, R>(
transaction: (tx: SQLiteEffectTransaction<TEffectHKT, TRunResult, TRelations>) => Effect.Effect<A, E, R>,
config?: SQLiteTransactionConfig,
) => Effect.Effect<A, E | SqlError, R> = (tx, config) => this.session.transaction(tx, config)
}
export type SQLiteEffectWithReplicas<Q> = Q & { $primary: Q; $replicas: Q[] }
export const withReplicas = <
TEffectHKT extends QueryEffectHKTBase,
TRunResult,
TRelations extends AnyRelations,
Q extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations>,
>(
primary: Q,
replicas: [Q, ...Q[]],
getReplica: (replicas: Q[]) => Q = () => replicas[Math.floor(Math.random() * replicas.length)]!,
): SQLiteEffectWithReplicas<Q> => {
const select: Q["select"] = (...args: []) => getReplica(replicas).select(...args)
const selectDistinct: Q["selectDistinct"] = (...args: []) => getReplica(replicas).selectDistinct(...args)
const $count: Q["$count"] = (...args: [any]) => getReplica(replicas).$count(...args)
const _with: Q["with"] = (...args: []) => getReplica(replicas).with(...args)
const $with = ((...args: [string] | [string, ColumnsSelection]) =>
args.length === 1
? getReplica(replicas).$with(args[0])
: getReplica(replicas).$with(args[0], args[1])) as Q["$with"]
const update: Q["update"] = (...args: [any]) => primary.update(...args)
const insert: Q["insert"] = (...args: [any]) => primary.insert(...args)
const $delete: Q["delete"] = (...args: [any]) => primary.delete(...args)
const run: Q["run"] = (...args: [any]) => primary.run(...args)
const all: Q["all"] = (...args: [any]) => primary.all(...args)
const get: Q["get"] = (...args: [any]) => primary.get(...args)
const values: Q["values"] = (...args: [any]) => primary.values(...args)
const transaction: Q["transaction"] = (...args: [any]) => primary.transaction(...args)
return {
...primary,
update,
insert,
delete: $delete,
run,
all,
get,
values,
transaction,
$primary: primary,
$replicas: replicas,
select,
selectDistinct,
$count,
$with,
with: _with,
get query() {
return getReplica(replicas).query
},
}
}
export type AnySQLiteEffectDatabase = SQLiteEffectDatabase<any, any, any>
export type AnySQLiteEffectSelectBase = SQLiteEffectSelectBase<any, any, any, any, any, any, any, any, any, any>
@@ -0,0 +1,261 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import type { SelectResultFields } from "drizzle-orm/query-builders/select.types"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import { SelectionProxyHandler } from "drizzle-orm/selection-proxy"
import type { Placeholder, Query, SQL, SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteDeleteConfig } from "drizzle-orm/sqlite-core/query-builders/delete"
import type { SelectedFieldsFlat } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig } from "drizzle-orm/sqlite-core/session"
import { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import { extractUsedTable } from "drizzle-orm/sqlite-core/utils"
import type { Subquery } from "drizzle-orm/subquery"
import { type DrizzleTypeError, type ValueOrArray } from "drizzle-orm/utils"
import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns/common"
import { getTableColumnsRuntime, orderSelectedFields } from "../../internal/drizzle-utils"
import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session"
export type SQLiteEffectDeleteWithout<
T extends AnySQLiteEffectDelete,
TDynamic extends boolean,
K extends keyof T & string,
> = TDynamic extends true
? T
: Omit<
SQLiteEffectDeleteBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["returning"],
TDynamic,
T["_"]["excludedMethods"] | K,
T["_"]["effectHKT"]
>,
T["_"]["excludedMethods"] | K
>
export type SQLiteEffectDeleteReturningAll<
T extends AnySQLiteEffectDelete,
TDynamic extends boolean,
> = SQLiteEffectDeleteWithout<
SQLiteEffectDeleteBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["table"]["$inferSelect"],
T["_"]["dynamic"],
T["_"]["excludedMethods"],
T["_"]["effectHKT"]
>,
TDynamic,
"returning"
>
export type SQLiteEffectDeleteReturning<
T extends AnySQLiteEffectDelete,
TDynamic extends boolean,
TSelectedFields extends SelectedFieldsFlat,
> = SQLiteEffectDeleteWithout<
SQLiteEffectDeleteBase<
T["_"]["table"],
T["_"]["runResult"],
SelectResultFields<TSelectedFields>,
T["_"]["dynamic"],
T["_"]["excludedMethods"],
T["_"]["effectHKT"]
>,
TDynamic,
"returning"
>
export type SQLiteEffectDeleteExecute<T extends AnySQLiteEffectDelete> = T["_"]["returning"] extends undefined
? T["_"]["runResult"]
: T["_"]["returning"][]
export type SQLiteEffectDeletePrepare<
T extends AnySQLiteEffectDelete,
TEffectHKT extends QueryEffectHKTBase = T["_"]["effectHKT"],
> = SQLiteEffectPreparedQuery<
PreparedQueryConfig & {
run: T["_"]["runResult"]
all: T["_"]["returning"] extends undefined
? DrizzleTypeError<".all() cannot be used without .returning()">
: T["_"]["returning"][]
get: T["_"]["returning"] extends undefined
? DrizzleTypeError<".get() cannot be used without .returning()">
: T["_"]["returning"] | undefined
values: T["_"]["returning"] extends undefined
? DrizzleTypeError<".values() cannot be used without .returning()">
: any[][]
execute: SQLiteEffectDeleteExecute<T>
},
TEffectHKT
>
export type SQLiteEffectDeleteDynamic<T extends AnySQLiteEffectDelete> = SQLiteEffectDelete<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["returning"],
T["_"]["effectHKT"]
>
export type SQLiteEffectDelete<
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TReturning extends Record<string, unknown> | undefined = undefined,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> = SQLiteEffectDeleteBase<TTable, TRunResult, TReturning, true, never, TEffectHKT>
export type AnySQLiteEffectDelete = SQLiteEffectDeleteBase<any, any, any, any, any, any>
export interface SQLiteEffectDeleteBase<
TTable extends SQLiteTable,
TRunResult,
TReturning extends Record<string, unknown> | undefined = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">,
SQLWrapper,
Effect.Effect<
TReturning extends undefined ? TRunResult : TReturning[],
TEffectHKT["error"],
TEffectHKT["context"]
> {
readonly _: {
dialect: "sqlite"
readonly table: TTable
readonly resultType: "async"
readonly runResult: TRunResult
readonly returning: TReturning
readonly dynamic: TDynamic
readonly excludedMethods: _TExcludedMethods
readonly result: TReturning extends undefined ? TRunResult : TReturning[]
readonly effectHKT: TEffectHKT
}
}
export class SQLiteEffectDeleteBase<
TTable extends SQLiteTable,
TRunResult,
TReturning extends Record<string, unknown> | undefined = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
implements RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">, SQLWrapper
{
static readonly [entityKind]: string = "SQLiteEffectDelete"
/** @internal */
config: SQLiteDeleteConfig
constructor(
private table: TTable,
private effectSession: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
private effectDialect: SQLiteDialect,
withList?: Subquery[],
) {
this.config = { table, withList }
}
where(where: SQL | undefined): SQLiteEffectDeleteWithout<this, TDynamic, "where"> {
this.config.where = where
return this as any
}
orderBy(
builder: (deleteTable: TTable) => ValueOrArray<SQLiteColumn | SQL | SQL.Aliased>,
): SQLiteEffectDeleteWithout<this, TDynamic, "orderBy">
orderBy(...columns: (SQLiteColumn | SQL | SQL.Aliased)[]): SQLiteEffectDeleteWithout<this, TDynamic, "orderBy">
orderBy(
...columns:
| [(deleteTable: TTable) => ValueOrArray<SQLiteColumn | SQL | SQL.Aliased>]
| (SQLiteColumn | SQL | SQL.Aliased)[]
): SQLiteEffectDeleteWithout<this, TDynamic, "orderBy"> {
if (typeof columns[0] === "function") {
const orderBy = columns[0](
new Proxy(
getTableColumnsRuntime(this.config.table),
new SelectionProxyHandler({ sqlAliasedBehavior: "alias", sqlBehavior: "sql" }),
) as any,
)
this.config.orderBy = Array.isArray(orderBy) ? orderBy : [orderBy]
return this as any
}
this.config.orderBy = columns as (SQLiteColumn | SQL | SQL.Aliased)[]
return this as any
}
limit(limit: number | Placeholder): SQLiteEffectDeleteWithout<this, TDynamic, "limit"> {
this.config.limit = limit
return this as any
}
returning(): SQLiteEffectDeleteReturningAll<this, TDynamic>
returning<TSelectedFields extends SelectedFieldsFlat>(
fields: TSelectedFields,
): SQLiteEffectDeleteReturning<this, TDynamic, TSelectedFields>
returning(
fields: SelectedFieldsFlat = getTableColumnsRuntime(this.table),
): SQLiteEffectDeleteReturning<this, TDynamic, any> | SQLiteEffectDeleteReturningAll<this, TDynamic> {
this.config.returning = orderSelectedFields<SQLiteColumn>(fields)
return this as any
}
/** @internal */
getSQL(): SQL {
return this.effectDialect.buildDeleteQuery(this.config)
}
toSQL(): Query {
return this.effectDialect.sqlToQuery(this.getSQL())
}
/** @internal */
_prepare(isOneTimeQuery = true): SQLiteEffectDeletePrepare<this, TEffectHKT> {
return this.effectSession[isOneTimeQuery ? "prepareOneTimeQuery" : "prepareQuery"](
this.effectDialect.sqlToQuery(this.getSQL()),
this.config.returning,
this.config.returning ? "all" : "run",
undefined,
{
type: "delete",
tables: extractUsedTable(this.config.table),
},
) as SQLiteEffectDeletePrepare<this, TEffectHKT>
}
prepare(): SQLiteEffectDeletePrepare<this, TEffectHKT> {
return this._prepare(false)
}
run: ReturnType<this["prepare"]>["run"] = (placeholderValues) => {
return this._prepare().run(placeholderValues)
}
all: ReturnType<this["prepare"]>["all"] = (placeholderValues) => {
return this._prepare().all(placeholderValues)
}
get: ReturnType<this["prepare"]>["get"] = (placeholderValues) => {
return this._prepare().get(placeholderValues)
}
values: ReturnType<this["prepare"]>["values"] = (placeholderValues) => {
return this._prepare().values(placeholderValues)
}
execute: ReturnType<this["prepare"]>["execute"] = (placeholderValues) => {
return this._prepare().execute(placeholderValues)
}
$dynamic(): SQLiteEffectDeleteDynamic<this> {
return this as any
}
}
applyEffectWrapper(SQLiteEffectDeleteBase)
@@ -0,0 +1,10 @@
/* oxlint-disable */
export * from "./count"
export * from "./db"
export * from "./delete"
export * from "./insert"
export * from "./query"
export * from "./raw"
export * from "./select"
export * from "./session"
export * from "./update"
@@ -0,0 +1,349 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind, is } from "drizzle-orm/entity"
import type { SelectResultFields } from "drizzle-orm/query-builders/select.types"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import type { Query, SQLWrapper } from "drizzle-orm/sql/sql"
import { Param, SQL, sql } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { IndexColumn } from "drizzle-orm/sqlite-core/indexes"
import type {
SQLiteInsertConfig,
SQLiteInsertSelectQueryBuilder,
SQLiteInsertValue,
} from "drizzle-orm/sqlite-core/query-builders/insert"
import type { SelectedFieldsFlat } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig } from "drizzle-orm/sqlite-core/session"
import { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import { extractUsedTable } from "drizzle-orm/sqlite-core/utils"
import type { Subquery } from "drizzle-orm/subquery"
import { type DrizzleTypeError, haveSameKeys } from "drizzle-orm/utils"
import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns/common"
import { QueryBuilder } from "drizzle-orm/sqlite-core/query-builders/query-builder"
import type { SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core/query-builders/update"
import { getTableColumnsRuntime, mapUpdateSet, orderSelectedFields } from "../../internal/drizzle-utils"
import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session"
export type SQLiteEffectInsertWithout<
T extends AnySQLiteEffectInsert,
TDynamic extends boolean,
K extends keyof T & string,
> = TDynamic extends true
? T
: Omit<
SQLiteEffectInsertBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["returning"],
TDynamic,
T["_"]["excludedMethods"] | K,
T["_"]["effectHKT"]
>,
T["_"]["excludedMethods"] | K
>
export type SQLiteEffectInsertReturning<
T extends AnySQLiteEffectInsert,
TDynamic extends boolean,
TSelectedFields extends SelectedFieldsFlat,
> = SQLiteEffectInsertWithout<
SQLiteEffectInsertBase<
T["_"]["table"],
T["_"]["runResult"],
SelectResultFields<TSelectedFields>,
TDynamic,
T["_"]["excludedMethods"],
T["_"]["effectHKT"]
>,
TDynamic,
"returning"
>
export type SQLiteEffectInsertReturningAll<
T extends AnySQLiteEffectInsert,
TDynamic extends boolean,
> = SQLiteEffectInsertWithout<
SQLiteEffectInsertBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["table"]["$inferSelect"],
TDynamic,
T["_"]["excludedMethods"],
T["_"]["effectHKT"]
>,
TDynamic,
"returning"
>
export type SQLiteEffectInsertDynamic<T extends AnySQLiteEffectInsert> = SQLiteEffectInsert<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["returning"],
T["_"]["effectHKT"]
>
export type SQLiteEffectInsertOnConflictDoUpdateConfig<T extends AnySQLiteEffectInsert> = {
target: IndexColumn | IndexColumn[]
/** @deprecated - use either `targetWhere` or `setWhere` */
where?: SQL
targetWhere?: SQL
setWhere?: SQL
set: SQLiteUpdateSetSource<T["_"]["table"]>
}
export type SQLiteEffectInsertExecute<T extends AnySQLiteEffectInsert> = T["_"]["returning"] extends undefined
? T["_"]["runResult"]
: T["_"]["returning"][]
export type SQLiteEffectInsertPrepare<
T extends AnySQLiteEffectInsert,
TEffectHKT extends QueryEffectHKTBase = T["_"]["effectHKT"],
> = SQLiteEffectPreparedQuery<
PreparedQueryConfig & {
run: T["_"]["runResult"]
all: T["_"]["returning"] extends undefined
? DrizzleTypeError<".all() cannot be used without .returning()">
: T["_"]["returning"][]
get: T["_"]["returning"] extends undefined
? DrizzleTypeError<".get() cannot be used without .returning()">
: T["_"]["returning"]
values: T["_"]["returning"] extends undefined
? DrizzleTypeError<".values() cannot be used without .returning()">
: any[][]
execute: SQLiteEffectInsertExecute<T>
},
TEffectHKT
>
export type SQLiteEffectInsert<
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TReturning = any,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> = SQLiteEffectInsertBase<TTable, TRunResult, TReturning, true, never, TEffectHKT>
export type AnySQLiteEffectInsert = SQLiteEffectInsertBase<any, any, any, any, any, any>
export class SQLiteEffectInsertBuilder<
TTable extends SQLiteTable,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> {
static readonly [entityKind]: string = "SQLiteEffectInsertBuilder"
constructor(
protected table: TTable,
protected session: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
protected dialect: SQLiteDialect,
private withList?: Subquery[],
) {}
values(
value: SQLiteInsertValue<TTable>,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
values(
values: SQLiteInsertValue<TTable>[],
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
values(
values: SQLiteInsertValue<TTable> | SQLiteInsertValue<TTable>[],
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT> {
values = Array.isArray(values) ? values : [values]
if (values.length === 0) {
throw new Error("values() must be called with at least one value")
}
const mappedValues = values.map((entry) => {
const result: Record<string, Param | SQL> = {}
const cols = getTableColumnsRuntime(this.table)
for (const colKey of Object.keys(entry)) {
const colValue = entry[colKey as keyof typeof entry]
result[colKey] = is(colValue, SQL) ? colValue : new Param(colValue, cols[colKey])
}
return result
})
return new SQLiteEffectInsertBase(this.table, mappedValues, this.session, this.dialect, this.withList)
}
select(
selectQuery: (qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder<TTable>,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery: (qb: QueryBuilder) => SQL,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(selectQuery: SQL): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery: SQLiteInsertSelectQueryBuilder<TTable>,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery:
| SQL
| SQLiteInsertSelectQueryBuilder<TTable>
| ((qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder<TTable> | SQL),
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT> {
const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery
if (!is(select, SQL) && !haveSameKeys(getTableColumnsRuntime(this.table), select._.selectedFields)) {
throw new Error(
"Insert select error: selected fields are not the same or are in a different order compared to the table definition",
)
}
return new SQLiteEffectInsertBase(this.table, select, this.session, this.dialect, this.withList, true)
}
}
export interface SQLiteEffectInsertBase<
TTable extends SQLiteTable,
TRunResult,
TReturning = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLWrapper,
RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">,
Effect.Effect<
TReturning extends undefined ? TRunResult : TReturning[],
TEffectHKT["error"],
TEffectHKT["context"]
> {
readonly _: {
readonly dialect: "sqlite"
readonly table: TTable
readonly resultType: "async"
readonly runResult: TRunResult
readonly returning: TReturning
readonly dynamic: TDynamic
readonly excludedMethods: _TExcludedMethods
readonly result: TReturning extends undefined ? TRunResult : TReturning[]
readonly effectHKT: TEffectHKT
}
}
export class SQLiteEffectInsertBase<
TTable extends SQLiteTable,
TRunResult,
TReturning = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
implements RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">, SQLWrapper
{
static readonly [entityKind]: string = "SQLiteEffectInsert"
/** @internal */
config: SQLiteInsertConfig<TTable>
constructor(
private table: TTable,
values: SQLiteInsertConfig["values"],
private effectSession: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
private effectDialect: SQLiteDialect,
withList?: Subquery[],
select?: boolean,
) {
this.config = { table, values: values as any, withList, select }
}
returning(): SQLiteEffectInsertReturningAll<this, TDynamic>
returning<TSelectedFields extends SelectedFieldsFlat>(
fields: TSelectedFields,
): SQLiteEffectInsertReturning<this, TDynamic, TSelectedFields>
returning(
fields: SelectedFieldsFlat = getTableColumnsRuntime(this.config.table),
): SQLiteEffectInsertWithout<AnySQLiteEffectInsert, TDynamic, "returning"> {
this.config.returning = orderSelectedFields<SQLiteColumn>(fields)
return this as any
}
onConflictDoNothing(config: { target?: IndexColumn | IndexColumn[]; where?: SQL } = {}): this {
if (!this.config.onConflict) this.config.onConflict = []
if (config.target === undefined) {
this.config.onConflict.push(sql` on conflict do nothing`)
return this
}
const targetSql = Array.isArray(config.target) ? sql`${config.target}` : sql`${[config.target]}`
const whereSql = config.where ? sql` where ${config.where}` : sql``
this.config.onConflict.push(sql` on conflict ${targetSql} do nothing${whereSql}`)
return this
}
onConflictDoUpdate(config: SQLiteEffectInsertOnConflictDoUpdateConfig<this>): this {
if (config.where && (config.targetWhere || config.setWhere)) {
throw new Error(
'You cannot use both "where" and "targetWhere"/"setWhere" at the same time - "where" is deprecated, use "targetWhere" or "setWhere" instead.',
)
}
if (!this.config.onConflict) this.config.onConflict = []
const whereSql = config.where ? sql` where ${config.where}` : undefined
const targetWhereSql = config.targetWhere ? sql` where ${config.targetWhere}` : undefined
const setWhereSql = config.setWhere ? sql` where ${config.setWhere}` : undefined
const targetSql = Array.isArray(config.target) ? sql`${config.target}` : sql`${[config.target]}`
const setSql = this.effectDialect.buildUpdateSet(
this.config.table,
mapUpdateSet(this.config.table, config.set as SQLiteUpdateSetSource<TTable>),
)
this.config.onConflict.push(
sql` on conflict ${targetSql}${targetWhereSql} do update set ${setSql}${whereSql}${setWhereSql}`,
)
return this
}
/** @internal */
getSQL(): SQL {
return this.effectDialect.buildInsertQuery(this.config)
}
toSQL(): Query {
return this.effectDialect.sqlToQuery(this.getSQL())
}
/** @internal */
_prepare(isOneTimeQuery = true): SQLiteEffectInsertPrepare<this, TEffectHKT> {
return this.effectSession[isOneTimeQuery ? "prepareOneTimeQuery" : "prepareQuery"](
this.effectDialect.sqlToQuery(this.getSQL()),
this.config.returning,
this.config.returning ? "all" : "run",
undefined,
{
type: "insert",
tables: extractUsedTable(this.config.table),
},
) as SQLiteEffectInsertPrepare<this, TEffectHKT>
}
prepare(): SQLiteEffectInsertPrepare<this, TEffectHKT> {
return this._prepare(false)
}
run: ReturnType<this["prepare"]>["run"] = (placeholderValues) => {
return this._prepare().run(placeholderValues)
}
all: ReturnType<this["prepare"]>["all"] = (placeholderValues) => {
return this._prepare().all(placeholderValues)
}
get: ReturnType<this["prepare"]>["get"] = (placeholderValues) => {
return this._prepare().get(placeholderValues)
}
values: ReturnType<this["prepare"]>["values"] = (placeholderValues) => {
return this._prepare().values(placeholderValues)
}
execute: ReturnType<this["prepare"]>["execute"] = (placeholderValues) => {
return this._prepare().execute(placeholderValues)
}
$dynamic(): SQLiteEffectInsertDynamic<this> {
return this as any
}
}
applyEffectWrapper(SQLiteEffectInsertBase)
@@ -0,0 +1,198 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import {
type BuildQueryResult,
type BuildRelationalQueryResult,
type DBQueryConfig,
makeDefaultRqbMapper,
type TableRelationalConfig,
type TablesRelationalConfig,
} from "drizzle-orm/relations"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import { type Query, type SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql"
import type { KnownKeysOnly } from "drizzle-orm/utils"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { PreparedQueryConfig } from "drizzle-orm/sqlite-core/session"
import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session"
export class SQLiteEffectRelationalQueryBuilder<
TSchema extends TablesRelationalConfig,
TFields extends TableRelationalConfig,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> {
static readonly [entityKind]: string = "SQLiteEffectRelationalQueryBuilderV2"
constructor(
private schema: TSchema,
private table: SQLiteTable,
private tableConfig: TableRelationalConfig,
private dialect: SQLiteDialect,
private session: SQLiteEffectSession<TEffectHKT, any, any>,
private rowMode?: boolean,
private forbidJsonb?: boolean,
) {}
findMany<TConfig extends DBQueryConfig<"many", TSchema, TFields>>(
config?: KnownKeysOnly<TConfig, DBQueryConfig<"many", TSchema, TFields>>,
): SQLiteEffectRelationalQuery<BuildQueryResult<TSchema, TFields, TConfig>[], TEffectHKT> {
return new SQLiteEffectRelationalQuery(
this.schema,
this.table,
this.tableConfig,
this.dialect,
this.session,
(config as DBQueryConfig<"many"> | undefined) ?? true,
"many",
this.rowMode,
this.forbidJsonb,
)
}
findFirst<TConfig extends DBQueryConfig<"one", TSchema, TFields>>(
config?: KnownKeysOnly<TConfig, DBQueryConfig<"one", TSchema, TFields>>,
): SQLiteEffectRelationalQuery<BuildQueryResult<TSchema, TFields, TConfig> | undefined, TEffectHKT> {
return new SQLiteEffectRelationalQuery(
this.schema,
this.table,
this.tableConfig,
this.dialect,
this.session,
(config as DBQueryConfig<"one"> | undefined) ?? true,
"first",
this.rowMode,
this.forbidJsonb,
)
}
}
export interface SQLiteEffectRelationalQuery<TResult, TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
extends Effect.Effect<TResult, TEffectHKT["error"], TEffectHKT["context"]>,
RunnableQuery<TResult, "sqlite">,
SQLWrapper {}
export class SQLiteEffectRelationalQuery<TResult, TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
implements RunnableQuery<TResult, "sqlite">, SQLWrapper
{
static readonly [entityKind]: string = "SQLiteEffectRelationalQueryV2"
declare readonly _: {
readonly dialect: "sqlite"
readonly type: "async"
readonly result: TResult
}
/** @internal */
mode: "many" | "first"
/** @internal */
table: SQLiteTable
constructor(
private schema: TablesRelationalConfig,
table: SQLiteTable,
private tableConfig: TableRelationalConfig,
private dialect: SQLiteDialect,
private session: SQLiteEffectSession<TEffectHKT, any, any>,
private config: DBQueryConfig<"many" | "one"> | true,
mode: "many" | "first",
private rowMode?: boolean,
private forbidJsonb?: boolean,
) {
this.mode = mode
this.table = table
}
/** @internal */
getSQL(): SQL {
return this._getQuery().sql
}
/** @internal */
_prepare(
isOneTimeQuery = true,
): SQLiteEffectPreparedQuery<
PreparedQueryConfig & { all: TResult; get: TResult; execute: TResult },
TEffectHKT,
true
> {
const { query, builtQuery } = this._toSQL()
const mapperConfig = {
isFirst: this.mode === "first",
parseJson: !this.rowMode,
parseJsonIfString: false,
rootJsonMappers: true,
selection: query.selection,
}
return this.session[isOneTimeQuery ? "prepareOneTimeRelationalQuery" : "prepareRelationalQuery"](
builtQuery,
undefined,
this.mode === "first" ? "get" : "all",
makeDefaultRqbMapper(mapperConfig),
mapperConfig,
) as SQLiteEffectPreparedQuery<
PreparedQueryConfig & { all: TResult; get: TResult; execute: TResult },
TEffectHKT,
true
>
}
prepare(): SQLiteEffectPreparedQuery<
PreparedQueryConfig & { all: TResult; get: TResult; execute: TResult },
TEffectHKT,
true
> {
return this._prepare(false)
}
private _getQuery() {
const jsonb = this.forbidJsonb ? sql`json` : sql`jsonb`
const query = this.dialect.buildRelationalQuery({
schema: this.schema,
table: this.table,
tableConfig: this.tableConfig,
queryConfig: this.config,
mode: this.mode,
isNested: this.rowMode,
jsonb,
})
if (this.rowMode) {
const jsonColumns = sql.join(
query.selection.map((s) => {
return sql`${sql.raw(this.dialect.escapeString(s.key))}, ${
s.selection ? sql`${jsonb}(${sql.identifier(s.key)})` : sql.identifier(s.key)
}`
}),
sql`, `,
)
query.sql = sql`select json_object(${jsonColumns}) as ${sql.identifier("r")} from (${query.sql}) as ${sql.identifier(
"t",
)}`
}
return query
}
private _toSQL(): { query: BuildRelationalQueryResult; builtQuery: Query } {
const query = this._getQuery()
const builtQuery = this.dialect.sqlToQuery(query.sql)
return { query, builtQuery }
}
toSQL(): Query {
return this._toSQL().builtQuery
}
execute(placeholderValues?: Record<string, unknown>) {
return this.mode === "first" ? this._prepare().get(placeholderValues) : this._prepare().all(placeholderValues)
}
}
applyEffectWrapper(SQLiteEffectRelationalQuery)
@@ -0,0 +1,49 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import type { PreparedQuery } from "drizzle-orm/session"
import type { Query, SQL, SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
type SQLiteEffectRawAction = "all" | "get" | "values" | "run"
export interface SQLiteEffectRaw<TResult, TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
extends Effect.Effect<TResult, TEffectHKT["error"], TEffectHKT["context"]>,
RunnableQuery<TResult, "sqlite">,
SQLWrapper {}
export class SQLiteEffectRaw<TResult, TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
implements RunnableQuery<TResult, "sqlite">, SQLWrapper, PreparedQuery
{
static readonly [entityKind]: string = "SQLiteEffectRaw"
declare readonly _: {
readonly dialect: "sqlite"
readonly result: TResult
}
constructor(
public execute: () => Effect.Effect<TResult, TEffectHKT["error"], TEffectHKT["context"]>,
/** @internal */
public getSQL: () => SQL,
private action: SQLiteEffectRawAction,
private dialect: SQLiteAsyncDialect,
private mapBatchResult: (result: unknown) => unknown,
) {}
getQuery(): Query & { method: SQLiteEffectRawAction } {
return { ...this.dialect.sqlToQuery(this.getSQL()), method: this.action }
}
mapResult(result: unknown, isFromBatch?: boolean) {
return isFromBatch ? this.mapBatchResult(result) : result
}
_prepare(): PreparedQuery {
return this
}
}
applyEffectWrapper(SQLiteEffectRaw)
@@ -0,0 +1,279 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import type { CacheConfig } from "drizzle-orm/cache/core/types"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind, is } from "drizzle-orm/entity"
import type {
BuildSubquerySelection,
GetSelectTableName,
GetSelectTableSelection,
JoinNullability,
SelectMode,
SelectResult,
} from "drizzle-orm/query-builders/select.types"
import { SQL } from "drizzle-orm/sql/sql"
import type { ColumnsSelection, SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteSelectQueryBuilderBase } from "drizzle-orm/sqlite-core/query-builders/select"
import type {
CreateSQLiteSelectFromBuilderMode,
SelectedFields,
SQLiteSelectConfig,
SQLiteSelectHKTBase,
} from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base"
import { Subquery } from "drizzle-orm/subquery"
import { type Assume, getTableColumns } from "drizzle-orm/utils"
import { getViewSelectedFieldsRuntime, orderSelectedFields } from "../../internal/drizzle-utils"
import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session"
export type SQLiteEffectSelectPrepare<
T extends AnySQLiteEffectSelect,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> = SQLiteEffectPreparedQuery<
{
type: "async"
run: T["_"]["runResult"]
all: T["_"]["result"]
get: T["_"]["result"][number] | undefined
values: any[][]
execute: T["_"]["result"]
},
TEffectHKT
>
export class SQLiteEffectSelectBuilder<
TSelection extends SelectedFields | undefined,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TBuilderMode extends "db" | "qb" = "db",
> {
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
private fields: TSelection
private session: SQLiteEffectSession<TEffectHKT, TRunResult, any> | undefined
private dialect: SQLiteDialect
private withList: Subquery[] | undefined
private distinct: boolean | undefined
constructor(config: {
fields: TSelection
session: SQLiteEffectSession<TEffectHKT, TRunResult, any> | undefined
dialect: SQLiteDialect
withList?: Subquery[]
distinct?: boolean
}) {
this.fields = config.fields
this.session = config.session
this.dialect = config.dialect
this.withList = config.withList
this.distinct = config.distinct
}
from<TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL>(
source: TFrom,
): TBuilderMode extends "db"
? SQLiteEffectSelectBase<
GetSelectTableName<TFrom>,
TRunResult,
TSelection extends undefined ? GetSelectTableSelection<TFrom> : TSelection,
TSelection extends undefined ? "single" : "partial",
GetSelectTableName<TFrom> extends string ? Record<GetSelectTableName<TFrom>, "not-null"> : {},
false,
never,
SelectResult<
TSelection extends undefined ? GetSelectTableSelection<TFrom> : TSelection,
TSelection extends undefined ? "single" : "partial",
GetSelectTableName<TFrom> extends string ? Record<GetSelectTableName<TFrom>, "not-null"> : {}
>[],
BuildSubquerySelection<
TSelection extends undefined ? GetSelectTableSelection<TFrom> : TSelection,
GetSelectTableName<TFrom> extends string ? Record<GetSelectTableName<TFrom>, "not-null"> : {}
>,
TEffectHKT
>
: CreateSQLiteSelectFromBuilderMode<
TBuilderMode,
GetSelectTableName<TFrom>,
"async",
TRunResult,
TSelection extends undefined ? GetSelectTableSelection<TFrom> : TSelection,
TSelection extends undefined ? "single" : "partial"
> {
const isPartialSelect = !!this.fields
let fields: SelectedFields
if (this.fields) {
fields = this.fields
} else if (is(source, Subquery)) {
fields = Object.fromEntries(
Object.keys(source._.selectedFields).map((key) => [
key,
source[key as unknown as keyof typeof source] as unknown as SelectedFields[string],
]),
)
} else if (is(source, SQLiteViewBase)) {
fields = getViewSelectedFieldsRuntime(source).selectedFields as SelectedFields
} else if (is(source, SQL)) {
fields = {}
} else {
fields = getTableColumns<SQLiteTable>(source)
}
return new SQLiteEffectSelectBase({
table: source,
fields,
isPartialSelect,
session: this.session as any,
dialect: this.dialect,
withList: this.withList,
distinct: this.distinct,
}) as any
}
}
export interface SQLiteEffectSelectHKT<TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
extends SQLiteSelectHKTBase {
_type: SQLiteEffectSelectBase<
this["tableName"],
this["runResult"],
Assume<this["selection"], ColumnsSelection>,
this["selectMode"],
Assume<this["nullabilityMap"], Record<string, JoinNullability>>,
this["dynamic"],
this["excludedMethods"],
Assume<this["result"], any[]>,
Assume<this["selectedFields"], ColumnsSelection>,
TEffectHKT
>
}
export interface SQLiteEffectSelectBase<
TTableName extends string | undefined,
TRunResult,
TSelection extends ColumnsSelection,
TSelectMode extends SelectMode = "single",
TNullabilityMap extends Record<string, JoinNullability> = TTableName extends string
? Record<TTableName, "not-null">
: {},
TDynamic extends boolean = false,
TExcludedMethods extends string = never,
TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLiteSelectQueryBuilderBase<
SQLiteEffectSelectHKT<TEffectHKT>,
TTableName,
"async",
TRunResult,
TSelection,
TSelectMode,
TNullabilityMap,
TDynamic,
TExcludedMethods,
TResult,
TSelectedFields
>,
Effect.Effect<TResult, TEffectHKT["error"], TEffectHKT["context"]> {}
export class SQLiteEffectSelectBase<
TTableName extends string | undefined,
TRunResult,
TSelection extends ColumnsSelection,
TSelectMode extends SelectMode = "single",
TNullabilityMap extends Record<string, JoinNullability> = TTableName extends string
? Record<TTableName, "not-null">
: {},
TDynamic extends boolean = false,
TExcludedMethods extends string = never,
TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
extends SQLiteSelectQueryBuilderBase<
SQLiteEffectSelectHKT<TEffectHKT>,
TTableName,
"async",
TRunResult,
TSelection,
TSelectMode,
TNullabilityMap,
TDynamic,
TExcludedMethods,
TResult,
TSelectedFields
>
implements SQLWrapper
{
static override readonly [entityKind]: string = "SQLiteEffectSelect"
private get effectConfig() {
return (this as unknown as { config: SQLiteSelectConfig }).config
}
/** @internal */
getSQL(): SQL {
return this.dialect.buildSelectQuery(this.effectConfig)
}
/** @internal */
_prepare(isOneTimeQuery = true): SQLiteEffectSelectPrepare<this, TEffectHKT> {
if (!this.session) {
throw new Error("Cannot execute a query on a query builder. Please use a database instance instead.")
}
const session = this.session as unknown as SQLiteEffectSession<TEffectHKT, TRunResult, any>
const query = session[isOneTimeQuery ? "prepareOneTimeQuery" : "prepareQuery"](
this.dialect.sqlToQuery(this.getSQL()),
orderSelectedFields<SQLiteColumn>(this.effectConfig.fields),
"all",
undefined,
{
type: "select",
tables: [...this.usedTables],
},
this.cacheConfig,
)
query.joinsNotNullableMap = this.joinsNotNullableMap
return query as ReturnType<this["prepare"]>
}
$withCache(config?: { config?: CacheConfig; tag?: string; autoInvalidate?: boolean } | false) {
this.cacheConfig =
config === undefined
? { config: {}, enabled: true, autoInvalidate: true }
: config === false
? { enabled: false }
: { enabled: true, autoInvalidate: true, ...config }
return this
}
prepare(): SQLiteEffectSelectPrepare<this, TEffectHKT> {
return this._prepare(false)
}
run: ReturnType<this["prepare"]>["run"] = (placeholderValues) => {
return this._prepare().run(placeholderValues)
}
all: ReturnType<this["prepare"]>["all"] = (placeholderValues) => {
return this._prepare().all(placeholderValues)
}
get: ReturnType<this["prepare"]>["get"] = (placeholderValues) => {
return this._prepare().get(placeholderValues)
}
values: ReturnType<this["prepare"]>["values"] = (placeholderValues) => {
return this._prepare().values(placeholderValues)
}
execute: ReturnType<this["prepare"]>["execute"] = (placeholderValues) => {
return this._prepare().execute(placeholderValues)
}
}
applyEffectWrapper(SQLiteEffectSelectBase)
export type AnySQLiteEffectSelect = SQLiteEffectSelectBase<any, any, any, any, any, any, any, any, any, any>
@@ -0,0 +1,490 @@
/* oxlint-disable */
import * as Cause from "effect/Cause"
import * as Effect from "effect/Effect"
import type { SqlError } from "effect/unstable/sql/SqlError"
import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect"
import { NoopCache, strategyFor } from "drizzle-orm/cache/core/cache"
import type { WithCacheConfig } from "drizzle-orm/cache/core/types"
import { MigratorInitError } from "drizzle-orm/effect-core/errors"
import { EffectDrizzleQueryError, EffectTransactionRollbackError } from "drizzle-orm/effect-core/errors"
import type { EffectLoggerShape } from "drizzle-orm/effect-core/logger"
import type { QueryEffectHKTBase, QueryEffectKind } from "drizzle-orm/effect-core/query-effect"
import { entityKind, is } from "drizzle-orm/entity"
import type { MigrationConfig, MigrationMeta } from "drizzle-orm/migrator"
import { getMigrationsToRun } from "drizzle-orm/migrator.utils"
import type {
AnyRelations,
EmptyRelations,
RelationalQueryMapperConfig,
RelationalRowsMapper,
} from "drizzle-orm/relations"
import { makeJitRqbMapper } from "drizzle-orm/relations"
import type { PreparedQuery } from "drizzle-orm/session"
import { fillPlaceholders, type Query, type SQL, sql } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
import { upgradeIfNeeded } from "../../up-migrations/effect-sqlite"
import { assertUnreachable, makeJitQueryMapper, type RowsMapper } from "drizzle-orm/utils"
import { mapResultRow } from "../../internal/drizzle-utils"
import { SQLiteEffectDatabase } from "./db"
type MigrationConfigWithInit = MigrationConfig & { init?: boolean }
type SQLiteEffectExecuteMethod = SQLiteExecuteMethod | "values"
export class SQLiteEffectPreparedQuery<
T extends PreparedQueryConfig,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TIsRqbV2 extends boolean = false,
> implements PreparedQuery
{
static readonly [entityKind]: string = "SQLiteEffectPreparedQuery"
/** @internal */
joinsNotNullableMap?: Record<string, boolean>
private jitMapper?: RowsMapper<any> | RelationalRowsMapper<any>
private cacheConfig: WithCacheConfig | undefined
private effectExecuteMethod: SQLiteExecuteMethod
constructor(
private executor: (
params: unknown[],
executeMethod: SQLiteEffectExecuteMethod,
) => Effect.Effect<unknown, unknown, unknown>,
protected query: Query,
private logger: EffectLoggerShape,
private cache: EffectCacheShape,
private queryMetadata:
| {
type: "select" | "update" | "delete" | "insert"
tables: string[]
}
| undefined,
cacheConfig: WithCacheConfig | undefined,
private fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
private useJitMappers: boolean | undefined,
private customResultMapper?: (
rows: TIsRqbV2 extends true ? Record<string, unknown>[] : unknown[][],
mapColumnValue?: (value: unknown) => unknown,
) => unknown,
private isRqbV2Query?: TIsRqbV2,
private rqbConfig?: RelationalQueryMapperConfig,
private isInTransaction: Effect.Effect<boolean> = Effect.succeed(false),
) {
this.effectExecuteMethod = executeMethod
this.cacheConfig =
cache.strategy() === "all" && cacheConfig === undefined ? { enabled: true, autoInvalidate: true } : cacheConfig
if (!this.cacheConfig?.enabled) {
this.cacheConfig = undefined
}
}
run(placeholderValues?: Record<string, unknown>): QueryEffectKind<TEffectHKT, T["run"]>
run(placeholderValues?: Record<string, unknown>): any {
return this.executeWithCache<T["run"]>(placeholderValues, "run")
}
all(placeholderValues?: Record<string, unknown>): QueryEffectKind<TEffectHKT, T["all"]>
all(placeholderValues?: Record<string, unknown>): any {
if (this.isRqbV2Query) return this.allRqbV2(placeholderValues)
if (!this.fields && !this.customResultMapper) {
return this.executeWithCache<T["all"]>(placeholderValues, "all")
}
return this.executeWithCache<T["values"], T["all"]>(
placeholderValues,
"values",
(rows) => this.mapAllResult(rows) as T["all"],
)
}
get(placeholderValues?: Record<string, unknown>): QueryEffectKind<TEffectHKT, T["get"]>
get(placeholderValues?: Record<string, unknown>): any {
if (this.isRqbV2Query) return this.getRqbV2(placeholderValues)
if (!this.fields && !this.customResultMapper) {
return this.executeWithCache<T["get"]>(placeholderValues, "get")
}
return this.executeWithCache<T["values"], T["get"]>(
placeholderValues,
"values",
(rows) => this.mapGetResult(rows) as T["get"],
)
}
values(placeholderValues?: Record<string, unknown>): QueryEffectKind<TEffectHKT, T["values"]>
values(placeholderValues?: Record<string, unknown>): any {
return this.executeWithCache<T["values"]>(placeholderValues, "values")
}
execute(placeholderValues?: Record<string, unknown>): QueryEffectKind<TEffectHKT, T["execute"]>
execute(placeholderValues?: Record<string, unknown>): any {
return this[this.effectExecuteMethod](placeholderValues) as QueryEffectKind<TEffectHKT, T["execute"]>
}
mapRunResult(result: unknown, _isFromBatch?: boolean): unknown {
return result
}
mapAllResult(rows: unknown, isFromBatch?: boolean): unknown {
if (isFromBatch) {
rows = Array.isArray(rows) ? rows : []
}
if (!this.fields && !this.customResultMapper) {
return rows
}
if (this.isRqbV2Query) {
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RelationalRowsMapper<T["all"]>) ?? makeJitRqbMapper<T["all"]>(this.rqbConfig!))(
rows as Record<string, unknown>[],
)
: (this.customResultMapper as (rows: Record<string, unknown>[]) => unknown)(rows as Record<string, unknown>[])
}
if (this.customResultMapper) {
return (this.customResultMapper as (rows: unknown[][]) => unknown)(rows as unknown[][]) as T["all"]
}
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RowsMapper<T["all"]>) ??
makeJitQueryMapper<T["all"]>(this.fields!, this.joinsNotNullableMap))(rows as unknown[][])
: (rows as unknown[][]).map((row) => mapResultRow(this.fields!, row, this.joinsNotNullableMap))
}
mapGetResult(rows: unknown, isFromBatch?: boolean): unknown {
if (isFromBatch) {
rows = Array.isArray(rows) ? rows : []
}
if (!this.fields && !this.customResultMapper) {
return Array.isArray(rows) ? rows[0] : rows
}
const row = Array.isArray(rows) ? rows[0] : rows
if (!row) return undefined
if (this.isRqbV2Query) {
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RelationalRowsMapper<T["get"][]>) ?? makeJitRqbMapper<T["get"][]>(this.rqbConfig!))([
row as Record<string, unknown>,
])
: (this.customResultMapper as (rows: Record<string, unknown>[]) => unknown)([row as Record<string, unknown>])
}
if (this.customResultMapper) {
return (this.customResultMapper as (rows: unknown[][]) => unknown)([row as unknown[]]) as T["get"]
}
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RowsMapper<T["get"][]>) ??
makeJitQueryMapper<T["get"][]>(this.fields!, this.joinsNotNullableMap))([row as unknown[]])[0]
: mapResultRow(this.fields!, row as unknown[], this.joinsNotNullableMap)
}
private allRqbV2(placeholderValues?: Record<string, unknown>) {
return this.executeWithCache<unknown[], T["all"]>(
placeholderValues,
"all",
(rows) => this.mapAllResult(rows) as T["all"],
)
}
private getRqbV2(placeholderValues?: Record<string, unknown>) {
return this.executeWithCache<unknown, T["get"] | undefined>(placeholderValues, "get", (row) =>
row === undefined ? undefined : (this.mapGetResult(row) as T["get"]),
)
}
private executeWithCache<A, B = A>(
placeholderValues: Record<string, unknown> | undefined,
executeMethod: SQLiteEffectExecuteMethod,
mapResult?: (result: A) => B,
) {
return Effect.gen({ self: this }, function* () {
const params = fillPlaceholders(this.query.params, placeholderValues ?? {})
yield* this.logger.logQuery(this.query.sql, params)
return yield* this.queryWithCache(
this.query.sql,
params,
Effect.suspend(() => this.executor(params, executeMethod) as Effect.Effect<A, unknown, unknown>),
mapResult,
)
})
}
private mapCachedResult<A, B>(result: A, mapResult: ((result: A) => B) | undefined) {
if (!mapResult) return Effect.succeed(result as unknown as B)
return Effect.try({
try: () => mapResult(result),
catch: (cause) => cause,
})
}
private queryWithCache<A, E, R, B = A>(
queryString: string,
params: unknown[],
query: Effect.Effect<A, E, R>,
mapResult?: (result: A) => B,
) {
return Effect.gen({ self: this }, function* () {
if (this.queryMetadata?.type === "select" && this.cacheConfig?.enabled && (yield* this.isInTransaction)) {
return yield* this.mapCachedResult(yield* query, mapResult)
}
const cacheStrat: Awaited<ReturnType<typeof strategyFor>> = !is(this.cache.cache, NoopCache)
? yield* Effect.tryPromise(() => strategyFor(queryString, params, this.queryMetadata, this.cacheConfig))
: { type: "skip" as const }
if (cacheStrat.type === "skip") {
return yield* this.mapCachedResult(yield* query, mapResult)
}
if (cacheStrat.type === "invalidate") {
const result = yield* query
yield* this.cache.onMutate({ tables: cacheStrat.tables })
return yield* this.mapCachedResult(result, mapResult)
}
if (cacheStrat.type === "try") {
if (yield* this.isInTransaction) {
return yield* this.mapCachedResult(yield* query, mapResult)
}
const { tables, key, isTag, autoInvalidate, config } = cacheStrat
const fromCache: any[] | undefined = yield* this.cache.get(key, tables, isTag, autoInvalidate)
if (typeof fromCache !== "undefined") {
return yield* this.mapCachedResult(fromCache as unknown as A, mapResult)
}
const result = yield* query
yield* this.cache.put(key, result, autoInvalidate ? tables : [], isTag, config)
return yield* this.mapCachedResult(result, mapResult)
}
assertUnreachable(cacheStrat)
}).pipe(
Effect.catch((e) => {
return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) }))
}),
)
}
getQuery(): Query {
return this.query
}
mapResult(response: unknown, isFromBatch?: boolean) {
switch (this.effectExecuteMethod) {
case "run": {
return this.mapRunResult(response, isFromBatch)
}
case "all": {
return this.mapAllResult(response, isFromBatch)
}
case "get": {
return this.mapGetResult(response, isFromBatch)
}
}
}
}
export abstract class SQLiteEffectSession<
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TRunResult = unknown,
TRelations extends AnyRelations = EmptyRelations,
> {
static readonly [entityKind]: string = "SQLiteEffectSession"
constructor(readonly dialect: SQLiteAsyncDialect) {}
abstract prepareQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown,
queryMetadata?: {
type: "select" | "update" | "delete" | "insert"
tables: string[]
},
cacheConfig?: WithCacheConfig,
): SQLiteEffectPreparedQuery<T, TEffectHKT>
prepareOneTimeQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown,
queryMetadata?: {
type: "select" | "update" | "delete" | "insert"
tables: string[]
},
cacheConfig?: WithCacheConfig,
): SQLiteEffectPreparedQuery<T, TEffectHKT> {
return this.prepareQuery(query, fields, executeMethod, customResultMapper, queryMetadata, cacheConfig)
}
abstract prepareRelationalQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper: (rows: Record<string, unknown>[], mapColumnValue?: (value: unknown) => unknown) => unknown,
config: RelationalQueryMapperConfig,
): SQLiteEffectPreparedQuery<T, TEffectHKT, true>
prepareOneTimeRelationalQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper: (rows: Record<string, unknown>[], mapColumnValue?: (value: unknown) => unknown) => unknown,
config: RelationalQueryMapperConfig,
): SQLiteEffectPreparedQuery<T, TEffectHKT, true> {
return this.prepareRelationalQuery(query, fields, executeMethod, customResultMapper, config)
}
run(query: SQL): QueryEffectKind<TEffectHKT, TRunResult>
run(query: SQL): any {
return this.prepareQuery<PreparedQueryConfig & { run: TRunResult; execute: TRunResult }>(
this.dialect.sqlToQuery(query),
undefined,
"run",
).run()
}
all<T = unknown>(query: SQL): QueryEffectKind<TEffectHKT, T[]>
all<T = unknown>(query: SQL): any {
return this.prepareQuery<PreparedQueryConfig & { all: T[]; execute: T[] }>(
this.dialect.sqlToQuery(query),
undefined,
"all",
).all()
}
get<T = unknown>(query: SQL): QueryEffectKind<TEffectHKT, T | undefined>
get<T = unknown>(query: SQL): any {
return this.prepareQuery<PreparedQueryConfig & { get: T | undefined; execute: T | undefined }>(
this.dialect.sqlToQuery(query),
undefined,
"get",
).get()
}
values<T extends unknown[] = unknown[]>(query: SQL): QueryEffectKind<TEffectHKT, T[]>
values<T extends unknown[] = unknown[]>(query: SQL): any {
return this.prepareQuery<PreparedQueryConfig & { values: T[]; execute: T[] }>(
this.dialect.sqlToQuery(query),
undefined,
"all",
).values()
}
count(query: SQL): QueryEffectKind<TEffectHKT, number>
count(query: SQL): any {
return this.values<[number]>(query).pipe(Effect.map((result) => result[0]?.[0] ?? 0))
}
abstract transaction<A, E, R>(
transaction: (tx: SQLiteEffectTransaction<TEffectHKT, TRunResult, TRelations>) => Effect.Effect<A, E, R>,
config?: SQLiteTransactionConfig,
): Effect.Effect<A, E | SqlError, R>
}
export abstract class SQLiteEffectTransaction<
TEffectHKT extends QueryEffectHKTBase,
TRunResult,
TRelations extends AnyRelations = EmptyRelations,
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
constructor(
dialect: SQLiteAsyncDialect,
session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>,
protected relations: TRelations,
) {
super(dialect, session, relations)
}
rollback() {
return new EffectTransactionRollbackError()
}
}
export const migrate = Effect.fn("migrate")(function* <TEffectHKT extends QueryEffectHKTBase>(
migrations: MigrationMeta[],
session: SQLiteEffectSession<TEffectHKT>,
config: string | MigrationConfigWithInit,
) {
const migrationsTable =
typeof config === "string" ? "__drizzle_migrations" : (config.migrationsTable ?? "__drizzle_migrations")
const { newDb } = yield* upgradeIfNeeded(migrationsTable, session, migrations)
if (newDb) {
yield* session.run(sql`
CREATE TABLE IF NOT EXISTS ${sql.identifier(migrationsTable)} (
id INTEGER PRIMARY KEY,
hash text NOT NULL,
created_at numeric,
name text,
applied_at TEXT
)
`)
}
const dbMigrations = yield* session.all<{ id: number; hash: string; created_at: string; name: string | null }>(
sql`SELECT id, hash, created_at, name FROM ${sql.identifier(migrationsTable)}`,
)
if (typeof config === "object" && config.init) {
if (dbMigrations.length) {
return yield* new MigratorInitError({ exitCode: "databaseMigrations" })
}
if (migrations.length > 1) {
return yield* new MigratorInitError({ exitCode: "localMigrations" })
}
const [migration] = migrations
if (!migration) return
yield* session.run(
sql`insert into ${sql.identifier(
migrationsTable,
)} ("hash", "created_at", "name", "applied_at") values(${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${new Date().toISOString()})`,
)
return
}
const migrationsToRun = getMigrationsToRun({ localMigrations: migrations, dbMigrations })
if (migrationsToRun.length === 0) return
yield* session.transaction((tx) =>
Effect.gen(function* () {
for (const migration of migrationsToRun) {
for (const stmt of migration.sql) {
yield* tx.run(sql.raw(stmt))
}
yield* tx.run(
sql`insert into ${sql.identifier(
migrationsTable,
)} ("hash", "created_at", "name", "applied_at") values(${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${new Date().toISOString()})`,
)
}
}),
)
})
@@ -0,0 +1,402 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind, is } from "drizzle-orm/entity"
import type { SelectResultFields } from "drizzle-orm/query-builders/select.types"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import { SelectionProxyHandler } from "drizzle-orm/selection-proxy"
import type { Placeholder, Query, SQL, SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SelectedFields, SQLiteSelectJoinConfig } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { SQLiteUpdateConfig, SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core/query-builders/update"
import type { PreparedQueryConfig } from "drizzle-orm/sqlite-core/session"
import { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import { extractUsedTable } from "drizzle-orm/sqlite-core/utils"
import { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base"
import { Subquery } from "drizzle-orm/subquery"
import { type DrizzleTypeError, type UpdateSet, type ValueOrArray } from "drizzle-orm/utils"
import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns/common"
import {
getTableColumnsRuntime,
getTableLikeName,
getViewSelectedFieldsRuntime,
mapUpdateSet,
orderSelectedFields,
} from "../../internal/drizzle-utils"
import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session"
export type SQLiteEffectUpdateWithout<
T extends AnySQLiteEffectUpdate,
TDynamic extends boolean,
K extends keyof T & string,
> = TDynamic extends true
? T
: Omit<
SQLiteEffectUpdateBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["from"],
T["_"]["returning"],
TDynamic,
T["_"]["excludedMethods"] | K,
T["_"]["effectHKT"]
>,
T["_"]["excludedMethods"] | K
>
export type SQLiteEffectUpdateWithJoins<
T extends AnySQLiteEffectUpdate,
TDynamic extends boolean,
TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL,
> = TDynamic extends true
? T
: Omit<
SQLiteEffectUpdateBase<
T["_"]["table"],
T["_"]["runResult"],
TFrom,
T["_"]["returning"],
TDynamic,
Exclude<T["_"]["excludedMethods"] | "from", "leftJoin" | "rightJoin" | "innerJoin" | "fullJoin">,
T["_"]["effectHKT"]
>,
Exclude<T["_"]["excludedMethods"] | "from", "leftJoin" | "rightJoin" | "innerJoin" | "fullJoin">
>
export type SQLiteEffectUpdateReturningAll<
T extends AnySQLiteEffectUpdate,
TDynamic extends boolean,
> = SQLiteEffectUpdateWithout<
SQLiteEffectUpdateBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["from"],
T["_"]["table"]["$inferSelect"],
TDynamic,
T["_"]["excludedMethods"],
T["_"]["effectHKT"]
>,
TDynamic,
"returning"
>
export type SQLiteEffectUpdateReturning<
T extends AnySQLiteEffectUpdate,
TDynamic extends boolean,
TSelectedFields extends SelectedFields,
> = SQLiteEffectUpdateWithout<
SQLiteEffectUpdateBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["from"],
SelectResultFields<TSelectedFields>,
TDynamic,
T["_"]["excludedMethods"],
T["_"]["effectHKT"]
>,
TDynamic,
"returning"
>
export type SQLiteEffectUpdateExecute<T extends AnySQLiteEffectUpdate> = T["_"]["returning"] extends undefined
? T["_"]["runResult"]
: T["_"]["returning"][]
export type SQLiteEffectUpdatePrepare<
T extends AnySQLiteEffectUpdate,
TEffectHKT extends QueryEffectHKTBase = T["_"]["effectHKT"],
> = SQLiteEffectPreparedQuery<
PreparedQueryConfig & {
run: T["_"]["runResult"]
all: T["_"]["returning"] extends undefined
? DrizzleTypeError<".all() cannot be used without .returning()">
: T["_"]["returning"][]
get: T["_"]["returning"] extends undefined
? DrizzleTypeError<".get() cannot be used without .returning()">
: T["_"]["returning"]
values: T["_"]["returning"] extends undefined
? DrizzleTypeError<".values() cannot be used without .returning()">
: any[][]
execute: SQLiteEffectUpdateExecute<T>
},
TEffectHKT
>
export type SQLiteEffectUpdateDynamic<T extends AnySQLiteEffectUpdate> = SQLiteEffectUpdate<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["from"],
T["_"]["returning"],
T["_"]["effectHKT"]
>
export type SQLiteEffectUpdate<
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
TReturning extends Record<string, unknown> | undefined = Record<string, unknown> | undefined,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> = SQLiteEffectUpdateBase<TTable, TRunResult, TFrom, TReturning, true, never, TEffectHKT>
export type AnySQLiteEffectUpdate = SQLiteEffectUpdateBase<any, any, any, any, any, any, any>
export type SQLiteEffectUpdateJoinFn<T extends AnySQLiteEffectUpdate> = <
TJoinedTable extends SQLiteTable | Subquery | SQLiteViewBase | SQL,
>(
table: TJoinedTable,
on:
| ((
updateTable: T["_"]["table"]["_"]["columns"],
from: T["_"]["from"] extends SQLiteTable
? T["_"]["from"]["_"]["columns"]
: T["_"]["from"] extends Subquery | SQLiteViewBase
? T["_"]["from"]["_"]["selectedFields"]
: never,
) => SQL | undefined)
| SQL
| undefined,
) => T
export class SQLiteEffectUpdateBuilder<
TTable extends SQLiteTable,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> {
static readonly [entityKind]: string = "SQLiteEffectUpdateBuilder"
declare readonly _: {
readonly table: TTable
}
constructor(
protected table: TTable,
protected session: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
protected dialect: SQLiteDialect,
private withList?: Subquery[],
) {}
set(
values: SQLiteUpdateSetSource<TTable>,
): SQLiteEffectUpdateWithout<
SQLiteEffectUpdateBase<TTable, TRunResult, undefined, undefined, false, never, TEffectHKT>,
false,
"leftJoin" | "rightJoin" | "innerJoin" | "fullJoin"
> {
return new SQLiteEffectUpdateBase(
this.table,
mapUpdateSet(this.table, values),
this.session,
this.dialect,
this.withList,
) as any
}
}
export interface SQLiteEffectUpdateBase<
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
TReturning = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLWrapper,
RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">,
Effect.Effect<
TReturning extends undefined ? TRunResult : TReturning[],
TEffectHKT["error"],
TEffectHKT["context"]
> {
readonly _: {
readonly dialect: "sqlite"
readonly table: TTable
readonly resultType: "async"
readonly runResult: TRunResult
readonly from: TFrom
readonly returning: TReturning
readonly dynamic: TDynamic
readonly excludedMethods: _TExcludedMethods
readonly result: TReturning extends undefined ? TRunResult : TReturning[]
readonly effectHKT: TEffectHKT
}
}
export class SQLiteEffectUpdateBase<
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
TReturning = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
implements RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">, SQLWrapper
{
static readonly [entityKind]: string = "SQLiteEffectUpdate"
/** @internal */
config: SQLiteUpdateConfig
constructor(
table: TTable,
set: UpdateSet,
private effectSession: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
private effectDialect: SQLiteDialect,
withList?: Subquery[],
) {
this.config = { set, table, withList, joins: [] }
}
from<TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL>(
source: TFrom,
): SQLiteEffectUpdateWithJoins<this, TDynamic, TFrom> {
this.config.from = source
return this as any
}
private createJoin<TJoinType extends SQLiteSelectJoinConfig["joinType"]>(
joinType: TJoinType,
): SQLiteEffectUpdateJoinFn<this> {
return ((
table: SQLiteTable | Subquery | SQLiteViewBase | SQL,
on: ((updateTable: TTable, from: TFrom) => SQL | undefined) | SQL | undefined,
) => {
const tableName = getTableLikeName(table)
if (typeof tableName === "string" && this.config.joins.some((join) => join.alias === tableName)) {
throw new Error(`Alias "${tableName}" is already used in this query`)
}
if (typeof on === "function") {
const from = this.config.from
? is(table, SQLiteTable)
? getTableColumnsRuntime(table)
: is(table, Subquery)
? table._.selectedFields
: is(table, SQLiteViewBase)
? getViewSelectedFieldsRuntime(table).selectedFields
: undefined
: undefined
on = on(
new Proxy(
this.config.table._.columns,
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
) as any,
from &&
(new Proxy(from, new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" })) as any),
)
}
this.config.joins.push({ on, table, joinType, alias: tableName })
return this as any
}) as any
}
leftJoin = this.createJoin("left")
rightJoin = this.createJoin("right")
innerJoin = this.createJoin("inner")
fullJoin = this.createJoin("full")
where(where: SQL | undefined): SQLiteEffectUpdateWithout<this, TDynamic, "where"> {
this.config.where = where
return this as any
}
orderBy(
builder: (updateTable: TTable) => ValueOrArray<SQLiteColumn | SQL | SQL.Aliased>,
): SQLiteEffectUpdateWithout<this, TDynamic, "orderBy">
orderBy(...columns: (SQLiteColumn | SQL | SQL.Aliased)[]): SQLiteEffectUpdateWithout<this, TDynamic, "orderBy">
orderBy(
...columns:
| [(updateTable: TTable) => ValueOrArray<SQLiteColumn | SQL | SQL.Aliased>]
| (SQLiteColumn | SQL | SQL.Aliased)[]
): SQLiteEffectUpdateWithout<this, TDynamic, "orderBy"> {
if (typeof columns[0] === "function") {
const orderBy = columns[0](
new Proxy(
getTableColumnsRuntime(this.config.table),
new SelectionProxyHandler({ sqlAliasedBehavior: "alias", sqlBehavior: "sql" }),
) as any,
)
this.config.orderBy = Array.isArray(orderBy) ? orderBy : [orderBy]
return this as any
}
this.config.orderBy = columns as (SQLiteColumn | SQL | SQL.Aliased)[]
return this as any
}
limit(limit: number | Placeholder): SQLiteEffectUpdateWithout<this, TDynamic, "limit"> {
this.config.limit = limit
return this as any
}
returning(): SQLiteEffectUpdateReturningAll<this, TDynamic>
returning<TSelectedFields extends SelectedFields>(
fields: TSelectedFields,
): SQLiteEffectUpdateReturning<this, TDynamic, TSelectedFields>
returning(
fields: SelectedFields = getTableColumnsRuntime(this.config.table),
): SQLiteEffectUpdateWithout<AnySQLiteEffectUpdate, TDynamic, "returning"> {
this.config.returning = orderSelectedFields<SQLiteColumn>(fields)
return this as any
}
/** @internal */
getSQL(): SQL {
return this.effectDialect.buildUpdateQuery(this.config)
}
toSQL(): Query {
return this.effectDialect.sqlToQuery(this.getSQL())
}
/** @internal */
_prepare(isOneTimeQuery = true): SQLiteEffectUpdatePrepare<this, TEffectHKT> {
return this.effectSession[isOneTimeQuery ? "prepareOneTimeQuery" : "prepareQuery"](
this.effectDialect.sqlToQuery(this.getSQL()),
this.config.returning,
this.config.returning ? "all" : "run",
undefined,
{
type: "update",
tables: extractUsedTable(this.config.table),
},
) as SQLiteEffectUpdatePrepare<this, TEffectHKT>
}
prepare(): SQLiteEffectUpdatePrepare<this, TEffectHKT> {
return this._prepare(false)
}
run: ReturnType<this["prepare"]>["run"] = (placeholderValues) => {
return this._prepare().run(placeholderValues)
}
all: ReturnType<this["prepare"]>["all"] = (placeholderValues) => {
return this._prepare().all(placeholderValues)
}
get: ReturnType<this["prepare"]>["get"] = (placeholderValues) => {
return this._prepare().get(placeholderValues)
}
values: ReturnType<this["prepare"]>["values"] = (placeholderValues) => {
return this._prepare().values(placeholderValues)
}
execute: ReturnType<this["prepare"]>["execute"] = (placeholderValues) => {
return this._prepare().execute(placeholderValues)
}
$dynamic(): SQLiteEffectUpdateDynamic<this> {
return this as any
}
}
applyEffectWrapper(SQLiteEffectUpdateBase)
@@ -0,0 +1,102 @@
/* oxlint-disable */
import * as Effect from "effect/Effect"
import type { SqlError } from "effect/unstable/sql/SqlError"
import { EffectDrizzleError } from "drizzle-orm/effect-core/errors"
import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import type { MigrationMeta } from "drizzle-orm/migrator"
import { sql } from "drizzle-orm/sql/sql"
import type { SQLiteEffectSession } from "../sqlite-core/effect/session"
import {
buildSQLiteMigrationBackfillStatements,
prepareSQLiteMigrationBackfill,
type SQLiteMigrationTableRow,
} from "./sqlite"
import { GET_VERSION_FOR, MIGRATIONS_TABLE_VERSIONS, type UpgradeResult } from "./utils"
const migrationUpgradeError = (cause: unknown) =>
new EffectDrizzleError({
message:
typeof cause === "object" && cause !== null && "message" in cause && typeof cause.message === "string"
? cause.message
: String(cause),
cause,
})
export const upgradeIfNeeded: <TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
) => Effect.Effect<UpgradeResult, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]> =
Effect.fn("upgradeIfNeeded")(function* <TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
) {
const tableExists = yield* session.all(
sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`,
)
if (tableExists.length === 0) {
return { newDb: true }
}
const rows = yield* session.all<{ column_name: string }>(
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
)
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
const upgradeFn = upgradeFunctions[v]
if (!upgradeFn) {
return yield* new EffectDrizzleError({
message: `No upgrade path from migration table version ${v} to ${v + 1}`,
cause: { version: v },
})
}
yield* upgradeFn(migrationsTable, session, localMigrations)
}
return { newDb: false }
})
const upgradeFunctions: Record<
number,
<TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
) => Effect.Effect<void, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]>
> = {
0: upgradeFromV0,
}
function upgradeFromV0<TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
): Effect.Effect<void, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]> {
return Effect.gen(function* () {
const table = sql`${sql.identifier(migrationsTable)}`
const dbRows = yield* session.all<SQLiteMigrationTableRow>(
sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`,
)
const statements = yield* Effect.try({
try: () =>
buildSQLiteMigrationBackfillStatements(
migrationsTable,
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
),
catch: migrationUpgradeError,
})
yield* session.transaction((tx) =>
Effect.gen(function* () {
for (const statement of statements) {
yield* tx.run(statement)
}
}),
)
})
}
@@ -0,0 +1,253 @@
/* oxlint-disable */
import type { TablesRelationalConfig } from "drizzle-orm/_relations"
import type { MigrationMeta } from "drizzle-orm/migrator"
import type { AnyRelations } from "drizzle-orm/relations"
import { type SQL, sql } from "drizzle-orm/sql/sql"
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"
import type { SQLiteSession } from "drizzle-orm/sqlite-core/session"
import { GET_VERSION_FOR, MIGRATIONS_TABLE_VERSIONS, type UpgradeResult } from "./utils"
/** @internal */
export type SQLiteMigrationTableRow = { id: number | null; hash: string; created_at: number }
type AsyncSQLiteDatabaseWithSession = BaseSQLiteDatabase<"async", unknown, Record<string, unknown>> & {
session: {
all<T>(query: SQL): Promise<T[]>
}
transaction<T>(transaction: (tx: { run(query: SQL): Promise<unknown> }) => Promise<T>): Promise<T>
}
type SQLiteMigrationBackfillEntry = {
name: string
selector:
| { column: "id"; value: number }
| { column: "created_at"; value: number }
| { column: "hash"; value: string }
}
function unmatchedMigrationError(unmatched: SQLiteMigrationTableRow[]) {
return new Error(
`While upgrading your database migrations table we found ${unmatched.length} (${unmatched
.map((it) => `[id: ${it.id}, created_at: ${it.created_at}]`)
.join(
", ",
)}) migrations in the database that do not match any local migration. This means that some migrations were applied to the database but are missing from the local environment`,
)
}
/** @internal */
export function prepareSQLiteMigrationBackfill(
dbRows: SQLiteMigrationTableRow[],
localMigrations: MigrationMeta[],
): SQLiteMigrationBackfillEntry[] {
const sortedLocalMigrations = [...localMigrations].sort((a, b) =>
a.folderMillis !== b.folderMillis ? a.folderMillis - b.folderMillis : (a.name ?? "").localeCompare(b.name ?? ""),
)
const byMillis = new Map<number, MigrationMeta[]>()
const byHash = new Map<string, MigrationMeta>()
for (const migration of sortedLocalMigrations) {
if (!byMillis.has(migration.folderMillis)) {
byMillis.set(migration.folderMillis, [])
}
byMillis.get(migration.folderMillis)!.push(migration)
byHash.set(migration.hash, migration)
}
const toApply: SQLiteMigrationBackfillEntry[] = []
const unmatched: SQLiteMigrationTableRow[] = []
for (const dbRow of dbRows) {
const stringified = String(dbRow.created_at)
const millis = Number(stringified.substring(0, stringified.length - 3) + "000")
const candidates = byMillis.get(millis)
const matchedByMillis = candidates?.length === 1 ? candidates[0] : undefined
const matchedByCandidateHash =
candidates && candidates.length > 1
? candidates.find((candidate) => candidate.hash && dbRow.hash && candidate.hash === dbRow.hash)
: undefined
const matchedByHash = matchedByMillis || matchedByCandidateHash ? undefined : byHash.get(dbRow.hash)
const matched = matchedByMillis ?? matchedByCandidateHash ?? matchedByHash
if (matched) {
toApply.push({
name: matched.name,
selector:
dbRow.id !== null
? { column: "id", value: dbRow.id }
: matchedByMillis
? { column: "created_at", value: dbRow.created_at }
: { column: "hash", value: dbRow.hash },
})
continue
}
unmatched.push(dbRow)
}
if (unmatched.length > 0) {
throw unmatchedMigrationError(unmatched)
}
return toApply
}
/** @internal */
export function buildSQLiteMigrationBackfillStatements(
migrationsTable: string,
backfillEntries: SQLiteMigrationBackfillEntry[],
) {
const table = sql`${sql.identifier(migrationsTable)}`
const statements: SQL[] = [
sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("name")} text`,
sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("applied_at")} TEXT`,
]
for (const backfillEntry of backfillEntries) {
const updateQuery = sql`UPDATE ${table} SET ${sql.identifier("name")} = ${backfillEntry.name}, ${sql.identifier(
"applied_at",
)} = NULL WHERE`
updateQuery.append(sql` ${sql.identifier(backfillEntry.selector.column)} = ${backfillEntry.selector.value}`)
statements.push(updateQuery)
}
return statements
}
/**
* Detects the current version of the migrations table schema and upgrades it if needed.
*
* Version 0: Original schema (id, hash, created_at)
* Version 1: Extended schema (id, hash, created_at, name, applied_at)
*/
export function upgradeSyncIfNeeded(
migrationsTable: string,
session: SQLiteSession<"sync", unknown, Record<string, unknown>, AnyRelations, TablesRelationalConfig>,
localMigrations: MigrationMeta[],
): UpgradeResult {
const tableExists = session.all(sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`)
if (tableExists.length === 0) {
return { newDb: true }
}
// Table exists, check table shape
const rows = session.all<{ column_name: string }>(
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
)
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
const upgradeFn = upgradeSyncFunctions[v]
if (!upgradeFn) {
throw new Error(`No upgrade path from migration table version ${v} to ${v + 1}`)
}
upgradeFn(migrationsTable, session, localMigrations)
}
return { newDb: false }
}
const upgradeSyncFunctions: Record<
number,
(
migrationsTable: string,
session: SQLiteSession<"sync", unknown, Record<string, unknown>, AnyRelations, TablesRelationalConfig>,
localMigrations: MigrationMeta[],
) => void
> = {
/**
* Upgrade from version 0 to version 1:
* 1. Read all existing DB migrations
* 2. Sort localMigrations ASC by millis and if the same - sort by name
* 3. Match each DB row to a local migration
* If multiple migrations share the same second, use hash matching as a tiebreaker
* Not implemented for now -> If hash matching fails, fall back to serial id ordering
* 5. Create extra column and backfill names for matched migrations
*/
0: (migrationsTable, session, localMigrations) => {
const table = sql`${sql.identifier(migrationsTable)}`
const dbRows = session.all<SQLiteMigrationTableRow>(sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`)
const statements = buildSQLiteMigrationBackfillStatements(
migrationsTable,
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
)
session.transaction((tx) => {
for (const statement of statements) {
tx.run(statement)
}
})
},
}
/**
* Detects the current version of the migrations table schema and upgrades it if needed.
*
* Version 0: Original schema (id, hash, created_at)
* Version 1: Extended schema (id, hash, created_at, name, applied_at)
*/
export async function upgradeAsyncIfNeeded(
migrationsTable: string,
db: AsyncSQLiteDatabaseWithSession,
localMigrations: MigrationMeta[],
): Promise<UpgradeResult> {
// Check if the table exists at all
const tableExists = await db.session.all(
sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`,
)
if (tableExists.length === 0) {
return { newDb: true }
}
const rows = await db.session.all<{ column_name: string }>(
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
)
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
const upgradeFn = upgradeAsyncFunctions[v]
if (!upgradeFn) {
throw new Error(`No upgrade path from migration table version ${v} to ${v + 1}`)
}
await upgradeFn(migrationsTable, db, localMigrations)
}
return { newDb: false }
}
const upgradeAsyncFunctions: Record<
number,
(migrationsTable: string, db: AsyncSQLiteDatabaseWithSession, localMigrations: MigrationMeta[]) => Promise<void>
> = {
/**
* Upgrade from version 0 to version 1:
* 1. Read all existing DB migrations
* 2. Sort localMigrations ASC by millis and if the same - sort by name
* 3. Match each DB row to a local migration
* If multiple migrations share the same second, use hash matching as a tiebreaker
* Not implemented for now -> If hash matching fails, fall back to serial id ordering
* 5. Create extra column and backfill names for matched migrations
*/
0: async (migrationsTable, db, localMigrations) => {
const table = sql`${sql.identifier(migrationsTable)}`
const dbRows = await db.session.all<SQLiteMigrationTableRow>(
sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`,
)
const statements = buildSQLiteMigrationBackfillStatements(
migrationsTable,
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
)
await db.transaction(async (tx) => {
for (const statement of statements) {
await tx.run(statement)
}
})
},
}
@@ -0,0 +1,45 @@
/* oxlint-disable */
export interface UpgradeResult {
newDb: boolean
}
export const MIGRATIONS_TABLE_VERSIONS = {
sqlite: 1,
pg: 1,
effect: 1,
mysql: 1,
mssql: 1,
cockroach: 1,
singlestore: 1,
} as const
export const GET_VERSION_FOR = {
mysql: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
pg: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
effect: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
mssql: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
cockroach: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
singlestore: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
sqlite: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
} as const
@@ -0,0 +1,139 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { expect, test } from "bun:test"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { eq, sql } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Effect } from "effect"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
import { EffectDrizzleSqlite } from "../src"
const users = sqliteTable("users", {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
})
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
Effect.runPromise(
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
)
const makeDb = Effect.gen(function* () {
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
yield* db.run(sql`create table users (id integer primary key autoincrement, name text not null)`)
return db
})
const createMigrationsFolder = async () => {
const migrationsFolder = await mkdtemp(join(tmpdir(), "effect-drizzle-sqlite-"))
await mkdir(join(migrationsFolder, "20240101000000_create_migrated_users"), { recursive: true })
await Bun.write(
join(migrationsFolder, "20240101000000_create_migrated_users", "migration.sql"),
"create table migrated_users (id integer primary key autoincrement, name text not null);",
)
return migrationsFolder
}
test("selects rows through Effect-yieldable query builders", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.insert(users).values({ name: "Ada" })
expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Ada" }])
expect(yield* db.select({ id: users.id }).from(users).where(eq(users.name, "Ada")).get()).toEqual({ id: 1 })
}),
)
})
test("commits successful transactions", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.transaction((tx) => tx.insert(users).values({ name: "Grace" }), { behavior: "immediate" })
expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Grace" }])
}),
)
})
test("rolls back failed transactions", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db
.transaction((tx) =>
tx
.insert(users)
.values({ name: "Linus" })
.pipe(Effect.andThen(Effect.fail("boom"))),
)
.pipe(Effect.ignore)
expect(yield* db.select().from(users)).toEqual([])
}),
)
})
test("rolls back explicit transaction rollback", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db
.transaction((tx) =>
tx
.insert(users)
.values({ name: "Barbara" })
.pipe(Effect.andThen(Effect.fail(tx.rollback()))),
)
.pipe(Effect.ignore)
expect(yield* db.select().from(users)).toEqual([])
}),
)
})
test("supports returning and rejects empty update sets", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const inserted = yield* db.insert(users).values({ name: "Ada" }).returning({ id: users.id, name: users.name })
expect(inserted).toEqual([{ id: 1, name: "Ada" }])
const updated = yield* db.update(users).set({ name: "Grace" }).where(eq(users.id, 1)).returning()
expect(updated).toEqual([{ id: 1, name: "Grace" }])
const deleted = yield* db.delete(users).where(eq(users.id, 1)).returning({ id: users.id })
expect(deleted).toEqual([{ id: 1 }])
expect(() => db.update(users).set({ name: undefined })).toThrow("No values to set")
}),
)
})
test("runs migrations once and records migration metadata", async () => {
const migrationsFolder = await createMigrationsFolder()
try {
await run(
Effect.gen(function* () {
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
yield* EffectDrizzleSqlite.migrate(db, { migrationsFolder })
yield* EffectDrizzleSqlite.migrate(db, { migrationsFolder })
yield* db.run(sql`insert into migrated_users (name) values ('Margaret')`)
expect(yield* db.all<{ name: string }>(sql`select name from migrated_users`)).toEqual([{ name: "Margaret" }])
expect(yield* db.all<{ name: string | null }>(sql`select name from __drizzle_migrations`)).toEqual([
{ name: "20240101000000_create_migrated_users" },
])
}),
)
} finally {
await rm(migrationsFolder, { recursive: true, force: true })
}
})
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false,
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.15.5", "version": "1.15.6",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

Some files were not shown because too many files have changed in this diff Show More