mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-03 23:31:41 +08:00
Compare commits
13 Commits
kit/cli-fl
...
effect-dri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ff83a98d | ||
|
|
fd4887d45d | ||
|
|
25e546c837 | ||
|
|
f948a1e3b0 | ||
|
|
46996e5a67 | ||
|
|
322bb01257 | ||
|
|
aaa42cca07 | ||
|
|
8e2c15214e | ||
|
|
4faa6c64d6 | ||
|
|
f6f6cd0515 | ||
|
|
efcbc153ee | ||
|
|
e4ae265d8f | ||
|
|
89efce865d |
16
bun.lock
16
bun.lock
@@ -307,6 +307,19 @@
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.10",
|
||||
},
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.33",
|
||||
@@ -396,6 +409,7 @@
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -1572,6 +1586,8 @@
|
||||
|
||||
"@opencode-ai/desktop-electron": ["@opencode-ai/desktop-electron@workspace:packages/desktop-electron"],
|
||||
|
||||
"@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/function": ["@opencode-ai/function@workspace:packages/function"],
|
||||
|
||||
24
packages/effect-drizzle-sqlite/package.json
Normal file
24
packages/effect-drizzle-sqlite/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
246
packages/effect-drizzle-sqlite/src/index.ts
Normal file
246
packages/effect-drizzle-sqlite/src/index.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { drizzle as drizzleBun, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
||||
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
|
||||
import { SQLiteCountBuilder } from "drizzle-orm/sqlite-core/query-builders/count"
|
||||
import { SQLiteDeleteBase } from "drizzle-orm/sqlite-core/query-builders/delete"
|
||||
import { SQLiteInsertBase } from "drizzle-orm/sqlite-core/query-builders/insert"
|
||||
import { SQLiteRelationalQuery, SQLiteSyncRelationalQuery } from "drizzle-orm/sqlite-core/query-builders/_query"
|
||||
import { SQLiteSelectBase } from "drizzle-orm/sqlite-core/query-builders/select"
|
||||
import { SQLiteUpdateBase } from "drizzle-orm/sqlite-core/query-builders/update"
|
||||
import type { PreparedQueryConfig, SQLiteSession, SQLiteTransaction, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
|
||||
import { SQLitePreparedQuery } from "drizzle-orm/sqlite-core/session"
|
||||
import type { DrizzleConfig } from "drizzle-orm/utils"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import * as Effectable from "effect/Effectable"
|
||||
|
||||
export class EffectDrizzleQueryError extends Schema.TaggedErrorClass<EffectDrizzleQueryError>()(
|
||||
"EffectDrizzleQueryError",
|
||||
{
|
||||
query: Schema.String,
|
||||
params: Schema.Array(Schema.Unknown),
|
||||
cause: Schema.Unknown,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Failed query: ${this.query}\nparams: ${this.params}`
|
||||
}
|
||||
|
||||
constructor(params: { readonly query: string; readonly params: ReadonlyArray<unknown>; readonly cause: unknown }) {
|
||||
super(params)
|
||||
Error.captureStackTrace?.(this, EffectDrizzleQueryError)
|
||||
}
|
||||
}
|
||||
|
||||
export type EffectSQLiteDatabase<
|
||||
TSchema extends Record<string, unknown> = Record<string, never>,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
> = SQLiteBunDatabase<TSchema, TRelations> & {
|
||||
readonly $client: Database
|
||||
readonly withTransaction: <A, E, R>(
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
config?: SQLiteTransactionConfig,
|
||||
) => Effect.Effect<A, E, R>
|
||||
}
|
||||
|
||||
export type MakeConfig<
|
||||
TSchema extends Record<string, unknown> = Record<string, never>,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
> = DrizzleConfig<TSchema, TRelations> & {
|
||||
readonly client?: Database
|
||||
}
|
||||
|
||||
type EffectLikeQuery<A = unknown> = {
|
||||
readonly asEffect?: () => Effect.Effect<A, EffectDrizzleQueryError>
|
||||
readonly toSQL?: () => { readonly sql: string; readonly params?: readonly unknown[] }
|
||||
}
|
||||
|
||||
type PreparedLike<A = unknown> = EffectLikeQuery<A> & {
|
||||
readonly execute: () => unknown
|
||||
readonly getQuery?: () => { readonly sql: string; readonly params?: readonly unknown[] }
|
||||
}
|
||||
|
||||
type SelectLike<A = unknown> = EffectLikeQuery<A> & {
|
||||
readonly all: () => A
|
||||
}
|
||||
|
||||
type GetLike<A = unknown> = EffectLikeQuery & {
|
||||
readonly get: () => A
|
||||
}
|
||||
|
||||
type MutationLike<A = unknown> = EffectLikeQuery<A> & {
|
||||
readonly all: () => A
|
||||
readonly run: () => A
|
||||
readonly config?: { readonly returning?: unknown }
|
||||
}
|
||||
|
||||
type CountLike = EffectLikeQuery<number> & {
|
||||
readonly session: { readonly values: (sql: unknown) => unknown[][] }
|
||||
readonly sql: unknown
|
||||
}
|
||||
|
||||
class TransactionFailure extends Error {
|
||||
constructor(readonly effectCause: Cause.Cause<unknown>) {
|
||||
super("Effect transaction failed")
|
||||
}
|
||||
}
|
||||
|
||||
const queryInfo = (query: EffectLikeQuery | PreparedLike) => {
|
||||
const info = "getQuery" in query && typeof query.getQuery === "function" ? query.getQuery() : query.toSQL?.()
|
||||
return {
|
||||
query: info?.sql ?? "<unknown>",
|
||||
params: [...(info?.params ?? [])],
|
||||
}
|
||||
}
|
||||
|
||||
const queryError = (query: EffectLikeQuery | PreparedLike, cause: unknown) =>
|
||||
new EffectDrizzleQueryError({
|
||||
...queryInfo(query),
|
||||
cause,
|
||||
})
|
||||
|
||||
const fromSync = <A>(query: EffectLikeQuery, run: () => A) =>
|
||||
Effect.try({
|
||||
try: run,
|
||||
catch: (cause) => queryError(query, cause),
|
||||
})
|
||||
|
||||
const fromMutation = (query: MutationLike) => fromSync(query, () => (query.config?.returning ? query.all() : query.run()))
|
||||
|
||||
const fromCount = (query: CountLike) => fromSync(query, () => Number(query.session.values(query.sql)[0]?.[0] ?? 0))
|
||||
|
||||
export const getOne = <A>(query: GetLike<A>) => fromSync(query, () => query.get())
|
||||
|
||||
const fromExecuteResult = (result: unknown) => {
|
||||
if (result && typeof result === "object" && "sync" in result && typeof result.sync === "function") {
|
||||
return result.sync()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const queryEffectProto = {
|
||||
...Effectable.Prototype<Effect.Effect<unknown, EffectDrizzleQueryError> & EffectLikeQuery>({
|
||||
label: "DrizzleSqliteQuery",
|
||||
evaluate(this: EffectLikeQuery) {
|
||||
return this.asEffect?.() ?? Effect.die("Drizzle SQLite query is missing asEffect()")
|
||||
},
|
||||
}),
|
||||
commit(this: EffectLikeQuery) {
|
||||
return this.asEffect?.() ?? Effect.die("Drizzle SQLite query is missing asEffect()")
|
||||
},
|
||||
}
|
||||
|
||||
const patchClass = <A>(ctor: { readonly prototype: object }, asEffect: (self: A) => Effect.Effect<unknown, EffectDrizzleQueryError>) => {
|
||||
if (Object.prototype.hasOwnProperty.call(ctor.prototype, "asEffect")) return
|
||||
Object.assign(ctor.prototype, queryEffectProto, {
|
||||
asEffect(this: A) {
|
||||
return asEffect(this)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// `patchClass` is idempotent via `hasOwnProperty` check, so calling this
|
||||
// repeatedly is cheap. Patches are applied to Drizzle prototypes globally and
|
||||
// survive any Database close/reopen cycle.
|
||||
const patchQueryBuilders = () => {
|
||||
patchClass(SQLitePreparedQuery, (query: PreparedLike) => fromSync(query, () => fromExecuteResult(query.execute())))
|
||||
patchClass(SQLiteSelectBase, (query: SelectLike) => fromSync(query, () => query.all()))
|
||||
patchClass(SQLiteInsertBase, fromMutation)
|
||||
patchClass(SQLiteUpdateBase, fromMutation)
|
||||
patchClass(SQLiteDeleteBase, fromMutation)
|
||||
patchClass(SQLiteRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) =>
|
||||
fromSync(query, () => query.executeRaw()),
|
||||
)
|
||||
patchClass(SQLiteSyncRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) =>
|
||||
fromSync(query, () => query.executeRaw()),
|
||||
)
|
||||
patchClass(SQLiteCountBuilder, fromCount)
|
||||
}
|
||||
|
||||
const attachTransaction = <
|
||||
TSchema extends Record<string, unknown> = Record<string, never>,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
>(db: SQLiteBunDatabase<TSchema, TRelations> & { readonly $client: Database }): EffectSQLiteDatabase<TSchema, TRelations> => {
|
||||
const txStack: Array<SQLiteTransaction<"sync", void, TSchema, TRelations>> = []
|
||||
const bound = new WeakMap<object, Map<PropertyKey, unknown>>()
|
||||
const current = () => txStack.at(-1) ?? db
|
||||
const runTransaction = (target: SQLiteBunDatabase<TSchema, TRelations> | SQLiteTransaction<"sync", void, TSchema, TRelations>) =>
|
||||
target.transaction.bind(target) as (
|
||||
transaction: (tx: SQLiteTransaction<"sync", void, TSchema, TRelations>) => unknown,
|
||||
config?: SQLiteTransactionConfig,
|
||||
) => unknown
|
||||
|
||||
const withTransaction = <A, E, R>(
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
config?: SQLiteTransactionConfig,
|
||||
): Effect.Effect<A, E, R> =>
|
||||
Effect.context<R>().pipe(
|
||||
Effect.flatMap((context) =>
|
||||
Effect.sync(
|
||||
() =>
|
||||
runTransaction(current())((tx) => {
|
||||
txStack.push(tx)
|
||||
try {
|
||||
const exit = Effect.runSyncExit(Effect.provideContext(effect, context))
|
||||
if (Exit.isSuccess(exit)) return exit.value
|
||||
throw new TransactionFailure(exit.cause)
|
||||
} finally {
|
||||
txStack.pop()
|
||||
}
|
||||
}, config) as A,
|
||||
).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof TransactionFailure ? Effect.failCause(defect.effectCause as Cause.Cause<E>) : Effect.die(defect),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return new Proxy(db, {
|
||||
get(_target, property) {
|
||||
if (property === "withTransaction") return withTransaction
|
||||
if (property === "$client") return db.$client
|
||||
|
||||
const target = current()
|
||||
const value = Reflect.get(target, property)
|
||||
if (typeof value !== "function") return value
|
||||
const methods = bound.get(target) ?? new Map<PropertyKey, unknown>()
|
||||
bound.set(target, methods)
|
||||
if (!methods.has(property)) methods.set(property, value.bind(target))
|
||||
return methods.get(property)
|
||||
},
|
||||
}) as EffectSQLiteDatabase<TSchema, TRelations>
|
||||
}
|
||||
|
||||
export const make = <
|
||||
TSchema extends Record<string, unknown> = Record<string, never>,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
>(config: MakeConfig<TSchema, TRelations> = {}): EffectSQLiteDatabase<TSchema, TRelations> => {
|
||||
patchQueryBuilders()
|
||||
return attachTransaction(
|
||||
drizzleBun({
|
||||
...config,
|
||||
client: config.client ?? new Database(":memory:"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const drizzle = make
|
||||
|
||||
declare module "drizzle-orm/query-promise" {
|
||||
interface QueryPromise<T> extends Effect.Effect<T, EffectDrizzleQueryError> {
|
||||
asEffect(): Effect.Effect<T, EffectDrizzleQueryError>
|
||||
}
|
||||
}
|
||||
|
||||
declare module "drizzle-orm/sqlite-core/session" {
|
||||
interface SQLitePreparedQuery<T extends PreparedQueryConfig> extends Effect.Effect<T["execute"], EffectDrizzleQueryError> {
|
||||
asEffect(): Effect.Effect<T["execute"], EffectDrizzleQueryError>
|
||||
}
|
||||
}
|
||||
|
||||
declare module "drizzle-orm/sqlite-core/query-builders/count" {
|
||||
interface SQLiteCountBuilder<TSession extends SQLiteSession<any, any, any, any>>
|
||||
extends Effect.Effect<number, EffectDrizzleQueryError> {
|
||||
asEffect(): Effect.Effect<number, EffectDrizzleQueryError>
|
||||
}
|
||||
}
|
||||
246
packages/effect-drizzle-sqlite/test/sqlite.test.ts
Normal file
246
packages/effect-drizzle-sqlite/test/sqlite.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { Database } from "bun:sqlite"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { relations } from "drizzle-orm/_relations"
|
||||
import { drizzle as drizzleBun } from "drizzle-orm/bun-sqlite"
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { EffectDrizzleQueryError, getOne, make, type EffectSQLiteDatabase } from "../src"
|
||||
|
||||
const users = sqliteTable("users", {
|
||||
id: integer().primaryKey(),
|
||||
name: text().notNull(),
|
||||
})
|
||||
|
||||
const posts = sqliteTable("posts", {
|
||||
id: integer().primaryKey(),
|
||||
user_id: integer()
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
title: text().notNull(),
|
||||
})
|
||||
|
||||
const usersRelations = relations(users, ({ many }) => ({
|
||||
posts: many(posts),
|
||||
}))
|
||||
|
||||
const postsRelations = relations(posts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [posts.user_id],
|
||||
references: [users.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
const schema = { users, posts, usersRelations, postsRelations }
|
||||
|
||||
let db: EffectSQLiteDatabase<typeof schema>
|
||||
|
||||
const testEffect = <A, E>(name: string, effect: () => Effect.Effect<A, E>) => test(name, () => Effect.runPromise(effect()))
|
||||
|
||||
beforeEach(() => {
|
||||
db = make({ schema })
|
||||
db.$client.run("PRAGMA foreign_keys = ON")
|
||||
db.$client.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
|
||||
db.$client.run(
|
||||
"CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), title TEXT NOT NULL)",
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.$client.close()
|
||||
})
|
||||
|
||||
describe("effect drizzle sqlite", () => {
|
||||
test("keeps normal Drizzle Bun SQLite clients usable after patching", async () => {
|
||||
const sqlite = new Database(":memory:")
|
||||
try {
|
||||
const normal = drizzleBun({ client: sqlite })
|
||||
sqlite.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
|
||||
|
||||
normal.insert(users).values({ id: 1, name: "Ada" }).run()
|
||||
|
||||
expect(normal.select().from(users).all()).toEqual([{ id: 1, name: "Ada" }])
|
||||
expect(await normal.select().from(users)).toEqual([{ id: 1, name: "Ada" }])
|
||||
} finally {
|
||||
sqlite.close()
|
||||
}
|
||||
})
|
||||
|
||||
testEffect("makes select/insert/update/delete query builders yieldable Effects", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 1, name: "Ada" })
|
||||
yield* db.insert(users).values({ id: 2, name: "Grace" })
|
||||
|
||||
const selected = yield* db.select().from(users).orderBy(users.id)
|
||||
expect(selected).toEqual([
|
||||
{ id: 1, name: "Ada" },
|
||||
{ id: 2, name: "Grace" },
|
||||
])
|
||||
|
||||
const updated = yield* db.update(users).set({ name: "Lovelace" }).where(eq(users.id, 1)).returning()
|
||||
expect(updated).toEqual([{ id: 1, name: "Lovelace" }])
|
||||
|
||||
const deleted = yield* db.delete(users).where(eq(users.id, 2)).returning({ id: users.id })
|
||||
expect(deleted).toEqual([{ id: 2 }])
|
||||
|
||||
expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Lovelace" }])
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect("supports direct Effect combinators on queries", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 1, name: "Ada" })
|
||||
|
||||
expect(
|
||||
yield* (db.select().from(users) as Effect.Effect<Array<{ readonly name: string }>, EffectDrizzleQueryError>).pipe(
|
||||
Effect.map((rows) => rows.map((row) => row.name)),
|
||||
),
|
||||
).toEqual(["Ada"])
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect("supports relational query builders", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 1, name: "Ada" })
|
||||
yield* db.insert(posts).values({ id: 1, user_id: 1, title: "Notes" })
|
||||
expect(
|
||||
yield* db._query.users.findMany({
|
||||
with: {
|
||||
posts: true,
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
name: "Ada",
|
||||
posts: [{ id: 1, user_id: 1, title: "Notes" }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect("runs synchronous Effect programs inside transactions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 1, name: "Ada" })
|
||||
return yield* db.select().from(users)
|
||||
}).pipe(db.withTransaction)
|
||||
|
||||
expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Ada" }])
|
||||
|
||||
const exit = yield* Effect.exit(
|
||||
Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 2, name: "Grace" })
|
||||
return yield* Effect.fail("rollback")
|
||||
}).pipe(db.withTransaction),
|
||||
)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(yield* db.select().from(users).orderBy(users.id)).toEqual([{ id: 1, name: "Ada" }])
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect("supports pipeable transactions using the same database service", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 1, name: "Ada" })
|
||||
return yield* Effect.fail("rollback")
|
||||
}).pipe(db.withTransaction, Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(yield* db.select().from(users)).toEqual([])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 2, name: "Grace" })
|
||||
expect(yield* db.$count(users)).toBe(1)
|
||||
}).pipe(db.withTransaction)
|
||||
|
||||
expect(yield* db.select().from(users)).toEqual([{ id: 2, name: "Grace" }])
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect("supports count builders and prepared queries", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* db.insert(users).values([
|
||||
{ id: 1, name: "Ada" },
|
||||
{ id: 2, name: "Grace" },
|
||||
])
|
||||
|
||||
expect(yield* db.$count(users)).toBe(2)
|
||||
|
||||
const prepared = db.select().from(users).orderBy(users.id).prepare()
|
||||
expect(yield* prepared).toEqual([
|
||||
{ id: 1, name: "Ada" },
|
||||
{ id: 2, name: "Grace" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect("supports single-row select effects", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 1, name: "Ada" })
|
||||
|
||||
expect(yield* getOne(db.select().from(users).where(eq(users.id, 1)))).toEqual({ id: 1, name: "Ada" })
|
||||
expect(yield* getOne(db.select().from(users).where(eq(users.id, 2)))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect("nested pipeable transactions commit or roll back with the outer transaction", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 1, name: "Ada" })
|
||||
yield* Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 2, name: "Grace" })
|
||||
}).pipe(db.withTransaction)
|
||||
}).pipe(db.withTransaction)
|
||||
|
||||
expect(yield* db.select().from(users).orderBy(users.id)).toEqual([
|
||||
{ id: 1, name: "Ada" },
|
||||
{ id: 2, name: "Grace" },
|
||||
])
|
||||
|
||||
const exit = yield* Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 3, name: "Katherine" })
|
||||
yield* Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 4, name: "Dorothy" })
|
||||
return yield* Effect.fail("inner rollback")
|
||||
}).pipe(db.withTransaction)
|
||||
}).pipe(db.withTransaction, Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(yield* db.select().from(users).orderBy(users.id)).toEqual([
|
||||
{ id: 1, name: "Ada" },
|
||||
{ id: 2, name: "Grace" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect("defects inside transactions roll back and stay defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.gen(function* () {
|
||||
yield* db.insert(users).values({ id: 1, name: "Ada" })
|
||||
return yield* Effect.die("boom")
|
||||
}).pipe(db.withTransaction, Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(exit.cause.reasons.some(Cause.isDieReason)).toBe(true)
|
||||
}
|
||||
expect(yield* db.select().from(users)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect("wraps query failures with query text and parameters", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.exit(db.insert(posts).values({ id: 1, user_id: 404, title: "Missing" }))
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = exit.cause.reasons.filter(Cause.isFailReason)[0]?.error
|
||||
expect(error).toBeInstanceOf(EffectDrizzleQueryError)
|
||||
expect((error as EffectDrizzleQueryError).query).toContain("insert into")
|
||||
expect((error as EffectDrizzleQueryError).params).toEqual([1, 404, "Missing"])
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
15
packages/effect-drizzle-sqlite/tsconfig.json
Normal file
15
packages/effect-drizzle-sqlite/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"],
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "@effect/language-service",
|
||||
"transform": "@effect/language-service/transform",
|
||||
"namespaceImportPackages": ["effect", "@effect/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
|
||||
@@ -5,7 +5,8 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database } from "@/storage/db"
|
||||
import { DatabaseEffect } from "@/storage/db-effect"
|
||||
import { getOne } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -153,11 +154,15 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const db = yield* DatabaseEffect.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Permission.state")(function* (ctx) {
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
|
||||
)
|
||||
const row = yield* getOne(
|
||||
db
|
||||
.select()
|
||||
.from(PermissionTable)
|
||||
.where(eq(PermissionTable.project_id, ctx.project.id)),
|
||||
).pipe(Effect.orDie)
|
||||
const state = {
|
||||
pending: new Map<PermissionID, PendingEntry>(),
|
||||
approved: row?.data ?? [],
|
||||
@@ -319,6 +324,6 @@ export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
return result
|
||||
}
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Bus.layer), Layer.provide(DatabaseEffect.layer))
|
||||
|
||||
export * as Permission from "."
|
||||
|
||||
@@ -5,7 +5,7 @@ import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { Database } from "@/storage/db"
|
||||
import { DatabaseEffect } from "@/storage/db-effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { asc } from "drizzle-orm"
|
||||
import { TodoTable } from "./session.sql"
|
||||
@@ -42,34 +42,34 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const db = yield* DatabaseEffect.Service
|
||||
|
||||
const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) {
|
||||
yield* Effect.sync(() =>
|
||||
Database.transaction((db) => {
|
||||
db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
|
||||
if (input.todos.length === 0) return
|
||||
db.insert(TodoTable)
|
||||
.values(
|
||||
input.todos.map((todo, position) => ({
|
||||
session_id: input.sessionID,
|
||||
content: todo.content,
|
||||
status: todo.status,
|
||||
priority: todo.priority,
|
||||
position,
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID))
|
||||
if (input.todos.length === 0) return
|
||||
yield* db.insert(TodoTable).values(
|
||||
input.todos.map((todo, position) => ({
|
||||
session_id: input.sessionID,
|
||||
content: todo.content,
|
||||
status: todo.status,
|
||||
priority: todo.priority,
|
||||
position,
|
||||
})),
|
||||
)
|
||||
}).pipe(db.withTransaction, Effect.orDie)
|
||||
|
||||
yield* bus.publish(Event.Updated, input)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
|
||||
const rows = yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
|
||||
),
|
||||
)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(TodoTable)
|
||||
.where(eq(TodoTable.session_id, sessionID))
|
||||
.orderBy(asc(TodoTable.position))
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
return rows.map((row) => ({
|
||||
content: row.content,
|
||||
status: row.status,
|
||||
@@ -81,6 +81,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Bus.layer), Layer.provide(DatabaseEffect.layer))
|
||||
|
||||
export * as Todo from "./todo"
|
||||
|
||||
@@ -9,7 +9,8 @@ import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Database } from "@/storage/db"
|
||||
import { DatabaseEffect } from "@/storage/db-effect"
|
||||
import { getOne } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Config } from "@/config/config"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -76,9 +77,6 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ShareNext") {}
|
||||
|
||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||
Effect.sync(() => Database.use(fn))
|
||||
|
||||
function api(resource: string): Api {
|
||||
return {
|
||||
create: `/api/${resource}`,
|
||||
@@ -116,6 +114,7 @@ export const layer = Layer.effect(
|
||||
const httpOk = HttpClient.filterStatusOk(http)
|
||||
const provider = yield* Provider.Service
|
||||
const session = yield* Session.Service
|
||||
const db = yield* DatabaseEffect.Service
|
||||
|
||||
function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> {
|
||||
return Effect.gen(function* () {
|
||||
@@ -226,9 +225,9 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const get = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const row = yield* db((db) =>
|
||||
db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(),
|
||||
)
|
||||
const row = yield* getOne(
|
||||
db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)),
|
||||
).pipe(Effect.orDie)
|
||||
if (!row) return
|
||||
return { id: row.id, secret: row.secret, url: row.url } satisfies Share
|
||||
})
|
||||
@@ -314,16 +313,13 @@ export const layer = Layer.effect(
|
||||
Effect.flatMap((r) => httpOk.execute(r)),
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(ShareSchema)),
|
||||
)
|
||||
yield* db((db) =>
|
||||
db
|
||||
.insert(SessionShareTable)
|
||||
.values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url })
|
||||
.onConflictDoUpdate({
|
||||
target: SessionShareTable.session_id,
|
||||
set: { id: result.id, secret: result.secret, url: result.url },
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
yield* db
|
||||
.insert(SessionShareTable)
|
||||
.values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url })
|
||||
.onConflictDoUpdate({
|
||||
target: SessionShareTable.session_id,
|
||||
set: { id: result.id, secret: result.secret, url: result.url },
|
||||
})
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.shared.set(sessionID, result)
|
||||
yield* full(sessionID).pipe(
|
||||
@@ -355,7 +351,7 @@ export const layer = Layer.effect(
|
||||
Effect.flatMap((r) => httpOk.execute(r)),
|
||||
)
|
||||
|
||||
yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
|
||||
yield* db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID))
|
||||
s.shared.delete(sessionID)
|
||||
s.queue.delete(sessionID)
|
||||
})
|
||||
@@ -364,13 +360,14 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(DatabaseEffect.layer),
|
||||
)
|
||||
|
||||
export * as ShareNext from "./share-next"
|
||||
|
||||
20
packages/opencode/src/storage/db-effect.ts
Normal file
20
packages/opencode/src/storage/db-effect.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Database } from "@/storage/db"
|
||||
import { Context, Layer } from "effect"
|
||||
import type { EffectSQLiteDatabase } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import * as StorageSchema from "@/storage/schema"
|
||||
|
||||
export class Service extends Context.Service<Service, EffectSQLiteDatabase<typeof StorageSchema>>()(
|
||||
"@opencode/DatabaseEffect",
|
||||
) {}
|
||||
|
||||
const client = new Proxy({} as EffectSQLiteDatabase<typeof StorageSchema>, {
|
||||
get(_target, property) {
|
||||
const db = Database.Client()
|
||||
const value = Reflect.get(db, property)
|
||||
return typeof value === "function" ? value.bind(db) : value
|
||||
},
|
||||
})
|
||||
|
||||
export const layer = Layer.succeed(Service, client)
|
||||
|
||||
export * as DatabaseEffect from "./db-effect"
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { drizzle } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
|
||||
export function init(path: string) {
|
||||
const sqlite = new Database(path, { create: true })
|
||||
const db = drizzle({ client: sqlite })
|
||||
return db
|
||||
export function init<TSchema extends Record<string, unknown>>(path: string, schema: TSchema) {
|
||||
return drizzle({ client: new Database(path, { create: true }), schema })
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { DatabaseSync } from "node:sqlite"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite"
|
||||
|
||||
export function init(path: string) {
|
||||
const sqlite = new DatabaseSync(path)
|
||||
const db = drizzle({ client: sqlite })
|
||||
return db
|
||||
export function init<TSchema extends Record<string, unknown>>(path: string, schema: TSchema) {
|
||||
return drizzle({ client: new DatabaseSync(path), schema })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
|
||||
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
|
||||
export * from "drizzle-orm"
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
import { lazy } from "../util/lazy"
|
||||
@@ -14,6 +12,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstallationChannel } from "@opencode-ai/core/installation/version"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { iife } from "@/util/iife"
|
||||
import * as StorageSchema from "@/storage/schema"
|
||||
import { init } from "#db"
|
||||
|
||||
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
|
||||
@@ -42,16 +41,16 @@ export const Path = iife(() => {
|
||||
return getChannelPath()
|
||||
})
|
||||
|
||||
export type Transaction = SQLiteTransaction<"sync", void>
|
||||
export type Client = ReturnType<typeof open>
|
||||
|
||||
type Client = SQLiteBunDatabase
|
||||
export type Transaction = Parameters<Parameters<Client["transaction"]>[0]>[0]
|
||||
|
||||
type Journal = { sql: string; timestamp: number; name: string }[]
|
||||
|
||||
// Drizzle's migrate overloads trigger expensive variance checks here; narrow to the journal overload we actually use.
|
||||
const migrateFromJournal = migrate as unknown as (db: SQLiteBunDatabase, entries: Journal) => void
|
||||
const migrateFromJournal = migrate as unknown as (db: Client, entries: Journal) => void
|
||||
|
||||
function applyMigrations(db: SQLiteBunDatabase, entries: Journal) {
|
||||
function applyMigrations(db: Client, entries: Journal) {
|
||||
migrateFromJournal(db, entries)
|
||||
}
|
||||
|
||||
@@ -88,10 +87,10 @@ function migrations(dir: string): Journal {
|
||||
return sql.sort((a, b) => a.timestamp - b.timestamp)
|
||||
}
|
||||
|
||||
export const Client = lazy(() => {
|
||||
export function open() {
|
||||
log.info("opening database", { path: Path })
|
||||
|
||||
const db = init(Path)
|
||||
const db = init(Path, StorageSchema)
|
||||
|
||||
db.run("PRAGMA journal_mode = WAL")
|
||||
db.run("PRAGMA synchronous = NORMAL")
|
||||
@@ -119,7 +118,9 @@ export const Client = lazy(() => {
|
||||
}
|
||||
|
||||
return db
|
||||
})
|
||||
}
|
||||
|
||||
export const Client = lazy(open)
|
||||
|
||||
export function close() {
|
||||
if (!Client.loaded()) return
|
||||
@@ -140,7 +141,8 @@ export function use<T>(callback: (trx: TxOrDb) => T): T {
|
||||
} catch (err) {
|
||||
if (err instanceof LocalContext.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = []
|
||||
const result = ctx.provide({ effects, tx: Client() }, () => callback(Client()))
|
||||
const client = Client()
|
||||
const result = ctx.provide({ effects, tx: client }, () => callback(client))
|
||||
for (const effect of effects) effect()
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -15,6 +15,6 @@ export function lazy<T>(fn: () => T) {
|
||||
}
|
||||
|
||||
result.loaded = () => loaded
|
||||
|
||||
result.peek = () => (loaded ? value : undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PermissionID } from "../../src/permission/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { DatabaseEffect } from "../../src/storage/db-effect"
|
||||
import {
|
||||
disposeAllInstances,
|
||||
provideInstance,
|
||||
@@ -19,7 +20,11 @@ import { testEffect } from "../lib/effect"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
|
||||
const bus = Bus.layer
|
||||
const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer)
|
||||
const env = Layer.mergeAll(
|
||||
Permission.layer.pipe(Layer.provide(bus), Layer.provide(DatabaseEffect.layer)),
|
||||
bus,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
)
|
||||
const it = testEffect(env)
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import { Shell } from "../../src/shell/shell"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { DatabaseEffect } from "@/storage/db-effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import * as Database from "../../src/storage/db"
|
||||
@@ -170,6 +171,7 @@ function makeHttp() {
|
||||
lsp,
|
||||
mcp,
|
||||
AppFileSystem.defaultLayer,
|
||||
DatabaseEffect.layer,
|
||||
status,
|
||||
).pipe(Layer.provideMerge(infra))
|
||||
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
||||
|
||||
@@ -51,6 +51,7 @@ import { SessionStatus } from "../../src/session/status"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { DatabaseEffect } from "@/storage/db-effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
@@ -120,6 +121,7 @@ function makeHttp() {
|
||||
lsp,
|
||||
mcp,
|
||||
AppFileSystem.defaultLayer,
|
||||
DatabaseEffect.layer,
|
||||
status,
|
||||
).pipe(Layer.provideMerge(infra))
|
||||
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { SessionID } from "../../src/session/schema"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShareTable } from "../../src/share/share.sql"
|
||||
import { Database } from "@/storage/db"
|
||||
import { DatabaseEffect } from "@/storage/db-effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
@@ -39,18 +40,6 @@ const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unkno
|
||||
|
||||
const none = HttpClient.make(() => Effect.die("unexpected http call"))
|
||||
|
||||
function live(client: HttpClient.HttpClient) {
|
||||
const http = Layer.succeed(HttpClient.HttpClient, client)
|
||||
return ShareNext.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(http))),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(http),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
)
|
||||
}
|
||||
|
||||
function wired(client: HttpClient.HttpClient) {
|
||||
const http = Layer.succeed(HttpClient.HttpClient, client)
|
||||
return Layer.mergeAll(
|
||||
@@ -66,6 +55,7 @@ function wired(client: HttpClient.HttpClient) {
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(http),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(DatabaseEffect.layer),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,7 +94,7 @@ describe("ShareNext", () => {
|
||||
expect(req.baseUrl).toBe("https://legacy-share.example.com")
|
||||
expect(req.headers).toEqual({})
|
||||
}),
|
||||
).pipe(Effect.provide(live(none))),
|
||||
).pipe(Effect.provide(wired(none))),
|
||||
{ config: { enterprise: { url: "https://legacy-share.example.com" } } },
|
||||
),
|
||||
)
|
||||
@@ -119,7 +109,7 @@ describe("ShareNext", () => {
|
||||
expect(req.api.create).toBe("/api/share")
|
||||
expect(req.headers).toEqual({})
|
||||
}),
|
||||
).pipe(Effect.provide(live(none))),
|
||||
).pipe(Effect.provide(wired(none))),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -128,7 +118,7 @@ describe("ShareNext", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* seed("https://control.example.com", "org-1")
|
||||
|
||||
const req = yield* ShareNext.Service.use((svc) => svc.request()).pipe(Effect.provide(live(none)))
|
||||
const req = yield* ShareNext.Service.use((svc) => svc.request())
|
||||
|
||||
expect(req.api.create).toBe("/api/shares")
|
||||
expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync")
|
||||
@@ -139,33 +129,33 @@ describe("ShareNext", () => {
|
||||
authorization: "Bearer st_test_token",
|
||||
"x-org-id": "org-1",
|
||||
})
|
||||
}),
|
||||
}).pipe(Effect.provide(wired(none))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("create posts share, persists it, and returns the result", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service.use((svc) => svc.create({ title: "test" }))
|
||||
const seen: HttpClientRequest.HttpClientRequest[] = []
|
||||
const client = HttpClient.make((req) => {
|
||||
seen.push(req)
|
||||
if (req.url.endsWith("/api/share")) {
|
||||
return Effect.succeed(
|
||||
json(req, {
|
||||
id: "shr_abc",
|
||||
url: "https://legacy-share.example.com/share/abc",
|
||||
secret: "sec_123",
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(json(req, { ok: true }))
|
||||
})
|
||||
() => {
|
||||
const seen: HttpClientRequest.HttpClientRequest[] = []
|
||||
const client = HttpClient.make((req) => {
|
||||
seen.push(req)
|
||||
if (req.url.endsWith("/api/share")) {
|
||||
return Effect.succeed(
|
||||
json(req, {
|
||||
id: "shr_abc",
|
||||
url: "https://legacy-share.example.com/share/abc",
|
||||
secret: "sec_123",
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(json(req, { ok: true }))
|
||||
})
|
||||
|
||||
const result = yield* ShareNext.Service.use((svc) => svc.create(session.id)).pipe(
|
||||
Effect.provide(live(client)),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const shareNext = yield* ShareNext.Service
|
||||
const session = yield* sessions.create({ title: "test" })
|
||||
const result = yield* shareNext.create(session.id)
|
||||
|
||||
expect(result.id).toBe("shr_abc")
|
||||
expect(result.url).toBe("https://legacy-share.example.com/share/abc")
|
||||
@@ -179,60 +169,61 @@ describe("ShareNext", () => {
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0].method).toBe("POST")
|
||||
expect(seen[0].url).toBe("https://legacy-share.example.com/api/share")
|
||||
}),
|
||||
}).pipe(Effect.provide(wired(client)))
|
||||
},
|
||||
{ config: { enterprise: { url: "https://legacy-share.example.com" } } },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("remove deletes the persisted share and calls the delete endpoint", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service.use((svc) => svc.create({ title: "test" }))
|
||||
const seen: HttpClientRequest.HttpClientRequest[] = []
|
||||
const client = HttpClient.make((req) => {
|
||||
seen.push(req)
|
||||
if (req.method === "POST") {
|
||||
return Effect.succeed(
|
||||
json(req, {
|
||||
id: "shr_abc",
|
||||
url: "https://legacy-share.example.com/share/abc",
|
||||
secret: "sec_123",
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(req, new Response(null, { status: 200 })))
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
yield* ShareNext.Service.use((svc) => svc.create(session.id))
|
||||
yield* ShareNext.Service.use((svc) => svc.remove(session.id))
|
||||
}).pipe(Effect.provide(live(client)))
|
||||
() => {
|
||||
const seen: HttpClientRequest.HttpClientRequest[] = []
|
||||
const client = HttpClient.make((req) => {
|
||||
seen.push(req)
|
||||
if (req.method === "POST") {
|
||||
return Effect.succeed(
|
||||
json(req, {
|
||||
id: "shr_abc",
|
||||
url: "https://legacy-share.example.com/share/abc",
|
||||
secret: "sec_123",
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(req, new Response(null, { status: 200 })))
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const shareNext = yield* ShareNext.Service
|
||||
const session = yield* sessions.create({ title: "test" })
|
||||
yield* shareNext.create(session.id)
|
||||
yield* shareNext.remove(session.id)
|
||||
expect(share(session.id)).toBeUndefined()
|
||||
expect(seen.map((req) => [req.method, req.url])).toEqual([
|
||||
["POST", "https://legacy-share.example.com/api/share"],
|
||||
["DELETE", "https://legacy-share.example.com/api/share/shr_abc"],
|
||||
])
|
||||
}),
|
||||
}).pipe(Effect.provide(wired(client)))
|
||||
},
|
||||
{ config: { enterprise: { url: "https://legacy-share.example.com" } } },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("create fails on a non-ok response and does not persist a share", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service.use((svc) => svc.create({ title: "test" }))
|
||||
const client = HttpClient.make((req) => Effect.succeed(json(req, { error: "bad" }, 500)))
|
||||
provideTmpdirInstance(() => {
|
||||
const client = HttpClient.make((req) => Effect.succeed(json(req, { error: "bad" }, 500)))
|
||||
|
||||
const exit = yield* ShareNext.Service.use((svc) => Effect.exit(svc.create(session.id))).pipe(
|
||||
Effect.provide(live(client)),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const shareNext = yield* ShareNext.Service
|
||||
const session = yield* sessions.create({ title: "test" })
|
||||
const exit = yield* Effect.exit(shareNext.create(session.id))
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(share(session.id)).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
}).pipe(Effect.provide(wired(client)))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ShareNext coalesces rapid diff events into one delayed sync with latest data", () =>
|
||||
|
||||
73
packages/opencode/test/storage/db-effect.test.ts
Normal file
73
packages/opencode/test/storage/db-effect.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import { Database } from "@/storage/db"
|
||||
import { DatabaseEffect } from "@/storage/db-effect"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
|
||||
afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("DatabaseEffect.layer", () => {
|
||||
test("yields a working Service that round-trips a query", async () => {
|
||||
const rt = ManagedRuntime.make(DatabaseEffect.layer)
|
||||
try {
|
||||
const value = await rt.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* DatabaseEffect.Service
|
||||
return db.$client.prepare("SELECT 42 as n").get() as { n: number }
|
||||
}),
|
||||
)
|
||||
expect(value).toEqual({ n: 42 })
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("service resolves a fresh handle after Database.close", async () => {
|
||||
const rt = ManagedRuntime.make(DatabaseEffect.layer)
|
||||
const first = await rt.runPromise(Effect.sync(() => Database.Client().$client))
|
||||
expect(first.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
|
||||
|
||||
Database.close()
|
||||
|
||||
try {
|
||||
const second = await rt.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* DatabaseEffect.Service
|
||||
return db.$client
|
||||
}),
|
||||
)
|
||||
expect(second).not.toBe(first)
|
||||
expect(second.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("a runtime kept alive over Database.close uses the refreshed handle", async () => {
|
||||
const rt = ManagedRuntime.make(DatabaseEffect.layer)
|
||||
const captured = await rt.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* DatabaseEffect.Service
|
||||
return db.$client
|
||||
}),
|
||||
)
|
||||
expect(captured.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
|
||||
|
||||
Database.close()
|
||||
|
||||
try {
|
||||
const fresh = await rt.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* DatabaseEffect.Service
|
||||
return db.$client
|
||||
}),
|
||||
)
|
||||
expect(fresh).not.toBe(captured)
|
||||
expect(fresh.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user