From f6dc956d3c2e4cd18b9efd7b33d3f05f411f866a Mon Sep 17 00:00:00 2001 From: Rui <1685901819@qq.com> Date: Tue, 11 Aug 2026 22:51:06 +0800 Subject: [PATCH] Preserve blueprint bindings during code-only updates --- .../__tests__/blueprint-update.test.ts | 151 ++++++++++++++++++ packages/workshop-backend/src/overseer.ts | 8 +- 2 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 packages/workshop-backend/__tests__/blueprint-update.test.ts diff --git a/packages/workshop-backend/__tests__/blueprint-update.test.ts b/packages/workshop-backend/__tests__/blueprint-update.test.ts new file mode 100644 index 00000000..9c67a39c --- /dev/null +++ b/packages/workshop-backend/__tests__/blueprint-update.test.ts @@ -0,0 +1,151 @@ +import { RpcStub as NativeRpcStub } from "cloudflare:workers"; +import { describe, expect, it, vi } from "vitest"; +import { OverseerDurableObject } from "../src/overseer.js"; + +vi.mock("capnweb-validate", () => ({ validateRpc: () => () => undefined })); + +const originalBindings = { + OLD_API: { + type: "gatekeeper" as const, + title: "Original API", + gatekeeperName: "original", + typeUrlPattern: "https://old.example/*", + }, +}; + +const currentBindings = { + NEW_API: { + type: "gatekeeper" as const, + title: "Current API", + gatekeeperName: "current", + typeUrlPattern: "https://new.example/*", + }, +}; + +async function makeClient(options: { bindingError?: Error } = {}) { + let record = { + id: "blueprint-1", + gadgetId: 7, + codeVersion: 3, + metadata: { + title: "Demo", + description: "Demo blueprint", + author: { type: "user" as const, id: "owner", name: "Owner" }, + created: new Date("2026-01-01"), + lastUpdated: new Date("2026-01-01"), + version: 1, + bindings: structuredClone(originalBindings), + }, + }; + let collectBindingMetadata = vi.fn(() => { + if (options.bindingError) throw options.bindingError; + return structuredClone(currentBindings); + }); + let snapshot = new Uint8Array([1, 2, 3]); + let snapshotCode = vi.fn(async () => snapshot); + let propagateBlueprint = vi.fn(async () => {}); + let owner = { + whoami: async () => ({ type: "user" as const, id: "owner", name: "Owner" }), + }; + let overseer = { + open: OverseerDurableObject.prototype.open, + impl: { + ownerId: "user-id", + ensureAmbientCapsules: async () => {}, + logger: { error: () => {} }, + users: { + idFromString: (id: string) => id, + get: () => owner, + }, + markOutputsDirty: () => {}, + joinPresence: () => () => {}, + joinOutputsFanout: () => () => {}, + resolveGadgetId: (id: number) => id, + collectBindingMetadata, + snapshotCode, + propagateBlueprint, + storage: { + prohibitAllSharing: { get: () => false }, + blueprints: { get: () => record }, + codeVersion: { get: () => 4 }, + }, + }, + } satisfies Pick & { impl: object }; + let notifyClosed = new NativeRpcStub<() => void>(() => {}); + let client = await overseer.open("user-id", "owner", notifyClosed); + return { + client, + collectBindingMetadata, + propagateBlueprint, + record, + snapshot, + snapshotCode, + }; +} + +describe("Overseer.updateBlueprint", () => { + it("updates code without reading or changing binding metadata", async () => { + let bindingError = new Error("invalid current binding annotation"); + let { + client, + collectBindingMetadata, + propagateBlueprint, + record, + snapshot, + snapshotCode, + } = await makeClient({ bindingError }); + + await expect(client.updateBlueprint("blueprint-1", { + updateCode: true, + })).resolves.toBeUndefined(); + + expect(collectBindingMetadata).not.toHaveBeenCalled(); + expect(record.metadata.bindings).toEqual(originalBindings); + expect(record.codeVersion).toBe(4); + expect(record.metadata.version).toBe(2); + expect(snapshotCode).toHaveBeenCalledWith(7); + expect(propagateBlueprint).toHaveBeenCalledWith(record, snapshot, undefined); + }); + + it("updates bindings without changing the code snapshot", async () => { + let { + client, + collectBindingMetadata, + propagateBlueprint, + record, + snapshotCode, + } = await makeClient(); + + await client.updateBlueprint("blueprint-1", { updateBindings: true }); + + expect(collectBindingMetadata).toHaveBeenCalledWith(7); + expect(record.metadata.bindings).toEqual(currentBindings); + expect(record.codeVersion).toBe(3); + expect(record.metadata.version).toBe(1); + expect(snapshotCode).not.toHaveBeenCalled(); + expect(propagateBlueprint).toHaveBeenCalledWith(record, undefined, undefined); + }); + + it("updates code and bindings together", async () => { + let { + client, + collectBindingMetadata, + propagateBlueprint, + record, + snapshot, + snapshotCode, + } = await makeClient(); + + await client.updateBlueprint("blueprint-1", { + updateCode: true, + updateBindings: true, + }); + + expect(collectBindingMetadata).toHaveBeenCalledWith(7); + expect(record.metadata.bindings).toEqual(currentBindings); + expect(record.codeVersion).toBe(4); + expect(record.metadata.version).toBe(2); + expect(snapshotCode).toHaveBeenCalledWith(7); + expect(propagateBlueprint).toHaveBeenCalledWith(record, snapshot, undefined); + }); +}); diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index d6267e31..258e21dc 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -8572,10 +8572,12 @@ class OverseerClientInterface extends RpcTarget implements Overseer { let codeSnapshot: Uint8Array | undefined; if (options.updateCode || options.updateBindings) { - // Re-collect binding metadata from the source gadget (validates annotations). Records - // written before multi-gadget support carry no gadgetId; they export the default gadget. let gadgetId = this.impl.resolveGadgetId(record.gadgetId); - record.metadata.bindings = this.impl.collectBindingMetadata(gadgetId); + if (options.updateBindings) { + // Re-collect binding metadata from the source gadget (validates annotations). Records + // written before multi-gadget support carry no gadgetId; they export the default gadget. + record.metadata.bindings = this.impl.collectBindingMetadata(gadgetId); + } if (options.updateCode) { record.codeVersion = this.impl.storage.codeVersion.get(); record.metadata.version++;