progress
This commit is contained in:
parent
6bd47e1bce
commit
34bade292d
11 changed files with 404 additions and 304 deletions
|
|
@ -2,6 +2,9 @@
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"provider": {},
|
"provider": {},
|
||||||
"permission": {},
|
"permission": {},
|
||||||
|
"reference": {
|
||||||
|
"effect": "github.com/Effect-TS/effect-smol",
|
||||||
|
},
|
||||||
"mcp": {},
|
"mcp": {},
|
||||||
"tools": {
|
"tools": {
|
||||||
"github-triage": false,
|
"github-triage": false,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Context, Effect, Layer } from "effect"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { DatabaseMigration } from "./migration"
|
||||||
|
|
||||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||||
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
||||||
|
|
@ -24,6 +25,9 @@ const layer = Layer.effect(
|
||||||
yield* db.run("PRAGMA foreign_keys = ON")
|
yield* db.run("PRAGMA foreign_keys = ON")
|
||||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||||
|
|
||||||
|
console.log(DatabaseMigration.ensure
|
||||||
|
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { Context, Schema } from "effect"
|
import { Context, Schema } from "effect"
|
||||||
|
import { AbsolutePath } from "./schema"
|
||||||
|
|
||||||
export * as Location from "./location"
|
export * as Location from "./location"
|
||||||
|
|
||||||
export const Ref = Schema.Struct({
|
export const Ref = Schema.Struct({
|
||||||
directory: Schema.String,
|
directory: AbsolutePath,
|
||||||
workspaceID: Schema.optional(Schema.String),
|
workspaceID: Schema.optional(Schema.String),
|
||||||
}).annotate({ identifier: "Location.Ref" })
|
}).annotate({ identifier: "Location.Ref" })
|
||||||
export type Ref = typeof Ref.Type
|
export type Ref = typeof Ref.Type
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { AccountV2 } from "../account"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
|
|
||||||
|
// Depending on what account is active, enable matching providers for that
|
||||||
|
// service
|
||||||
export const AccountPlugin = PluginV2.define({
|
export const AccountPlugin = PluginV2.define({
|
||||||
id: PluginV2.ID.make("account"),
|
id: PluginV2.ID.make("account"),
|
||||||
effect: Effect.gen(function* () {
|
effect: Effect.gen(function* () {
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const modelsDev = yield* ModelsDev.Service
|
const modelsDev = yield* ModelsDev.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const scope = yield* Scope.Scope
|
|
||||||
const load = yield* catalog.loader()
|
const load = yield* catalog.loader()
|
||||||
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
|
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
|
||||||
const data = yield* modelsDev.get()
|
const data = yield* modelsDev.get()
|
||||||
|
|
@ -114,7 +113,7 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||||
yield* refresh()
|
yield* refresh()
|
||||||
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
|
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||||
Stream.runForEach(() => refresh()),
|
Stream.runForEach(() => refresh()),
|
||||||
Effect.forkIn(scope, { startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
|
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
130
packages/core/src/project.ts
Normal file
130
packages/core/src/project.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
export * as Project from "./project"
|
||||||
|
|
||||||
|
import path from "path"
|
||||||
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
|
import { AppFileSystem } from "./filesystem"
|
||||||
|
import { AppProcess } from "./process"
|
||||||
|
import { AbsolutePath, withStatics } from "./schema"
|
||||||
|
import type { Location } from "./location"
|
||||||
|
|
||||||
|
export const ID = Schema.String.pipe(
|
||||||
|
Schema.brand("AccountV2.ID"),
|
||||||
|
withStatics((schema) => ({
|
||||||
|
global: schema.make("global"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
export type ID = typeof ID.Type
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly create: (input: AbsolutePath) => Promise<ID>
|
||||||
|
readonly locations: (projectID: ID) => Promise<Location.Ref[]>
|
||||||
|
// opencode -> ["~/dev/projects/anomalyco/opencode", "~/.gitworktrees/anomalyci/opencode"]
|
||||||
|
// global -> ["~/.config/nvim", "/etc/nixos"]
|
||||||
|
|
||||||
|
readonly resolve: (input: AbsolutePath) => Promise<ID>
|
||||||
|
// ~/dev/projects/anomalyco/opencode -> opencode
|
||||||
|
// ~/dev/projects/anomalyco/opencode/packages/core -> opencode
|
||||||
|
// ~/.gitworktrees/anomalyci/opencode -> opencode
|
||||||
|
// ~/.config/nvim -> global
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
||||||
|
|
||||||
|
interface GitResult {
|
||||||
|
readonly exitCode: number
|
||||||
|
readonly text: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const proc = yield* AppProcess.Service
|
||||||
|
|
||||||
|
const runGit = Effect.fn("Project.git")(
|
||||||
|
function* (args: string[], cwd: string) {
|
||||||
|
const result = yield* proc.run(
|
||||||
|
ChildProcess.make("git", args, {
|
||||||
|
cwd,
|
||||||
|
extendEnv: true,
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
text: () => result.stdout.toString("utf8"),
|
||||||
|
} satisfies GitResult
|
||||||
|
},
|
||||||
|
Effect.catch(() =>
|
||||||
|
Effect.succeed({
|
||||||
|
exitCode: 1,
|
||||||
|
text: () => "",
|
||||||
|
} satisfies GitResult),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveGitPath = (cwd: string, value: string) => {
|
||||||
|
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||||
|
if (!trimmed) return cwd
|
||||||
|
const normalized = AppFileSystem.windowsPath(trimmed)
|
||||||
|
if (path.isAbsolute(normalized)) return path.normalize(normalized)
|
||||||
|
return path.resolve(cwd, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
||||||
|
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||||
|
Effect.map((x) => x.trim()),
|
||||||
|
Effect.map((x) => ID.make(x)),
|
||||||
|
Effect.catch(() => Effect.void),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolve = async (input: AbsolutePath) =>
|
||||||
|
Effect.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const repoPath = yield* fs.up({ targets: [".git"], start: input }).pipe(
|
||||||
|
Effect.map((matches) => matches[0]),
|
||||||
|
Effect.catch(() => Effect.void),
|
||||||
|
)
|
||||||
|
if (!repoPath) return ID.global
|
||||||
|
|
||||||
|
const cwd = path.dirname(repoPath)
|
||||||
|
const parsed = yield* runGit(["rev-parse", "--git-dir", "--git-common-dir"], cwd)
|
||||||
|
if (parsed.exitCode !== 0) return (yield* readCachedProjectId(repoPath)) ?? ID.global
|
||||||
|
|
||||||
|
const gitPaths = parsed
|
||||||
|
.text()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const commonDir = gitPaths[1] ? resolveGitPath(cwd, gitPaths[1]) : undefined
|
||||||
|
if (!commonDir) return (yield* readCachedProjectId(repoPath)) ?? ID.global
|
||||||
|
|
||||||
|
const cached = (yield* readCachedProjectId(repoPath)) ?? (yield* readCachedProjectId(commonDir))
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const id = (yield* runGit(["rev-list", "--max-parents=0", "HEAD"], cwd))
|
||||||
|
.text()
|
||||||
|
.split("\n")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.toSorted()[0]
|
||||||
|
|
||||||
|
if (!id) return ID.global
|
||||||
|
yield* fs.writeFileString(path.join(commonDir, "opencode"), id).pipe(Effect.ignore)
|
||||||
|
return ID.make(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
create: async () => {
|
||||||
|
throw new Error("Project.create is not implemented")
|
||||||
|
},
|
||||||
|
locations: async () => [],
|
||||||
|
resolve,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
@ -10,6 +10,18 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
|
||||||
*/
|
*/
|
||||||
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
|
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative file path (e.g., `src/components/Button.tsx`).
|
||||||
|
*/
|
||||||
|
export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
|
||||||
|
export type RelativePath = Schema.Schema.Type<typeof RelativePath>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute file path (e.g., `/home/user/projects/myapp/src/main.ts`).
|
||||||
|
*/
|
||||||
|
export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
|
||||||
|
export type AbsolutePath = Schema.Schema.Type<typeof AbsolutePath>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional public JSON field that can hold explicit `undefined` on the type
|
* Optional public JSON field that can hold explicit `undefined` on the type
|
||||||
* side but encodes it as an omitted key, matching legacy `JSON.stringify`.
|
* side but encodes it as an omitted key, matching legacy `JSON.stringify`.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,20 @@
|
||||||
export * as Session from "."
|
export * as Session from "."
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { withStatics } from "../schema"
|
import { AbsolutePath, RelativePath, withStatics } from "../schema"
|
||||||
import { Identifier } from "../util/identifier"
|
import { Identifier } from "../util/identifier"
|
||||||
|
import { Project } from "../project"
|
||||||
|
import { Workspace } from "../workspace"
|
||||||
|
import type { ModelV2 } from "../model"
|
||||||
|
import { Location } from "../location"
|
||||||
|
import type { SessionMessage } from "./message"
|
||||||
|
import type { Prompt } from "./prompt"
|
||||||
|
import type { EventV2 } from "../event"
|
||||||
|
|
||||||
|
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
|
||||||
|
identifier: "Session.Delivery",
|
||||||
|
})
|
||||||
|
export type Delivery = Schema.Schema.Type<typeof Delivery>
|
||||||
|
|
||||||
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
|
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
|
||||||
Schema.brand("SessionID"),
|
Schema.brand("SessionID"),
|
||||||
|
|
@ -11,3 +23,105 @@ export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
|
|
||||||
|
export const Info = Schema.Struct({
|
||||||
|
id: ID,
|
||||||
|
location: Location.Ref,
|
||||||
|
subpath: RelativePath, // derived from location
|
||||||
|
project: Project.ID, // derived from location
|
||||||
|
})
|
||||||
|
export type Info = typeof Info.Type
|
||||||
|
|
||||||
|
// get project -> project.locations
|
||||||
|
//
|
||||||
|
// get all sessions
|
||||||
|
//
|
||||||
|
|
||||||
|
// - by project
|
||||||
|
// - by subpath
|
||||||
|
// - by workspace (home is special)
|
||||||
|
|
||||||
|
type Cursor = {}
|
||||||
|
|
||||||
|
type ListInput = {
|
||||||
|
workspaceID?: Workspace.ID
|
||||||
|
search?: string
|
||||||
|
cursor?: Cursor
|
||||||
|
limit?: number
|
||||||
|
order?: "asc" | "desc"
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
project: Project.ID
|
||||||
|
subpath?: RelativePath
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
directory?: AbsolutePath
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateInput = {
|
||||||
|
id?: ID
|
||||||
|
agent?: string
|
||||||
|
model?: ModelV2.Ref
|
||||||
|
location: Location.Ref
|
||||||
|
}
|
||||||
|
|
||||||
|
type MoveInput = {
|
||||||
|
sessionID: ID
|
||||||
|
location: Location.Ref
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompactInput = {
|
||||||
|
sessionID: ID
|
||||||
|
prompt?: Prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
|
||||||
|
sessionID: ID,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export type Error = NotFoundError
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||||
|
readonly create: (input?: CreateInput) => Effect.Effect<Info>
|
||||||
|
readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError>
|
||||||
|
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
|
||||||
|
readonly messages: (input: {
|
||||||
|
sessionID: ID
|
||||||
|
limit?: number
|
||||||
|
order?: "asc" | "desc"
|
||||||
|
cursor?: {
|
||||||
|
id: SessionMessage.ID
|
||||||
|
time: number
|
||||||
|
direction: "previous" | "next"
|
||||||
|
}
|
||||||
|
}) => Effect.Effect<SessionMessage.Message[], NotFoundError>
|
||||||
|
readonly context: (sessionID: ID) => Effect.Effect<SessionMessage.Message[], NotFoundError>
|
||||||
|
readonly switchAgent: (input: { sessionID: ID; agent: string }) => Effect.Effect<void, never>
|
||||||
|
readonly switchModel: (input: { sessionID: ID; model: ModelV2.Ref }) => Effect.Effect<void, never>
|
||||||
|
readonly prompt: (input: {
|
||||||
|
id?: EventV2.ID
|
||||||
|
sessionID: ID
|
||||||
|
prompt: Prompt
|
||||||
|
delivery?: Delivery
|
||||||
|
resume?: boolean
|
||||||
|
}) => Effect.Effect<void, NotFoundError>
|
||||||
|
readonly shell: (input: {
|
||||||
|
id?: EventV2.ID
|
||||||
|
sessionID: ID
|
||||||
|
command: string
|
||||||
|
delivery?: Delivery
|
||||||
|
resume?: boolean
|
||||||
|
}) => Effect.Effect<void, never>
|
||||||
|
readonly skill: (input: {
|
||||||
|
id?: EventV2.ID
|
||||||
|
sessionID: ID
|
||||||
|
skill: string
|
||||||
|
delivery?: Delivery
|
||||||
|
resume?: boolean
|
||||||
|
}) => Effect.Effect<void, never>
|
||||||
|
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError>
|
||||||
|
readonly wait: (id: ID) => Effect.Effect<void, NotFoundError>
|
||||||
|
readonly resume: (sessionID: ID) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
|
||||||
11
packages/core/src/workspace.ts
Normal file
11
packages/core/src/workspace.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export * as Workspace from "./workspace"
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
|
import { withStatics } from "./schema"
|
||||||
|
import { Identifier } from "./util/identifier"
|
||||||
|
|
||||||
|
export const ID = Schema.String.pipe(
|
||||||
|
Schema.brand("AccountV2.ID"),
|
||||||
|
withStatics((schema) => ({ create: () => schema.make("wrk_" + Identifier.ascending()) })),
|
||||||
|
)
|
||||||
|
export type ID = typeof ID.Type
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
|
||||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
|
||||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||||
import { Effect } from "effect"
|
|
||||||
import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
|
|
||||||
import { sql, type ColumnBuilderBase } from "drizzle-orm"
|
|
||||||
import path from "path"
|
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
|
||||||
|
|
||||||
const rand = (seed: number) => () => {
|
const rand = (seed: number) => () => {
|
||||||
seed = (seed * 1664525 + 1013904223) >>> 0
|
seed = (seed * 1664525 + 1013904223) >>> 0
|
||||||
|
|
@ -14,315 +7,152 @@ const rand = (seed: number) => () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("DatabaseMigration", () => {
|
describe("DatabaseMigration", () => {
|
||||||
test("diff creates missing tables before indexes and apply is idempotent", () =>
|
test("diff creates missing tables before indexes", () => {
|
||||||
withDb((db) =>
|
const table = makeTable(
|
||||||
Effect.gen(function* () {
|
"session",
|
||||||
const table = sqliteTable(
|
[makeColumn("id", { primaryKey: true })],
|
||||||
"session",
|
[makeIndex("session_id_idx", "session", ["id"])],
|
||||||
{
|
)
|
||||||
id: text().primaryKey(),
|
|
||||||
slug: text().notNull(),
|
expect(DatabaseMigration.diff(emptySchema(), schema(table))).toEqual([
|
||||||
title: text().notNull().default("untitled"),
|
{ type: "create_table", table },
|
||||||
},
|
{ type: "create_index", index: table.indexes.session_id_idx },
|
||||||
(table) => [index("session_slug_idx").on(table.slug), uniqueIndex("session_title_uidx").on(table.title)],
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("diff adds missing columns and indexes without recreating existing tables", () => {
|
||||||
|
const actual = makeTable("session", [makeColumn("id", { primaryKey: true })], [])
|
||||||
|
const desired = makeTable(
|
||||||
|
"session",
|
||||||
|
[makeColumn("id", { primaryKey: true }), makeColumn("title", { notNull: true })],
|
||||||
|
[makeIndex("session_title_idx", "session", ["title"])],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(DatabaseMigration.diff(schema(actual), schema(desired))).toEqual([
|
||||||
|
{ type: "add_column", table: "session", column: desired.columns.title },
|
||||||
|
{ type: "create_index", index: desired.indexes.session_title_idx },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("diff is empty when actual already satisfies desired", () => {
|
||||||
|
const table = makeTable(
|
||||||
|
"session",
|
||||||
|
[makeColumn("id", { primaryKey: true }), makeColumn("title")],
|
||||||
|
[makeIndex("session_title_idx", "session", ["title"])],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(DatabaseMigration.diff(schema(table), schema(table))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("random desired schemas generate exactly missing additive operations", () => {
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const random = rand(seed)
|
||||||
|
const desiredTables = Array.from({ length: 1 + Math.floor(random() * 5) }, (_, i) =>
|
||||||
|
makeRandomTable(random, `table_${i}`),
|
||||||
|
)
|
||||||
|
const actualTables = desiredTables
|
||||||
|
.filter(() => random() > 0.25)
|
||||||
|
.map((table) =>
|
||||||
|
makeTable(
|
||||||
|
table.name,
|
||||||
|
Object.values(table.columns).filter((column) => column.primaryKey || random() > 0.35),
|
||||||
|
Object.values(table.indexes).filter(() => random() > 0.5),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
const operations = DatabaseMigration.diff(schema(...actualTables), schema(...desiredTables))
|
||||||
const operations = yield* DatabaseMigration.diff(db, [table])
|
const expected = desiredTables.flatMap<DatabaseMigration.Operation>((table) => {
|
||||||
expect(operations.map((operation) => operation.type)).toEqual(["create_table", "create_index", "create_index"])
|
const actual = actualTables.find((item) => item.name === table.name)
|
||||||
|
if (!actual) {
|
||||||
yield* DatabaseMigration.apply(db, operations)
|
return [createTableOperation(table), ...Object.values(table.indexes).map(createIndexOperation)]
|
||||||
expect(yield* DatabaseMigration.diff(db, [table])).toEqual([])
|
|
||||||
expect((yield* columnNames(db, "session")).sort()).toEqual(["id", "slug", "title"])
|
|
||||||
expect((yield* indexNames(db, "session")).sort()).toEqual(["session_slug_idx", "session_title_uidx"])
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
test("diff adds missing columns and indexes without recreating existing tables", () =>
|
|
||||||
withDb((db) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* db.run(`CREATE TABLE "session" ("id" text PRIMARY KEY NOT NULL)`)
|
|
||||||
const table = sqliteTable(
|
|
||||||
"session",
|
|
||||||
{
|
|
||||||
id: text().primaryKey(),
|
|
||||||
title: text().notNull().default("untitled"),
|
|
||||||
path: text(),
|
|
||||||
},
|
|
||||||
(table) => [index("session_title_idx").on(table.title)],
|
|
||||||
)
|
|
||||||
|
|
||||||
const operations = yield* DatabaseMigration.diff(db, [table])
|
|
||||||
expect(operations.map((operation) => operation.type)).toEqual(["add_column", "add_column", "create_index"])
|
|
||||||
|
|
||||||
yield* DatabaseMigration.apply(db, operations)
|
|
||||||
expect(yield* DatabaseMigration.diff(db, [table])).toEqual([])
|
|
||||||
expect((yield* columnNames(db, "session")).sort()).toEqual(["id", "path", "title"])
|
|
||||||
expect(yield* indexNames(db, "session")).toEqual(["session_title_idx"])
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
test("diff is additive only and ignores extra actual schema and definition drift", () =>
|
|
||||||
withDb((db) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* db.run(`CREATE TABLE "session" ("id" text PRIMARY KEY NOT NULL, "title" integer NOT NULL, "extra" text)`)
|
|
||||||
yield* db.run(`CREATE INDEX "session_extra_idx" ON "session" ("extra")`)
|
|
||||||
yield* db.run(`CREATE TABLE "extra_table" ("id" text PRIMARY KEY)`)
|
|
||||||
const table = sqliteTable("session", {
|
|
||||||
id: text().primaryKey(),
|
|
||||||
title: text().notNull().default("untitled"),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(yield* DatabaseMigration.diff(db, [table])).toEqual([])
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
test("diff ignores changed indexes to stay downgrade safe", () =>
|
|
||||||
withDb((db) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* db.run(`CREATE TABLE "session" ("id" text PRIMARY KEY NOT NULL, "title" text, "slug" text)`)
|
|
||||||
yield* db.run(`CREATE INDEX "session_lookup_idx" ON "session" ("slug")`)
|
|
||||||
yield* db.run(`CREATE INDEX "session_expression_idx" ON "session" (lower("slug"))`)
|
|
||||||
const table = sqliteTable(
|
|
||||||
"session",
|
|
||||||
{
|
|
||||||
id: text().primaryKey(),
|
|
||||||
title: text(),
|
|
||||||
slug: text(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
uniqueIndex("session_lookup_idx").on(table.title, table.slug),
|
|
||||||
index("session_expression_idx").on(sql`lower(${table.title})`),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(yield* DatabaseMigration.diff(db, [table])).toEqual([])
|
|
||||||
expect((yield* indexColumns(db, "session_lookup_idx")).map((column) => column.name)).toEqual(["slug"])
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
test("diff ignores changed not-null constraints to stay downgrade safe", () =>
|
|
||||||
withDb((db) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* db.run(`CREATE TABLE "session" ("id" text PRIMARY KEY NOT NULL, "required" text, "optional" text NOT NULL)`)
|
|
||||||
const table = sqliteTable("session", {
|
|
||||||
id: text().primaryKey(),
|
|
||||||
required: text().notNull(),
|
|
||||||
optional: text(),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(yield* DatabaseMigration.diff(db, [table])).toEqual([])
|
|
||||||
expect(yield* columnFlags(db, "session")).toMatchObject({ required: { notnull: 0 }, optional: { notnull: 1 } })
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
test("apply handles quoted identifiers, composite indexes, unique indexes, and expression indexes", () =>
|
|
||||||
withDb((db) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const table = sqliteTable(
|
|
||||||
"table \" with spaces",
|
|
||||||
{
|
|
||||||
id: text('id " col').primaryKey(),
|
|
||||||
value: text('value " one').notNull().default("a'b"),
|
|
||||||
other: text("other space"),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
uniqueIndex('idx " composite').on(table.value, table.other),
|
|
||||||
index('idx " expression').on(sql`lower(${table.value})`.inlineParams()),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* DatabaseMigration.apply(db, yield* DatabaseMigration.diff(db, [table]))
|
|
||||||
expect(yield* DatabaseMigration.diff(db, [table])).toEqual([])
|
|
||||||
expect((yield* columnNames(db, 'table " with spaces')).sort()).toEqual(["id \" col", "other space", "value \" one"])
|
|
||||||
expect((yield* indexNames(db, 'table " with spaces')).sort()).toEqual(['idx " composite', 'idx " expression'])
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
test("random schema reconciliation reaches a fixed point with additive operations", () =>
|
|
||||||
withDb((db) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
for (let seed = 1; seed <= 75; seed++) {
|
|
||||||
const random = rand(seed)
|
|
||||||
const specs = Array.from({ length: 1 + Math.floor(random() * 4) }, (_, index) => randomSpec(random, seed, index))
|
|
||||||
for (const spec of specs) yield* seedActualSchema(db, spec, random)
|
|
||||||
|
|
||||||
const tables = specs.map(tableFromSpec)
|
|
||||||
const operations = yield* DatabaseMigration.diff(db, tables)
|
|
||||||
expect(
|
|
||||||
operations.every((operation) => ["create_table", "add_column", "create_index"].includes(operation.type)),
|
|
||||||
).toBe(true)
|
|
||||||
|
|
||||||
yield* DatabaseMigration.apply(db, operations)
|
|
||||||
expect(yield* DatabaseMigration.diff(db, tables)).toEqual([])
|
|
||||||
}
|
}
|
||||||
}),
|
return [
|
||||||
),
|
...Object.values(table.columns)
|
||||||
20000,
|
.filter((column) => actual.columns[column.name] === undefined)
|
||||||
)
|
.map((column) => addColumnOperation(table.name, column)),
|
||||||
|
...Object.values(table.indexes)
|
||||||
|
.filter((index) => actual.indexes[index.name] === undefined)
|
||||||
|
.map(createIndexOperation),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(operations).toEqual(expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("random operations render quoted SQL", () => {
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const random = rand(seed)
|
||||||
|
const table = makeRandomTable(random, `table_"${seed}`)
|
||||||
|
const operations = DatabaseMigration.diff(emptySchema(), schema(table))
|
||||||
|
|
||||||
|
for (const operation of operations) {
|
||||||
|
const rendered = DatabaseMigration.toSql(operation)
|
||||||
|
expect(rendered).not.toContain("undefined")
|
||||||
|
expect(rendered).toContain('"')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
type TableSpec = {
|
function emptySchema(): DatabaseMigration.SchemaAst {
|
||||||
name: string
|
return { tables: {} }
|
||||||
columns: ColumnSpec[]
|
|
||||||
indexes: IndexSpec[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ColumnSpec = {
|
function schema(...tables: DatabaseMigration.TableAst[]): DatabaseMigration.SchemaAst {
|
||||||
key: string
|
return { tables: Object.fromEntries(tables.map((table) => [table.name, table])) }
|
||||||
name: string
|
|
||||||
primaryKey: boolean
|
|
||||||
notNull: boolean
|
|
||||||
default?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type IndexSpec = {
|
function makeTable(
|
||||||
name: string
|
name: string,
|
||||||
columns: string[]
|
columns: DatabaseMigration.ColumnAst[],
|
||||||
unique: boolean
|
indexes: DatabaseMigration.IndexAst[],
|
||||||
}
|
): DatabaseMigration.TableAst {
|
||||||
|
return {
|
||||||
function tableFromSpec(spec: TableSpec) {
|
name,
|
||||||
const columns = Object.fromEntries(spec.columns.map((column) => [column.key, columnBuilder(column)])) as Record<string, ColumnBuilderBase>
|
columns: Object.fromEntries(columns.map((column) => [column.name, column])),
|
||||||
return sqliteTable(spec.name, columns, (table) =>
|
indexes: Object.fromEntries(indexes.map((index) => [index.name, index])),
|
||||||
spec.indexes.map((item) => {
|
|
||||||
const columns = item.columns.map((key) => table[key]).filter((column) => column !== undefined)
|
|
||||||
const first = columns[0]
|
|
||||||
if (!first) throw new Error(`index ${item.name} has no columns`)
|
|
||||||
return (item.unique ? uniqueIndex(item.name) : index(item.name)).on(first, ...columns.slice(1))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function columnBuilder(spec: ColumnSpec): ColumnBuilderBase {
|
|
||||||
if (spec.primaryKey) return text(spec.name).primaryKey()
|
|
||||||
if (spec.default !== undefined) return text(spec.name).notNull().default(spec.default)
|
|
||||||
if (spec.notNull) return text(spec.name).notNull()
|
|
||||||
return text(spec.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomSpec(random: () => number, seed: number, index: number): TableSpec {
|
|
||||||
const name = identifier(random, `table_${seed}_${index}`)
|
|
||||||
const columns = Array.from({ length: 1 + Math.floor(random() * 8) }, (_, i): ColumnSpec => {
|
|
||||||
const primaryKey = i === 0
|
|
||||||
const notNull = primaryKey || random() > 0.5
|
|
||||||
return {
|
|
||||||
key: `column_${i}`,
|
|
||||||
name: identifier(random, `column_${i}`),
|
|
||||||
primaryKey,
|
|
||||||
notNull,
|
|
||||||
...(notNull && !primaryKey ? { default: `default_${Math.floor(random() * 1000)}` } : {}),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const indexes = columns
|
|
||||||
.filter((column) => !column.primaryKey && random() > 0.45)
|
|
||||||
.map((column, i): IndexSpec => ({
|
|
||||||
name: identifier(random, `${name}_${column.name}_${i}_idx`),
|
|
||||||
columns: random() > 0.65 ? columns.filter((item) => !item.primaryKey).slice(0, 2).map((item) => item.key) : [column.key],
|
|
||||||
unique: random() > 0.8,
|
|
||||||
}))
|
|
||||||
.filter((item) => item.columns.length > 0)
|
|
||||||
return { name, columns, indexes }
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedActualSchema(db: EffectDrizzleSqlite.EffectSQLiteDatabase, spec: TableSpec, random: () => number) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
if (random() < 0.25) return
|
|
||||||
const columns = spec.columns.filter((column) => column.primaryKey || random() > 0.35)
|
|
||||||
yield* db.run(`CREATE TABLE ${quoteIdentifier(spec.name)} (${columns.map(columnSql).join(", ")})`)
|
|
||||||
for (const column of columns.filter((column) => !column.primaryKey && !column.notNull && random() > 0.6)) {
|
|
||||||
yield* db.run(`ALTER TABLE ${quoteIdentifier(spec.name)} ALTER COLUMN ${quoteIdentifier(column.name)} SET NOT NULL`)
|
|
||||||
}
|
|
||||||
for (const item of spec.indexes.filter(() => random() > 0.5)) {
|
|
||||||
if (!item.columns.every((key) => columns.some((column) => column.key === key))) continue
|
|
||||||
const changed = random() > 0.5
|
|
||||||
yield* db.run(
|
|
||||||
indexSql(spec.name, {
|
|
||||||
...item,
|
|
||||||
unique: changed ? !item.unique : item.unique,
|
|
||||||
columns: changed ? [...item.columns].reverse() : item.columns,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function columnSql(spec: ColumnSpec) {
|
|
||||||
return [
|
|
||||||
quoteIdentifier(spec.name),
|
|
||||||
"text",
|
|
||||||
spec.primaryKey ? "PRIMARY KEY" : undefined,
|
|
||||||
spec.notNull ? "NOT NULL" : undefined,
|
|
||||||
spec.default === undefined ? undefined : `DEFAULT ${literal(spec.default)}`,
|
|
||||||
]
|
|
||||||
.filter((item) => item !== undefined)
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
function indexSql(table: string, spec: IndexSpec) {
|
|
||||||
return [
|
|
||||||
"CREATE",
|
|
||||||
spec.unique ? "UNIQUE" : undefined,
|
|
||||||
"INDEX",
|
|
||||||
quoteIdentifier(spec.name),
|
|
||||||
"ON",
|
|
||||||
quoteIdentifier(table),
|
|
||||||
`(${spec.columns.map((column) => quoteIdentifier(columnName(column))).join(", ")})`,
|
|
||||||
]
|
|
||||||
.filter((item) => item !== undefined)
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
function indexColumns(db: EffectDrizzleSqlite.EffectSQLiteDatabase, index: string) {
|
|
||||||
return db.all<{ name: string | null }>(`PRAGMA index_info(${quoteIdentifier(index)})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function columnName(key: string) {
|
|
||||||
return key.replace(/^column_/, "column_")
|
|
||||||
}
|
|
||||||
|
|
||||||
function identifier(random: () => number, fallback: string) {
|
|
||||||
const suffixes = ["", " space", ' " quote', " select", "-dash", "_underscore"]
|
|
||||||
return `${fallback}${suffixes[Math.floor(random() * suffixes.length)]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function columnNames(db: EffectDrizzleSqlite.EffectSQLiteDatabase, table: string) {
|
|
||||||
return db.all<{ name: string }>(`PRAGMA table_info(${quoteIdentifier(table)})`).pipe(Effect.map((rows) => rows.map((row) => row.name)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function indexNames(db: EffectDrizzleSqlite.EffectSQLiteDatabase, table: string) {
|
|
||||||
return db
|
|
||||||
.all<{ name: string }>(`PRAGMA index_list(${quoteIdentifier(table)})`)
|
|
||||||
.pipe(Effect.map((rows) => rows.map((row) => row.name).filter((name) => !name.startsWith("sqlite_autoindex_"))))
|
|
||||||
}
|
|
||||||
|
|
||||||
function columnFlags(db: EffectDrizzleSqlite.EffectSQLiteDatabase, table: string) {
|
|
||||||
return db
|
|
||||||
.all<{ name: string; notnull: number }>(`PRAGMA table_info(${quoteIdentifier(table)})`)
|
|
||||||
.pipe(Effect.map((rows) => Object.fromEntries(rows.map((row) => [row.name, { notnull: row.notnull }]))))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withDb<A>(fn: (db: EffectDrizzleSqlite.EffectSQLiteDatabase) => Effect.Effect<A, unknown, never>) {
|
|
||||||
const dir = await tmpdir()
|
|
||||||
try {
|
|
||||||
return await Effect.gen(function* () {
|
|
||||||
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
|
|
||||||
return yield* fn(db)
|
|
||||||
}).pipe(Effect.provide(SqliteClient.layer({ filename: path.join(dir.path, "test.db") })), Effect.runPromise)
|
|
||||||
} finally {
|
|
||||||
await dir[Symbol.asyncDispose]()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function quoteIdentifier(value: string) {
|
function createTableOperation(table: DatabaseMigration.TableAst): DatabaseMigration.Operation {
|
||||||
return `"${value.replaceAll('"', '""')}"`
|
return { type: "create_table", table }
|
||||||
}
|
}
|
||||||
|
|
||||||
function literal(value: string) {
|
function addColumnOperation(table: string, column: DatabaseMigration.ColumnAst): DatabaseMigration.Operation {
|
||||||
return `'${value.replaceAll("'", "''")}'`
|
return { type: "add_column", table, column }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIndexOperation(index: DatabaseMigration.IndexAst): DatabaseMigration.Operation {
|
||||||
|
return { type: "create_index", index }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeColumn(
|
||||||
|
name: string,
|
||||||
|
options: Partial<Omit<DatabaseMigration.ColumnAst, "name" | "type">> = {},
|
||||||
|
): DatabaseMigration.ColumnAst {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: "text",
|
||||||
|
notNull: options.notNull ?? false,
|
||||||
|
primaryKey: options.primaryKey ?? false,
|
||||||
|
...(options.default === undefined ? {} : { default: options.default }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIndex(name: string, table: string, columns: string[], unique = false): DatabaseMigration.IndexAst {
|
||||||
|
return { name, table, columns, unique }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRandomTable(random: () => number, name: string) {
|
||||||
|
const columns = Array.from({ length: 1 + Math.floor(random() * 8) }, (_, i) =>
|
||||||
|
makeColumn(`column_${i}`, {
|
||||||
|
primaryKey: i === 0,
|
||||||
|
notNull: i === 0 || random() > 0.5,
|
||||||
|
default: random() > 0.7 ? String(Math.floor(random() * 100)) : undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const indexes = columns
|
||||||
|
.filter((column) => !column.primaryKey && random() > 0.5)
|
||||||
|
.map((column) => makeIndex(`${name}_${column.name}_idx`, name, [column.name], random() > 0.75))
|
||||||
|
return makeTable(name, columns, indexes)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,9 +85,9 @@ export interface Interface {
|
||||||
readonly list: (input: {
|
readonly list: (input: {
|
||||||
limit?: number
|
limit?: number
|
||||||
order?: "asc" | "desc"
|
order?: "asc" | "desc"
|
||||||
directory?: string
|
projectID?: ProjectID
|
||||||
path?: string
|
|
||||||
workspaceID?: WorkspaceID
|
workspaceID?: WorkspaceID
|
||||||
|
path?: string
|
||||||
roots?: boolean
|
roots?: boolean
|
||||||
start?: number
|
start?: number
|
||||||
search?: string
|
search?: string
|
||||||
|
|
@ -110,24 +110,18 @@ export interface Interface {
|
||||||
readonly context: (
|
readonly context: (
|
||||||
sessionID: SessionID,
|
sessionID: SessionID,
|
||||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||||
readonly prompt: (input: {
|
|
||||||
id?: EventV2.ID
|
|
||||||
sessionID: SessionID
|
|
||||||
prompt: Prompt
|
|
||||||
delivery?: Delivery
|
|
||||||
}) => Effect.Effect<SessionMessage.User, NotFoundError | OperationUnavailableError>
|
|
||||||
readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never>
|
|
||||||
readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never>
|
|
||||||
readonly subagent: (input: {
|
readonly subagent: (input: {
|
||||||
id?: EventV2.ID
|
id?: EventV2.ID
|
||||||
parentID: SessionID
|
parentID: SessionID
|
||||||
prompt: Prompt
|
prompt: Prompt
|
||||||
agent: string
|
agent: string
|
||||||
model?: ModelV2.Ref
|
model?: ModelV2.Ref
|
||||||
|
resume?: boolean
|
||||||
}) => Effect.Effect<void, NotFoundError | OperationUnavailableError | MessageDecodeError>
|
}) => Effect.Effect<void, NotFoundError | OperationUnavailableError | MessageDecodeError>
|
||||||
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
|
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
|
||||||
readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect<void, never>
|
readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect<void, never>
|
||||||
readonly compact: (sessionID: SessionID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
readonly compact: (sessionID: SessionID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||||
|
readonly resume: (sessionID: SessionID) => Effect.Effect<void>
|
||||||
readonly wait: (sessionID: SessionID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
readonly wait: (sessionID: SessionID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue