mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 20:19:53 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffaaad89a4 |
@@ -1,4 +1,3 @@
|
||||
packages/core/migration/**/snapshot.json linguist-generated
|
||||
packages/core/src/database/migration.gen.ts linguist-generated
|
||||
packages/core/src/models-dev/snapshot.txt linguist-generated
|
||||
packages/core/src/**/*.txt text eol=lf
|
||||
|
||||
@@ -124,66 +124,12 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli-unsigned
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist/cli-*
|
||||
|
||||
outputs:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
sign-cli-macos:
|
||||
needs: build-cli
|
||||
runs-on: macos-26
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
|
||||
with:
|
||||
keychain: build
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-preview-cli-unsigned
|
||||
path: packages/cli/dist
|
||||
|
||||
- name: Sign macOS CLI binaries
|
||||
run: |
|
||||
identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)
|
||||
if [ -z "$identity" ]; then
|
||||
echo "Developer ID Application identity not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
found=0
|
||||
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
|
||||
if [ ! -f "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
found=1
|
||||
codesign \
|
||||
--force \
|
||||
--timestamp \
|
||||
--options runtime \
|
||||
--entitlements packages/cli/script/entitlements.plist \
|
||||
--sign "$identity" \
|
||||
"$file"
|
||||
codesign --verify --deep --strict --verbose=4 "$file"
|
||||
codesign --display --requirements - "$file"
|
||||
done
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "No macOS CLI binaries found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-cli:
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
@@ -522,7 +468,6 @@ jobs:
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-macos
|
||||
- build-node-cli
|
||||
- sign-cli-windows
|
||||
- build-electron
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
|
||||
import { chmod, copyFile, mkdir, mkdtemp, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { build } from "vite"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
|
||||
import { mainConfig } from "../vite.node.config"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
|
||||
@@ -55,34 +56,14 @@ const builder =
|
||||
? await resolveHostNode()
|
||||
: undefined
|
||||
|
||||
// Vite silently rewrites text imports of known asset types (.txt) to asset
|
||||
// URL strings when the raw-text plugin doesn't intercept them first — the
|
||||
// bundle still builds and `--help` still runs, so only content assertions
|
||||
// catch it. Guards the models.dev snapshot and the prompt/tool description
|
||||
// text that ships inside the bundle.
|
||||
async function assertTextImportsInlined(bundlePath: string) {
|
||||
const bundle = await readFile(bundlePath, "utf8")
|
||||
const markers = [
|
||||
{ marker: '"zhipuai"', source: "models-dev snapshot" },
|
||||
{ marker: "/assets/snapshot", source: "models-dev snapshot inlined as asset URL", forbidden: true },
|
||||
{ marker: '="/assets/', source: "text import inlined as asset URL", forbidden: true },
|
||||
]
|
||||
for (const { marker, source, forbidden } of markers) {
|
||||
const present = bundle.includes(marker)
|
||||
if (forbidden ? present : !present)
|
||||
throw new Error(`${bundlePath}: ${source} — text imports are not inlined as content (marker ${marker})`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
console.log(`building cli-node-${targetName(target)}`)
|
||||
const assets = await collectNodeAssets(target)
|
||||
await rm("dist-node", { recursive: true, force: true })
|
||||
const assetHash = await hashNodeAssets(assets)
|
||||
const input = { version: Script.version, channel: Script.channel, assetHash, target }
|
||||
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
|
||||
await copyNodeAssets(assets)
|
||||
await build(mainConfig(input))
|
||||
await assertTextImportsInlined("dist-node/opencode.mjs")
|
||||
|
||||
const host = target.platform === process.platform && target.arch === process.arch
|
||||
if (host) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Script } from "@opencode-ai/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
@@ -98,6 +99,7 @@ for (const item of targets) {
|
||||
define: {
|
||||
OPENCODE_VERSION: `'${Script.version}'`,
|
||||
OPENCODE_CLI_NAME: `'${binary}'`,
|
||||
OPENCODE_MODELS_DEV: modelsData,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
|
||||
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-executable-page-protection</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
|
||||
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
|
||||
|
||||
export const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? await readFile(process.env.MODELS_DEV_API_JSON, "utf8")
|
||||
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
|
||||
|
||||
console.log("Loaded models.dev snapshot")
|
||||
@@ -10,13 +10,8 @@ const dir = import.meta.dirname
|
||||
function rawTextPlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:raw-text",
|
||||
// "pre" is load-bearing for .txt: Vite's built-in asset plugin claims
|
||||
// known asset types (.txt among them) ahead of normal-priority plugins,
|
||||
// replacing the import with an asset URL string instead of the content.
|
||||
// .md only ever worked without it because .md is not a known asset type.
|
||||
enforce: "pre",
|
||||
async load(id) {
|
||||
if (!id.endsWith(".md") && !id.endsWith(".txt")) return
|
||||
if (!id.endsWith(".md")) return
|
||||
return `export default ${JSON.stringify(await readFile(id, "utf8"))}`
|
||||
},
|
||||
}
|
||||
@@ -214,6 +209,7 @@ if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
|
||||
export type NodeBuildInput = {
|
||||
readonly version: string
|
||||
readonly channel: string
|
||||
readonly models: string
|
||||
readonly assetHash: string
|
||||
readonly target: NodeTarget
|
||||
}
|
||||
@@ -237,6 +233,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
define: {
|
||||
OPENCODE_VERSION: JSON.stringify(input.version),
|
||||
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
|
||||
OPENCODE_MODELS_DEV: input.models,
|
||||
OPENCODE_CHANNEL: JSON.stringify(input.channel),
|
||||
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
|
||||
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
|
||||
@@ -259,6 +256,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
export default mainConfig({
|
||||
version: process.env.OPENCODE_VERSION ?? "local",
|
||||
channel: process.env.OPENCODE_CHANNEL ?? "local",
|
||||
models: "undefined",
|
||||
assetHash: "local",
|
||||
target: nodeTarget(process.platform, process.arch),
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"migration": "bun run script/migration.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"benchmark:location": "bun run script/benchmark-location.ts",
|
||||
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
|
||||
},
|
||||
@@ -26,7 +25,6 @@
|
||||
},
|
||||
"imports": {
|
||||
"#sqlite": {
|
||||
"workerd": "./src/database/sqlite.workerd.ts",
|
||||
"bun": "./src/database/sqlite.bun.ts",
|
||||
"node": "./src/database/sqlite.node.ts",
|
||||
"default": "./src/database/sqlite.bun.ts"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Refreshes the bundled models.dev catalog snapshot at src/models-dev/snapshot.txt.
|
||||
* The snapshot is the boot-time floor for the catalog when no cache entry exists
|
||||
* and fetching is disabled or unavailable; live fetch still refreshes on top.
|
||||
*/
|
||||
const source = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
|
||||
const response = await fetch(`${source}/api.json`)
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch ${source}/api.json: ${response.status} ${response.statusText}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const text = await response.text()
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
// A floor, not equality: guards against committing an error page or a
|
||||
// truncated body that still parses as a small object.
|
||||
const MINIMUM_PROVIDERS = 100
|
||||
if (typeof parsed !== "object" || parsed === null || Object.keys(parsed).length < MINIMUM_PROVIDERS) {
|
||||
console.error(`Fetched catalog has fewer than ${MINIMUM_PROVIDERS} providers; refusing to write snapshot`)
|
||||
process.exit(1)
|
||||
}
|
||||
const target = new URL("../src/models-dev/snapshot.txt", import.meta.url)
|
||||
await Bun.write(target, text)
|
||||
console.log(`Wrote ${Object.keys(parsed).length} providers (${text.length} bytes) to ${Bun.fileURLToPath(target)}`)
|
||||
@@ -88,13 +88,9 @@ function mapBedrockSettings(
|
||||
: typeof settings.bearerToken === "string"
|
||||
? settings.bearerToken
|
||||
: undefined
|
||||
const region = bedrockRegion(settings)
|
||||
const credentials = mapBedrockCredentials(settings, region)
|
||||
const credentials = mapBedrockCredentials(settings)
|
||||
return {
|
||||
...baseSettings,
|
||||
...(typeof baseSettings.baseURL === "string" && region !== undefined
|
||||
? { baseURL: baseSettings.baseURL.replaceAll("${AWS_REGION}", region) }
|
||||
: {}),
|
||||
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
|
||||
? { baseURL: settings.endpoint }
|
||||
: {}),
|
||||
@@ -159,8 +155,14 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
|
||||
}
|
||||
}
|
||||
|
||||
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, region: string | undefined) {
|
||||
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
|
||||
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
|
||||
const region =
|
||||
typeof settings.region === "string"
|
||||
? settings.region
|
||||
: typeof credentials.region === "string"
|
||||
? credentials.region
|
||||
: undefined
|
||||
if (
|
||||
region === undefined ||
|
||||
typeof credentials.accessKeyId !== "string" ||
|
||||
@@ -175,15 +177,6 @@ function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, regi
|
||||
}
|
||||
}
|
||||
|
||||
function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
|
||||
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
|
||||
return typeof settings.region === "string"
|
||||
? settings.region
|
||||
: typeof credentials.region === "string"
|
||||
? credentials.region
|
||||
: undefined
|
||||
}
|
||||
|
||||
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export * as Database from "./database"
|
||||
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
|
||||
import { sqliteLayer } from "#sqlite"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import type { SqlClient } from "effect/unstable/sql"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration"
|
||||
@@ -28,15 +27,12 @@ const databaseLayer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDatabase
|
||||
|
||||
if (supportsTuningPragmas) {
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
}
|
||||
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
|
||||
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
yield* DatabaseMigration.apply(db)
|
||||
|
||||
return { db }
|
||||
@@ -46,7 +42,7 @@ const databaseLayer = Layer.effect(
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
@@ -55,12 +51,6 @@ export function layer(options: Options = { path: ":memory:" }) {
|
||||
)
|
||||
}
|
||||
|
||||
// The database service over an injected SqlClient, for runtimes that receive
|
||||
// database storage instead of opening a filesystem path. Any client provided
|
||||
// here still goes through the pragma guards and migrations; Global is required
|
||||
// because migrations may read it (the v1 import).
|
||||
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer
|
||||
|
||||
export function configured(options?: Options) {
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as DatabaseMigration from "./migration"
|
||||
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Semaphore } from "effect"
|
||||
import { supportsForeignKeyToggle } from "#sqlite"
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { migrations } from "./migration.gen"
|
||||
import schema from "./schema.gen"
|
||||
@@ -21,10 +20,8 @@ export type Migration = {
|
||||
export function apply(db: Database) {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
// OpenCode owns the unprefixed table namespace. Embedders sharing this
|
||||
// database may own underscore-prefixed tables, which bootstrap ignores.
|
||||
const tables = yield* db.all<{ name: string }>(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
|
||||
)
|
||||
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
|
||||
return yield* applyOnly(db, migrations)
|
||||
@@ -106,15 +103,9 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Durable Object SQLite rejects the foreign_keys toggle; the closest
|
||||
// allowlisted relaxation is deferring enforcement to transaction commit.
|
||||
const relaxForeignKeys = supportsForeignKeyToggle
|
||||
? db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
: db.run(sql`PRAGMA defer_foreign_keys = ON`)
|
||||
const restoreForeignKeys = supportsForeignKeyToggle ? db.run(sql`PRAGMA foreign_keys = ON`) : Effect.void
|
||||
yield* relaxForeignKeys
|
||||
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
yield* apply.pipe(
|
||||
Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)),
|
||||
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
|
||||
Effect.tapError((error) =>
|
||||
Effect.logError("database migration failed", {
|
||||
migration: migration.id,
|
||||
|
||||
@@ -8,11 +8,6 @@ import { Sqlite } from "./sqlite"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
|
||||
|
||||
export const supportsTuningPragmas = true
|
||||
|
||||
// Foreign keys default OFF and can be toggled per connection.
|
||||
export const supportsForeignKeyToggle = true
|
||||
|
||||
interface Config extends Sqlite.ClientConfig {
|
||||
readonly filename: string
|
||||
readonly readonly?: boolean
|
||||
|
||||
@@ -8,11 +8,6 @@ import { Sqlite } from "./sqlite"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
|
||||
|
||||
export const supportsTuningPragmas = true
|
||||
|
||||
// Foreign keys default OFF and can be toggled per connection.
|
||||
export const supportsForeignKeyToggle = true
|
||||
|
||||
interface Config extends Sqlite.ClientConfig {
|
||||
readonly filename: string
|
||||
readonly readonly?: boolean
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
import { drizzle } from "drizzle-orm/durable-sqlite"
|
||||
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { identity } from "effect/Function"
|
||||
import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
|
||||
import { Sqlite } from "./sqlite"
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteWorkerd" as const
|
||||
type TypeId = typeof TypeId
|
||||
|
||||
// Durable Object SQLite only allowlists introspection pragmas; journal_mode,
|
||||
// synchronous, busy_timeout, cache_size, and wal_checkpoint all throw, and
|
||||
// foreign keys are already enforced by default (SQLITE_DEFAULT_FOREIGN_KEYS=1).
|
||||
export const supportsTuningPragmas = false
|
||||
|
||||
// Durable Object SQLite rejects `PRAGMA foreign_keys`: enforcement is always
|
||||
// on (SQLITE_DEFAULT_FOREIGN_KEYS=1) and only `defer_foreign_keys` is
|
||||
// allowlisted for migrations that must relax checking inside a transaction.
|
||||
export const supportsForeignKeyToggle = false
|
||||
|
||||
// Minimal structural types for the Durable Object storage API so this adapter
|
||||
// does not depend on @cloudflare/workers-types (whose ambient globals conflict
|
||||
// with @types/bun). Shapes match the SqlStorage and DurableObjectStorage docs.
|
||||
type SqlStorageValue = ArrayBuffer | string | number | null
|
||||
|
||||
interface SqlStorageCursor {
|
||||
readonly columnNames: Array<string>
|
||||
raw(): IterableIterator<Array<SqlStorageValue>>
|
||||
toArray(): Array<Record<string, SqlStorageValue>>
|
||||
}
|
||||
|
||||
export interface SqlStorage {
|
||||
exec(query: string, ...bindings: Array<unknown>): SqlStorageCursor
|
||||
}
|
||||
|
||||
export interface DurableObjectStorage {
|
||||
readonly sql: SqlStorage
|
||||
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T>
|
||||
transactionSync<T>(closure: () => T): T
|
||||
}
|
||||
|
||||
interface SqliteClient extends SqlClient.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: Config
|
||||
readonly updateValues: never
|
||||
}
|
||||
|
||||
interface Config {
|
||||
readonly storage: DurableObjectStorage
|
||||
readonly spanAttributes?: Record<string, unknown>
|
||||
readonly transformResultNames?: (str: string) => string
|
||||
readonly transformQueryNames?: (str: string) => string
|
||||
}
|
||||
|
||||
// sql.exec() rejects BEGIN/COMMIT/SAVEPOINT, so SqlClient.make's default
|
||||
// transaction SQL can never run. withTransaction is replaced below with a
|
||||
// DurableObjectStorage.transaction-backed implementation; this service only
|
||||
// tracks the active transaction connection for statements and nesting checks.
|
||||
const WorkerdTransaction = Context.Service<SqlClient.TransactionConnection, SqlClient.TransactionConnection.Service>(
|
||||
"@opencode-ai/core/database/SqliteWorkerdTransaction",
|
||||
)
|
||||
|
||||
const transactionError = (message: string) =>
|
||||
new SqlError({
|
||||
reason: new UnknownError({ cause: new Error(message), message, operation: "transaction" }),
|
||||
})
|
||||
|
||||
const makeWithTransaction =
|
||||
(
|
||||
storage: DurableObjectStorage,
|
||||
connection: Connection,
|
||||
semaphore: Semaphore.Semaphore,
|
||||
): SqlClient.SqlClient["withTransaction"] =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | SqlError, R> =>
|
||||
Effect.withFiber((fiber) => {
|
||||
const services = fiber.context
|
||||
if (Context.getOption(services, WorkerdTransaction)._tag === "Some")
|
||||
return Effect.fail(
|
||||
transactionError("Nested transactions are not supported by Cloudflare Durable Object SQLite storage"),
|
||||
)
|
||||
const effectWithTxn = Effect.provideContext(
|
||||
effect,
|
||||
Context.add(services, WorkerdTransaction, [connection, 0] as const),
|
||||
)
|
||||
return semaphore.withPermits(1)(
|
||||
Effect.callback((resume) => {
|
||||
let interrupted = false
|
||||
const promise = storage
|
||||
.transaction(
|
||||
(txn) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (interrupted) return resolve()
|
||||
resume(
|
||||
Effect.onExit(effectWithTxn, (exit) => {
|
||||
if (Exit.isFailure(exit)) txn.rollback()
|
||||
resolve()
|
||||
// wait for the transaction to complete
|
||||
return Effect.promise(() => promise)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.catch((cause) =>
|
||||
resume(
|
||||
Effect.fail(
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed transaction", operation: "transaction" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Effect.suspend(() => {
|
||||
interrupted = true
|
||||
return Effect.promise(() => promise)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const make = (options: Config) =>
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as DurableObjectStorage
|
||||
|
||||
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
// SqlClient.SafeIntegers is ignored: Durable Object SQLite has no bigint
|
||||
// mode and always returns integers as numbers. Blobs come back as
|
||||
// ArrayBuffer and are normalized to Uint8Array to match the other adapters.
|
||||
function* runIterator(query: string, params: ReadonlyArray<unknown> = []) {
|
||||
const cursor = native.sql.exec(query, ...params)
|
||||
const columns = cursor.columnNames
|
||||
for (const row of cursor.raw()) {
|
||||
const record: Record<string, unknown> = {}
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const value = row[i]
|
||||
record[columns[i]] = value instanceof ArrayBuffer ? new Uint8Array(value) : value
|
||||
}
|
||||
yield record
|
||||
}
|
||||
}
|
||||
|
||||
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.try({
|
||||
try: () => Array.from(runIterator(query, params)),
|
||||
catch: (cause) =>
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.try({
|
||||
try: () =>
|
||||
Array.from(native.sql.exec(query, ...params).raw(), (row) =>
|
||||
row.map((value) => (value instanceof ArrayBuffer ? new Uint8Array(value) : value)),
|
||||
),
|
||||
catch: (cause) =>
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const connection = identity<Connection>({
|
||||
execute(query, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
||||
},
|
||||
executeRaw(query, params) {
|
||||
return run(query, params)
|
||||
},
|
||||
executeValues(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeValuesUnprepared(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeUnprepared(query, params, transformRows) {
|
||||
return this.execute(query, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
})
|
||||
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
|
||||
const client = Object.assign(
|
||||
(yield* SqlClient.make({
|
||||
acquirer,
|
||||
compiler,
|
||||
transactionAcquirer,
|
||||
transactionService: WorkerdTransaction,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
[ATTR_DB_SYSTEM_NAME, "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
})) as SqliteClient,
|
||||
{
|
||||
[TypeId]: TypeId,
|
||||
config: options,
|
||||
withTransaction: makeWithTransaction(native, connection, semaphore),
|
||||
// Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such
|
||||
// as the drizzle session must route through withTransaction instead.
|
||||
transactionStatements: false,
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
})
|
||||
|
||||
// Defends against the shared path-based Database.layer, which passes a
|
||||
// filename instead of storage when resolved under the workerd condition.
|
||||
const nativeLayer = (config: Config) =>
|
||||
config.storage
|
||||
? Layer.succeed(Sqlite.Native, config.storage)
|
||||
: Layer.effect(
|
||||
Sqlite.Native,
|
||||
Effect.die(
|
||||
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
|
||||
),
|
||||
)
|
||||
|
||||
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
|
||||
|
||||
const drizzleLayer = Layer.effect(
|
||||
Sqlite.Drizzle,
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as DurableObjectStorage
|
||||
return drizzle(native) as unknown as Sqlite.DrizzleClient
|
||||
}),
|
||||
)
|
||||
|
||||
export const sqliteLayer = (config: Config) => {
|
||||
const native = nativeLayer(config)
|
||||
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
|
||||
Layer.provide(Reactivity.layer),
|
||||
)
|
||||
}
|
||||
@@ -158,42 +158,45 @@ export const fromCatalogModel = (
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
|
||||
resolveCatalogModel(model, credential, dependencies).pipe(
|
||||
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
|
||||
)
|
||||
|
||||
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) {
|
||||
const resolved = prepareRuntimeModel(model, credential)
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> => {
|
||||
const prepared = prepareRuntimeModel(model, credential)
|
||||
if (prepared.unresolved.length > 0)
|
||||
return Effect.fail(
|
||||
new UnresolvedProviderVariablesError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variables: prepared.unresolved,
|
||||
}),
|
||||
)
|
||||
const resolved = prepared.model
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
const configuration = credential?.type === "key" ? credential.configuration : undefined
|
||||
|
||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||
const runtime = yield* prepareProviderModel(resolved)
|
||||
return withDefaults(runtime, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
|
||||
const runtime = yield* prepareProviderModel(resolved)
|
||||
return withDefaults(runtime, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (
|
||||
Provider.isAISDK(resolved.package) &&
|
||||
packageName === "@ai-sdk/openai-compatible" &&
|
||||
typeof resolved.settings?.baseURL === "string"
|
||||
) {
|
||||
const runtime = yield* prepareProviderModel(resolved)
|
||||
return withDefaults(runtime, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
|
||||
const mapping = Provider.isAISDK(resolved.package)
|
||||
@@ -205,107 +208,64 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
: undefined
|
||||
const native = mapping?.package ?? resolved.package
|
||||
if (Provider.isAISDK(resolved.package) && !mapping) {
|
||||
const loadAISDK = dependencies?.loadAISDK
|
||||
if (!loadAISDK) return yield* unsupported(resolved)
|
||||
const settings = yield* prepareProviderSettings(
|
||||
resolved,
|
||||
Provider.mergeOverlay(resolved.settings, {
|
||||
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
...nativeCredentialSettings(resolved.package ?? "", credential),
|
||||
...credential?.metadata,
|
||||
...configuration,
|
||||
}) ?? {},
|
||||
)
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = settings
|
||||
})
|
||||
})
|
||||
return yield* loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!native) return yield* unsupported(resolved)
|
||||
if (!native) return Effect.fail(unsupported(resolved))
|
||||
|
||||
const specifier = native
|
||||
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
|
||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
return LanguageModel.update(runtime, {
|
||||
provider: resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
? Object.assign({}, runtime.compatibility, resolved.compatibility)
|
||||
: runtime.compatibility,
|
||||
})
|
||||
},
|
||||
catch: () => unsupported(resolved),
|
||||
return Effect.gen(function* () {
|
||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const mapped = mapping?.settings ?? configured
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
return LanguageModel.update(runtime, {
|
||||
provider: resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
? Object.assign({}, runtime.compatibility, resolved.compatibility)
|
||||
: runtime.compatibility,
|
||||
})
|
||||
},
|
||||
catch: () => unsupported(resolved),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
|
||||
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
|
||||
return produce(model, (draft) => {
|
||||
const prepared = produce(model, (draft) => {
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
|
||||
if (typeof draft.settings?.baseURL !== "string") return
|
||||
draft.settings.baseURL = draft.settings.baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => {
|
||||
return process.env[name] ?? placeholder
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function validateProviderVariables(
|
||||
model: Info,
|
||||
resolved: LanguageModel,
|
||||
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
|
||||
const baseURL = resolved.route.endpoint.baseURL
|
||||
if (typeof baseURL !== "string") return Effect.succeed(resolved)
|
||||
const failure = unresolvedProviderVariables(model, baseURL)
|
||||
return failure ? Effect.fail(failure) : Effect.succeed(resolved)
|
||||
}
|
||||
|
||||
function prepareProviderModel(model: Info): Effect.Effect<Info, UnresolvedProviderVariablesError> {
|
||||
if (!model.settings) return Effect.succeed(model)
|
||||
return prepareProviderSettings(model, model.settings).pipe(
|
||||
Effect.map((settings) =>
|
||||
settings === model.settings
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
draft.settings = settings
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function prepareProviderSettings(
|
||||
model: Info,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
|
||||
const baseURL = settings.baseURL
|
||||
if (typeof baseURL !== "string") return Effect.succeed(settings)
|
||||
return prepareProviderURL(model, baseURL).pipe(
|
||||
Effect.map((prepared) => (prepared === baseURL ? settings : { ...settings, baseURL: prepared })),
|
||||
)
|
||||
}
|
||||
|
||||
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
|
||||
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
|
||||
const failure = unresolvedProviderVariables(model, prepared)
|
||||
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
|
||||
}
|
||||
|
||||
function unresolvedProviderVariables(model: Info, baseURL: string) {
|
||||
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
|
||||
if (variables.size === 0) return
|
||||
return new UnresolvedProviderVariablesError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variables: Array.from(variables),
|
||||
})
|
||||
const baseURL = prepared.settings?.baseURL
|
||||
const unresolved =
|
||||
typeof baseURL === "string"
|
||||
? Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]).filter(
|
||||
(name, index, names) => names.indexOf(name) === index,
|
||||
)
|
||||
: []
|
||||
return { model: prepared, unresolved }
|
||||
}
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Model } from "./model"
|
||||
import { Provider } from "./provider"
|
||||
import { KV } from "./kv"
|
||||
import snapshotText from "./models-dev/snapshot.txt" with { type: "text" }
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
||||
@@ -520,6 +519,8 @@ function modelInfo(
|
||||
|
||||
export { Event } from "@opencode-ai/schema/models-dev"
|
||||
|
||||
declare const OPENCODE_MODELS_DEV: Record<string, SourceProvider> | undefined
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<readonly Snapshot[]>
|
||||
readonly refresh: (force?: boolean) => Effect.Effect<void>
|
||||
@@ -529,17 +530,12 @@ export const Options = Schema.Struct({
|
||||
url: Schema.optional(Schema.String),
|
||||
file: Schema.optional(Schema.String),
|
||||
fetch: Schema.optional(Schema.Boolean),
|
||||
snapshot: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
|
||||
|
||||
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const decodeCatalog = (text: string) =>
|
||||
Schema.decodeUnknownEffect(CatalogJson)(text).pipe(
|
||||
Effect.map((catalog) => catalog as Record<string, SourceProvider>),
|
||||
)
|
||||
const Cache = Schema.Struct({
|
||||
updatedAt: Schema.Number,
|
||||
body: CatalogJson,
|
||||
@@ -609,15 +605,13 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
: Effect.succeed(undefined)
|
||||
|
||||
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
|
||||
// packages/core/src/models-dev/snapshot.txt and refreshed via
|
||||
// `bun run script/update-models-snapshot.ts`. It is the boot-time floor
|
||||
// for the catalog; the periodic fetch below still refreshes on top.
|
||||
const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : decodeCatalog(snapshotText)
|
||||
const loadSnapshot = Effect.sync(() =>
|
||||
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
|
||||
)
|
||||
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
const catalog = yield* decodeCatalog(text)
|
||||
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
|
||||
// Best-effort: a cache-write failure must never kill catalog
|
||||
// population. The payload has outgrown some KV backends' per-value
|
||||
// limits (Durable Object SQLite caps values at 2 MB and api.json
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,9 @@
|
||||
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { Instructions } from "../instructions/index"
|
||||
import { InstructionState } from "./instruction-state"
|
||||
@@ -10,8 +11,6 @@ import { SessionMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
@@ -50,7 +49,7 @@ const messageRows = Effect.fnUntraced(function* (
|
||||
})
|
||||
|
||||
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
SessionMessageRow.decode(row).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export * as SessionMessageRow from "./message-row"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { SessionMessage } from "./message"
|
||||
import type { SessionMessageTable } from "./sql"
|
||||
|
||||
export type Representation = Pick<typeof SessionMessageTable.$inferSelect, "id" | "type" | "data">
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const decodeMessageSync = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
||||
export const decode = (row: Representation) => decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
|
||||
export const decodeSync = (row: Representation) => decodeMessageSync({ ...row.data, id: row.id, type: row.type })
|
||||
|
||||
export function encode(message: SessionMessage.Info): Representation {
|
||||
const { id, type, ...data } = encodeMessage(message)
|
||||
return { id: SessionMessage.ID.make(id), type, data }
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { Bus } from "../bus"
|
||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable, SessionPendingTable } from "./sql"
|
||||
|
||||
@@ -35,7 +36,6 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
|
||||
const encodeUser = Schema.encodeSync(UserData)
|
||||
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
|
||||
|
||||
@@ -113,7 +113,7 @@ const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(func
|
||||
if (row === undefined) return undefined
|
||||
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const message = SessionMessageRow.decodeSync(row)
|
||||
const base = { id, sessionID, timeCreated: message.time.created, delivery }
|
||||
if (message.type === "user")
|
||||
return User.make({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Agent } from "../agent"
|
||||
import { Model } from "../model"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionPending } from "./pending"
|
||||
import { Workspace } from "../workspace"
|
||||
@@ -22,9 +23,6 @@ type DatabaseService = Database.Interface["db"]
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
||||
export class SessionAlreadyProjected extends Error {}
|
||||
|
||||
type Usage = {
|
||||
@@ -210,22 +208,15 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
|
||||
function run(db: DatabaseService, event: MessageEvent) {
|
||||
return Effect.gen(function* () {
|
||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => SessionMessageRow.decodeSync(row)
|
||||
const updateMessage = (message: SessionMessage.Info) => {
|
||||
if (event.durable === undefined)
|
||||
return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
const row = SessionMessageRow.encode(message)
|
||||
return db
|
||||
.update(SessionMessageTable)
|
||||
.set({ type, time_created: DateTime.toEpochMillis(message.time.created), data })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.id, SessionMessage.ID.make(id)),
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
),
|
||||
)
|
||||
.set({ type: row.type, time_created: DateTime.toEpochMillis(message.time.created), data: row.data })
|
||||
.where(and(eq(SessionMessageTable.id, row.id), eq(SessionMessageTable.session_id, event.data.sessionID)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
@@ -343,17 +334,16 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
|
||||
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
|
||||
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
const row = SessionMessageRow.encode(message)
|
||||
return db
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(id),
|
||||
id: row.id,
|
||||
session_id: event.data.sessionID,
|
||||
type,
|
||||
type: row.type,
|
||||
seq: event.durable.seq,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
data: row.data,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RelativePath } from "../schema"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable } from "./sql"
|
||||
|
||||
@@ -46,10 +47,9 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const files = new Map<RelativePath, Snapshot.ID>()
|
||||
for (const row of rows) {
|
||||
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
||||
const message = yield* SessionMessageRow.decode(row).pipe(Effect.orDie)
|
||||
if (message.type !== "assistant" || !message.snapshot?.start) continue
|
||||
for (const file of message.snapshot.files ?? [])
|
||||
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export * as SessionStore from "./store"
|
||||
|
||||
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionHistory } from "./history"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessageTable, SessionTable } from "./sql"
|
||||
import { fromRow } from "./info"
|
||||
@@ -51,8 +52,6 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
return Service.of({
|
||||
get: Effect.fn("SessionStore.get")(function* (sessionID) {
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||
@@ -71,7 +70,7 @@ const layer = Layer.effect(
|
||||
return row
|
||||
? {
|
||||
sessionID: Session.ID.make(row.session_id),
|
||||
message: yield* decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie),
|
||||
message: yield* SessionMessageRow.decode(row).pipe(Effect.orDie),
|
||||
}
|
||||
: undefined
|
||||
}),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Session } from "../session"
|
||||
import { Slug } from "../util/slug"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionProjector } from "./projector"
|
||||
import { SessionMessageTable, SessionTable } from "./sql"
|
||||
|
||||
@@ -47,8 +48,6 @@ const layer = Layer.effect(
|
||||
const { db } = yield* Database.Service
|
||||
const projects = yield* Project.Service
|
||||
const sessions = yield* Session.Service
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
|
||||
return Service.of({
|
||||
@@ -71,15 +70,14 @@ const layer = Layer.effect(
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* persistProject(project)
|
||||
const messages = input.data.messages.map((message, index) => {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id: _, type, ...data } = encoded
|
||||
const row = SessionMessageRow.encode(message)
|
||||
return {
|
||||
id: message.id,
|
||||
session_id: sessionID,
|
||||
type,
|
||||
type: row.type,
|
||||
seq: index + 1,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
data: row.data,
|
||||
}
|
||||
})
|
||||
yield* bus
|
||||
|
||||
@@ -123,16 +123,6 @@ describe("AISDKNative", () => {
|
||||
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe(
|
||||
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
|
||||
)
|
||||
expect(
|
||||
map(
|
||||
"@ai-sdk/amazon-bedrock/mantle",
|
||||
{
|
||||
region: "us-west-2",
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
|
||||
},
|
||||
"openai.gpt-5.5",
|
||||
),
|
||||
).toMatchObject({ settings: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" } })
|
||||
})
|
||||
|
||||
test("maps static Bedrock Mantle credentials without leaking connection options", () => {
|
||||
@@ -144,9 +134,8 @@ describe("AISDKNative", () => {
|
||||
accessKeyId: "key",
|
||||
secretAccessKey: "secret",
|
||||
sessionToken: "session",
|
||||
region: "eu-west-1",
|
||||
},
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/v1",
|
||||
region: "eu-west-1",
|
||||
profile: "ignored",
|
||||
credentialProvider: "ignored",
|
||||
fetch: "ignored",
|
||||
@@ -163,7 +152,7 @@ describe("AISDKNative", () => {
|
||||
sessionToken: "session",
|
||||
region: "eu-west-1",
|
||||
},
|
||||
baseURL: "https://bedrock-mantle.eu-west-1.api.aws/v1",
|
||||
region: "eu-west-1",
|
||||
providerOptions: { openai: { store: false } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -84,19 +84,6 @@ describe("DatabaseMigration", () => {
|
||||
).rejects.toThrow("Database is not empty and has no session table")
|
||||
})
|
||||
|
||||
test("bootstraps alongside underscore-prefixed embedder tables", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE _embedder_state (id text PRIMARY KEY)`)
|
||||
yield* DatabaseMigration.apply(db)
|
||||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_v2'`)).toEqual(
|
||||
{ name: "session_v2" },
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("applies generic migrations once and records their order", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -123,24 +123,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves environment templates before native providers inspect endpoints", () =>
|
||||
withEnv({ AZURE_HOST: "resource.openai.azure.com" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), {
|
||||
providerID: Provider.ID.azure,
|
||||
settings: { baseURL: "https://${AZURE_HOST}/openai" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route.endpoint).toMatchObject({
|
||||
baseURL: "https://resource.openai.azure.com/openai/v1",
|
||||
query: { "api-version": "v1" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = Credential.Key.make({ type: "key", key: "secret" })
|
||||
@@ -170,46 +152,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves Bedrock Mantle catalog endpoints from the configured region", () =>
|
||||
withEnv({ AWS_REGION: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
providerID: Provider.ID.amazonBedrock,
|
||||
modelID: "openai.gpt-5.5",
|
||||
settings: {
|
||||
region: "us-west-2",
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
|
||||
},
|
||||
})
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
|
||||
|
||||
expect(resolved.route).toMatchObject({
|
||||
id: "bedrock-mantle-responses",
|
||||
endpoint: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" },
|
||||
})
|
||||
expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers the configured Mantle region over the environment", () =>
|
||||
withEnv({ AWS_REGION: "us-east-1" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
modelID: "openai.gpt-5.5",
|
||||
settings: {
|
||||
region: "us-west-2",
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://bedrock-mantle.us-west-2.api.aws/openai/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
@@ -323,7 +265,7 @@ describe("ModelResolver", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects unresolved variables in constructed provider routes", () =>
|
||||
it.effect("rejects unresolved provider URL variables before route construction", () =>
|
||||
withEnv({ REQUIRED_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
@@ -881,63 +823,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unresolved variables before loading opaque AISDK packages", () =>
|
||||
withEnv({ REQUIRED_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
|
||||
}),
|
||||
undefined,
|
||||
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
variables: ["REQUIRED_HOST"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects placeholders introduced by environment expansion before loading providers", () =>
|
||||
withEnv({ PROVIDER_HOST: "${MISSING_HOST}", MISSING_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { baseURL: "https://${PROVIDER_HOST}/v1" },
|
||||
}),
|
||||
undefined,
|
||||
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
variables: ["MISSING_HOST"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects unresolved variables before loading native provider packages", () =>
|
||||
withEnv({ REQUIRED_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
|
||||
}),
|
||||
undefined,
|
||||
{ loadPackage: () => Effect.die("Native package loader should not be called") },
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
variables: ["REQUIRED_HOST"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects AISDK packages without an available loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
|
||||
@@ -228,20 +228,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(
|
||||
Effect.provide(buildLayer(state, cache, { fetch: false, snapshot: false })),
|
||||
)
|
||||
expect(result).toEqual([])
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() falls back to the bundled snapshot when KV is empty and fetch is disabled", () =>
|
||||
it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
@@ -250,9 +237,7 @@ describe("ModelsDev Service", () => {
|
||||
cache,
|
||||
ModelsDev.Service.use((s) => s.get()),
|
||||
)
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
const anthropic = result.find((snapshot) => snapshot.info.id === "anthropic")
|
||||
expect(anthropic?.environment).toContain("ANTHROPIC_API_KEY")
|
||||
expect(result).toEqual([])
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls).toEqual([])
|
||||
}),
|
||||
@@ -263,7 +248,7 @@ describe("ModelsDev Service", () => {
|
||||
const cache = makeCache()
|
||||
writeCacheText(cache, "{")
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
|
||||
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
@@ -278,7 +263,7 @@ describe("ModelsDev Service", () => {
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const layer = Layer.fresh(
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeFailingWriteKV(cache)],
|
||||
]),
|
||||
@@ -296,7 +281,7 @@ describe("ModelsDev Service", () => {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
yield* ModelsDev.Service.use((service) => service.get()).pipe(
|
||||
Effect.provide(buildLayer(state, cache, { url: "", fetch: true, snapshot: false })),
|
||||
Effect.provide(buildLayer(state, cache, { url: "", fetch: true })),
|
||||
)
|
||||
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
|
||||
}),
|
||||
@@ -311,7 +296,7 @@ describe("ModelsDev Service", () => {
|
||||
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true, snapshot: false })))
|
||||
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true })))
|
||||
for (const result of results) expect(result).toEqual(fixtureSnapshot)
|
||||
expect((yield* Ref.get(state)).calls.length).toBe(1)
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionMessageRow } from "@opencode-ai/core/session/message-row"
|
||||
|
||||
const message = SessionMessage.Synthetic.make({
|
||||
id: SessionMessage.ID.make("msg_row"),
|
||||
type: "synthetic",
|
||||
text: "hello",
|
||||
time: { created: DateTime.makeUnsafe(1_000) },
|
||||
})
|
||||
|
||||
test("round trips the persisted message representation", async () => {
|
||||
const row = SessionMessageRow.encode(message)
|
||||
|
||||
expect(row.id).toBe(message.id)
|
||||
expect(row.type).toBe(message.type)
|
||||
expect(row.data).toHaveProperty("text", message.text)
|
||||
expect(row.data).toHaveProperty("time.created", 1_000)
|
||||
expect(await Effect.runPromise(SessionMessageRow.decode(row))).toEqual(message)
|
||||
expect(SessionMessageRow.decodeSync(row)).toEqual(message)
|
||||
})
|
||||
|
||||
test("canonical columns override stale values in message data", () => {
|
||||
const row = SessionMessageRow.encode(message)
|
||||
const data = {
|
||||
...row.data,
|
||||
id: SessionMessage.ID.make("msg_stale"),
|
||||
type: "system" as const,
|
||||
}
|
||||
|
||||
expect(SessionMessageRow.decodeSync({ ...row, data })).toEqual(message)
|
||||
})
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Database } from "bun:sqlite"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
|
||||
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
|
||||
// Emulates the Durable Object storage API over bun:sqlite so the adapter can
|
||||
// be verified without workerd or Cloudflare runtime dependencies.
|
||||
const makeFakeStorage = () => {
|
||||
const native = new Database(":memory:")
|
||||
const toSqlStorageValue = (value: unknown) => {
|
||||
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
|
||||
const buffer = new ArrayBuffer(value.byteLength)
|
||||
new Uint8Array(buffer).set(value)
|
||||
return buffer
|
||||
}
|
||||
const storage: DurableObjectStorage = {
|
||||
sql: {
|
||||
exec(query: string, ...bindings: Array<unknown>) {
|
||||
const statement = native.query(query)
|
||||
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
|
||||
const columnNames = statement.columnNames
|
||||
return {
|
||||
columnNames,
|
||||
raw: () => rows[Symbol.iterator](),
|
||||
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
|
||||
}
|
||||
},
|
||||
},
|
||||
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
|
||||
native.run("BEGIN")
|
||||
let rolledBack = false
|
||||
return closure({ rollback: () => (rolledBack = true) }).then(
|
||||
(result) => {
|
||||
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
|
||||
return result
|
||||
},
|
||||
(error) => {
|
||||
native.run("ROLLBACK")
|
||||
throw error
|
||||
},
|
||||
)
|
||||
},
|
||||
transactionSync<T>(closure: () => T): T {
|
||||
return native.transaction(closure)()
|
||||
},
|
||||
}
|
||||
return storage
|
||||
}
|
||||
|
||||
const run = <A, E>(storage: DurableObjectStorage, effect: Effect.Effect<A, E, SqlClient.SqlClient>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(sqliteLayer({ storage })), Effect.scoped))
|
||||
|
||||
describe("sqlite.workerd", () => {
|
||||
test("executes statements with bindings and maps rows to records", async () => {
|
||||
const rows = await run(
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`
|
||||
yield* sql`INSERT INTO item (id, name) VALUES (${1}, ${"one"}), (${2}, ${"two"})`
|
||||
return yield* sql<{ id: number; name: string }>`SELECT id, name FROM item ORDER BY id`
|
||||
}),
|
||||
)
|
||||
expect(rows).toEqual([
|
||||
{ id: 1, name: "one" },
|
||||
{ id: 2, name: "two" },
|
||||
])
|
||||
})
|
||||
|
||||
test("normalizes ArrayBuffer blob values to Uint8Array", async () => {
|
||||
const rows = await run(
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE blob (data BLOB NOT NULL)`
|
||||
yield* sql`INSERT INTO blob (data) VALUES (${new Uint8Array([1, 2, 3])})`
|
||||
return yield* sql<{ data: Uint8Array }>`SELECT data FROM blob`
|
||||
}),
|
||||
)
|
||||
expect(rows[0].data).toBeInstanceOf(Uint8Array)
|
||||
expect(Array.from(rows[0].data)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("withTransaction commits on success and rolls back on failure", async () => {
|
||||
const storage = makeFakeStorage()
|
||||
const count = await run(
|
||||
storage,
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
|
||||
yield* sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"kept"})`)
|
||||
yield* sql
|
||||
.withTransaction(
|
||||
Effect.gen(function* () {
|
||||
yield* sql`INSERT INTO t (value) VALUES (${"discarded"})`
|
||||
return yield* Effect.fail("rollback")
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.ignore)
|
||||
return yield* sql<{ count: number }>`SELECT count(*) AS count FROM t`
|
||||
}),
|
||||
)
|
||||
expect(count[0].count).toBe(1)
|
||||
})
|
||||
|
||||
test("nested withTransaction fails with SqlError", async () => {
|
||||
const error = await run(
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
|
||||
return yield* sql
|
||||
.withTransaction(sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"nested"})`))
|
||||
.pipe(Effect.flip)
|
||||
}),
|
||||
)
|
||||
expect(error).toBeInstanceOf(SqlError)
|
||||
})
|
||||
|
||||
test("boots the full database layer with migrations over injected storage", async () => {
|
||||
const storage = makeFakeStorage()
|
||||
const core = await import("@opencode-ai/core/database/database")
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Layer.build(
|
||||
core.Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })), Layer.provide(tempGlobalLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
const names = storage.sql
|
||||
.exec("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.toArray()
|
||||
.map((row) => row.name)
|
||||
expect(names).toContain("migration")
|
||||
expect(names).toContain("session_v2")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user