Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions packages/client/src/solid/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,16 @@ export function createData(config: CreateDataInput) {
)
}

function refreshCatalog(field: "integration" | "model" | "provider", location: LocationRef) {
const resource = result.location[field]
const key = `location.${field}:${locationKey(location)}`
const loaded = resource.list(location) !== undefined || sync.has(key)
resource.invalidate(location)
// Unopened catalogs stay stale until first use. Related notifications share a
// trailing window per catalog, including updates that overtake its first read.
if (loaded) settle(key, () => resource.sync(location))
}

function handleEvent(event: OpenCodeEvent) {
switch (event.type) {
case "server.connected": {
Expand Down Expand Up @@ -1259,12 +1269,10 @@ export function createData(config: CreateDataInput) {
const location = event.location
switch (event.type) {
case "provider.updated":
result.location.provider.invalidate(location)
refresh(() => result.location.provider.sync(location))
refreshCatalog("provider", location)
break
case "model.updated":
result.location.model.invalidate(location)
refresh(() => result.location.model.sync(location))
refreshCatalog("model", location)
break
case "agent.updated":
result.location.agent.invalidate(location)
Expand Down Expand Up @@ -1314,16 +1322,9 @@ export function createData(config: CreateDataInput) {
refresh(() => result.location.reference.sync(location))
break
case "integration.updated":
result.location.integration.invalidate(location)
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
refresh(() =>
Promise.all([
result.location.integration.sync(location),
result.location.model.sync(location),
result.location.provider.sync(location),
]),
)
refreshCatalog("integration", location)
refreshCatalog("model", location)
refreshCatalog("provider", location)
break
case "config.updated":
result.location.config.invalidate(location)
Expand Down
107 changes: 107 additions & 0 deletions packages/client/test/solid-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { afterEach, expect, jest, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, settleMs, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent } from "../src/promise"

afterEach(() => jest.useRealTimers())

const catalogs = ["integration", "model", "provider"] as const

test.each(catalogs)("%s notifications leave unopened locations unloaded until first use", async (field) => {
jest.useFakeTimers()
const setup = fixture()
const opened = { directory: "/project" }
const unopened = { directory: "/other" }
try {
await Promise.all(catalogs.map((field) => setup.data.location[field].sync(opened)))
setup.requests.length = 0
// An unrelated event can create a store entry without the client opening it.
setup.emit({ type: "vcs.branch.updated", location: unopened, data: { branch: "dev" } })
setup.emit({ type: `${field}.updated`, location: unopened, data: {} })
setup.emit({ type: `${field}.updated`, location: { ...opened, workspaceID: "other" }, data: {} })
jest.runOnlyPendingTimers()
expect(setup.requests).toEqual([])
catalogs.forEach((field) => expect(setup.data.location[field].list(unopened)).toBeUndefined())

await Promise.all(catalogs.map((field) => setup.data.location[field].sync(unopened)))
expect(setup.requests).toEqual(catalogs.map((field) => [`/api/${field}`, unopened.directory]))
catalogs.forEach((field) => expect(setup.data.location[field].list(unopened)).toEqual([]))
} finally {
setup.dispose()
}
})

test.each(catalogs)("%s notifications refresh loaded catalogs only at their location", async (field) => {
jest.useFakeTimers()
const setup = fixture()
const location = { directory: "/project" }
try {
await Promise.all(
[location, { directory: "/other" }].flatMap((location) =>
catalogs.map((field) => setup.data.location[field].sync(location)),
),
)
setup.requests.length = 0
setup.emit({ type: `${field}.updated`, location, data: {} })
expect(setup.requests).toEqual([])
jest.runOnlyPendingTimers()
expect(setup.requests).toEqual(
(field === "integration" ? catalogs : [field]).map((field) => [`/api/${field}`, location.directory]),
)
await Promise.all(catalogs.map((field) => setup.data.location[field].sync(location)))
} finally {
setup.dispose()
}
})

test("related catalog notifications coalesce to one read per loaded catalog", async () => {
jest.useFakeTimers()
const setup = fixture()
const location = { directory: "/project" }
try {
await Promise.all(catalogs.map((field) => setup.data.location[field].sync(location)))
setup.requests.length = 0
setup.emit({ type: "integration.updated", location, data: {} })
jest.advanceTimersByTime(settleMs / 2)
setup.emit({ type: "model.updated", location, data: {} })
setup.emit({ type: "provider.updated", location, data: {} })
setup.emit({ type: "integration.updated", location, data: {} })
expect(setup.requests).toEqual([])
jest.runOnlyPendingTimers()
expect(setup.requests).toEqual(catalogs.map((field) => [`/api/${field}`, location.directory]))
await Promise.all(catalogs.map((field) => setup.data.location[field].sync(location)))
expect(setup.requests).toHaveLength(3)
} finally {
setup.dispose()
}
})

function fixture() {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: string[][] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const url = new URL((input instanceof Request ? input : new Request(input, init)).url)
const directory = url.searchParams.get("location[directory]") ?? "/project"
requests.push([url.pathname, directory])
return Response.json({ location: { directory }, data: [] })
},
})
return createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })),
requests,
dispose,
}))
}
Loading