Skip to content
Open
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
4 changes: 3 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
"pack": "bun run script/pack.ts",
"generate": "bun run script/generate.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api",
"test": "bun test --timeout 5000",
"test": "bun run test:unit && bun run test:reactivity",
"test:unit": "bun test --timeout 5000",
"test:reactivity": "bun test --conditions=browser --timeout 5000 ./test/solid-data.test.ts --test-name-pattern \"session sync reactivity\"",
"typecheck": "bun check --no-script",
"typecheck:tsc": "tsc --noEmit"
},
Expand Down
8 changes: 1 addition & 7 deletions packages/client/src/solid/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1729,13 +1729,7 @@ export function createData(config: CreateDataInput) {
])
const sessions = [info, ...children]
batch(() => {
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
)
for (const session of sessions) setStore("session", "info", session.id, reconcile(session))
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
Expand Down
100 changes: 99 additions & 1 deletion packages/client/test/solid-data.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { getEventListeners } from "node:events"
import { createRoot } from "solid-js"
import { createEffect, createRoot } from "solid-js"
import { isServer } from "solid-js/web"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type ModelInfo, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"

Expand Down Expand Up @@ -1511,3 +1512,100 @@ async function wait(check: () => boolean) {
await Bun.sleep(10)
}
}

function syncFixture(remote: Map<string, SessionInfo>) {
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)
if (url.pathname === "/api/session")
return Response.json({
data: [...remote.values()].filter((item) => item.parentID === url.searchParams.get("parentID")),
cursor: {},
})
return Response.json({ data: remote.get(decodeURIComponent(url.pathname.split("/").pop()!)) })
},
})
return createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: { on: () => () => {}, listen: () => () => {} },
}),
dispose,
}))
}

// The server build of solid-js has no effects, so this runs in the `test:reactivity` stage (browser condition).
test.skipIf(isServer)("session sync reactivity: an unchanged sync keeps the list and consumers quiet", async () => {
const remote = new Map<string, SessionInfo>()
const setup = syncFixture(remote)
const toSorted = Array.prototype.toSorted
let sorted = 0
let runs = 0
let disposeObserver = () => {}
try {
for (let index = 0; index < 50; index++) {
const info = { ...session(0), id: `ses_${index}`, time: { created: index, updated: index, idle: 2 } }
remote.set(info.id, info)
setup.data.session.remember(info)
}
createRoot((dispose) => {
disposeObserver = dispose
createEffect(() => {
setup.data.session.list()
runs++
})
})
await Promise.resolve()
const before = setup.data.session.get("ses_10")
Array.prototype.toSorted = function (this: unknown[], compare?: (a: never, b: never) => number) {
sorted++
return toSorted.call(this, compare)
} as typeof Array.prototype.toSorted

for (let repeat = 0; repeat < 3; repeat++) {
setup.data.session.invalidate("ses_10")
await setup.data.session.sync("ses_10")
await Promise.resolve()
}
expect({ sorted, runs }).toEqual({ sorted: 0, runs: 1 })
expect(setup.data.session.get("ses_10")).toBe(before)

remote.set("ses_10", { ...remote.get("ses_10")!, time: { created: 10, updated: 100, idle: 2 } })
setup.data.session.invalidate("ses_10")
await setup.data.session.sync("ses_10")
await Promise.resolve()
expect(runs).toBe(2)
expect(setup.data.session.list()[0]?.id).toBe("ses_10")
} finally {
Array.prototype.toSorted = toSorted
disposeObserver()
setup.dispose()
}
})

test("session sync semantics: replaces the stored session, drops omitted fields, never deletes children", async () => {
const remote = new Map<string, SessionInfo>([
["ses_root", { ...session(0), id: "ses_root", parentID: "ses_gone" }],
["ses_child", { ...session(0), id: "ses_child", parentID: "ses_root" }],
])
const setup = syncFixture(remote)
try {
await setup.data.session.sync("ses_root", { children: true })
expect(setup.data.session.get("ses_root")?.parentID).toBe("ses_gone")

remote.set("ses_root", { ...session(0), id: "ses_root", cost: 3, location: { directory: "/other" } })
remote.delete("ses_child")
setup.data.session.invalidate("ses_root")
await setup.data.session.sync("ses_root")

const info = setup.data.session.get("ses_root")!
expect("parentID" in info).toBe(false)
expect(info.cost).toBe(3)
expect(info.location.directory).toBe("/other")
expect(setup.data.session.get("ses_child")).toBeDefined()
} finally {
setup.dispose()
}
})
Loading