import { expect } from "@std/expect"; import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { Identity } from "@commonfabric/identity"; import { Runtime } from "../src/runtime.ts"; import { StorageManager } from "../src/storage/cache.deno.ts"; const signer = await Identity.fromPassphrase( "captured-binding-across-versions", ); const space = signer.did(); /** * A pattern whose rows each capture a writer-protected `tags` list, made with * `.for(tagsCause)`. Two versions that differ only in that cause name two * different cells for the same binding. */ function program(tagsCause: string) { return { main: "/main.tsx", files: [{ name: "/main.tsx", contents: `/// import { handler, pattern, Writable, WriteAuthorizedBy } from "commonfabric"; const add = handler<{ add: string }, { items: Writable }>( (event, { items }) => { items.set([...items.get(), event.add]); }, ); const editTags = handler<{ tag: string }, { tags: Writable }>( (event, { tags }) => { tags.set([...tags.get(), event.tag]); }, ); type Tags = WriteAuthorizedBy; export default pattern>(() => { const items = new Writable([]).for("items"); const tags = new Writable([]).for("${tagsCause}"); return { items, tags, rows: items.map((item) => ({ item, tag: editTags({ tags }) })), add: add({ items }), }; }); `, }], }; } /** * A pattern that passes a writer-protected `tags` list, made with * `.for(tagsCause)`, to a sub-pattern it composes, and re-exports the * sub-pattern's view of it. */ function composing(tagsCause: string) { return { main: "/main.tsx", files: [{ name: "/main.tsx", contents: `/// import { handler, pattern, Writable, WriteAuthorizedBy } from "commonfabric"; const edit = handler<{ add: string }, { tags: Writable }>( (event, { tags }) => { tags.set([...tags.get(), event.add]); }, ); type Tags = WriteAuthorizedBy; const Child = pattern<{ list: Writable }, { list: Writable }>( ({ list }) => ({ list }), ); export default pattern>(() => { const tags = new Writable([]).for("${tagsCause}"); const child = Child({ list: tags }); return { tags, childList: child.list, add: edit({ tags }) }; }); `, }], }; } describe("captured-binding-across-versions", () => { let runtime: Runtime; let manager: ReturnType; beforeEach(() => { manager = StorageManager.emulate({ as: signer }); runtime = new Runtime({ apiUrl: new URL("https://example.com"), storageManager: manager, trustSnapshotProvider: () => ({ id: signer.did(), actingPrincipal: signer.did(), }), }); }); afterEach(async () => { await runtime.dispose(); }); /** * Sets up the first version over two rows, then sets up a version whose * captured cell is made with `.for(cause)`, and returns the second setup's * refusal and every error the scheduler reported after it. */ async function upgradeTo(cause: string) { const errors: string[] = []; runtime.scheduler.onError((error) => errors.push(String(error))); const v1 = await runtime.patternManager.compilePattern(program("tags")); const tx = runtime.edit(); const result = runtime.run( tx, v1, {}, runtime.getCell(space, "output", v1.resultSchema, tx), ); runtime.prepareTxForCommit(tx); expect((await tx.commit().settled).error).toBeUndefined(); const cancel = result.sink(() => {}); await runtime.idle(); result.key("add").send({ add: "a" }); await runtime.idle(); result.key("add").send({ add: "b" }); await runtime.idle(); await manager.synced(); expect(errors).toEqual([]); const v2 = await runtime.patternManager.compilePattern(program(cause)); const upgrade = runtime.edit(); runtime.run( upgrade, v2, {}, runtime.getCell(space, "output", v2.resultSchema, upgrade), ); runtime.prepareTxForCommit(upgrade); const setupError = (await upgrade.commit().settled).error?.message; await runtime.idle(); await manager.synced(); cancel(); return { setupError, errors }; } it("re-stages each row's capture under a version that makes the captured cell the same way", async () => { const { setupError, errors } = await upgradeTo("tags"); expect(setupError).toBeUndefined(); expect(errors).toEqual([]); }); // Whether a trusted setup may re-point a captured slot when a pattern // version names another cell for the same binding is open. Until it is // settled, the slot keeps the cell it was first given, and re-pointing it // is refused. A binding passed to a composed sub-pattern is re-pointed // (see the last case). it("refuses to re-point each row's capture under a version that makes the captured cell another way", async () => { const { setupError, errors } = await upgradeTo("labels"); expect(setupError).toBeUndefined(); expect(errors).not.toEqual([]); for (const error of errors) { expect(error).toContain( "writeAuthorizedBy requires a trusted verified binding identity at /params/tags", ); } }); it("re-points a composed sub-pattern's binding under a version that makes the passed cell another way", async () => { // Unlike a list builtin's capture, a binding a setup passes to a // sub-pattern it composes follows the cell the version names. const errors: string[] = []; runtime.scheduler.onError((error) => errors.push(String(error))); const v1 = await runtime.patternManager.compilePattern(composing("tags")); const tx = runtime.edit(); const result = runtime.run( tx, v1, {}, runtime.getCell(space, "composed", v1.resultSchema, tx), ); runtime.prepareTxForCommit(tx); expect((await tx.commit().settled).error).toBeUndefined(); const cancel = result.sink(() => {}); await runtime.idle(); result.key("add").send({ add: "a" }); await runtime.idle(); await manager.synced(); const v2 = await runtime.patternManager.compilePattern( composing("labels"), ); const upgrade = runtime.edit(); runtime.run( upgrade, v2, {}, runtime.getCell(space, "composed", v2.resultSchema, upgrade), ); runtime.prepareTxForCommit(upgrade); expect((await upgrade.commit().settled).error).toBeUndefined(); await runtime.idle(); await manager.synced(); result.key("add").send({ add: "after" }); await runtime.idle(); await manager.synced(); expect(await result.key("tags").pull()).toEqual(["after"]); expect(await result.key("childList").pull()).toEqual(["after"]); expect(errors).toEqual([]); cancel(); }); });