feat(storage): thread the owning team's scopeId into collab rooms

The closed backend's storage rework scopes collab rooms/keys by team:
room ids are now <scopeId>:<projectId>:<docPath> (@pcbjam/shared bump).
The editor threads a scopeId prop parallel to projectId — ToolPage takes
it from the project DTO (falling back to "local" for scope-less
backends; Home/LibTool pages pass "local" explicitly) — through WasmTool
into the doc session, sheet-manager warm rooms, and cross-app presence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Istvan Matejcsok 2026-07-14 13:13:45 +02:00
commit bea14c772f
10 changed files with 34 additions and 9 deletions

@ -1 +1 @@
Subproject commit 77eea4933734fbb410c9dabbede970e84944d4a0 Subproject commit 9f8bd91104e6cd9a31921f25bf2f351a2a5b8c08

View file

@ -405,6 +405,7 @@ async function maybeConnectDocSession(
opts: { opts: {
docSource?: DocSource; docSource?: DocSource;
tool: Tool; tool: Tool;
scopeId: string;
projectId: string; projectId: string;
targetPath?: string; targetPath?: string;
log: (m: string) => void; log: (m: string) => void;
@ -414,7 +415,7 @@ async function maybeConnectDocSession(
if (!opts.targetPath || !COLLAB_TOOLS.has(opts.tool)) return {}; if (!opts.targetPath || !COLLAB_TOOLS.has(opts.tool)) return {};
const { connectKicadDoc } = await import("@/wasm/collab"); const { connectKicadDoc } = await import("@/wasm/collab");
const room = collabRoomId(opts.projectId, opts.targetPath); const room = collabRoomId(opts.scopeId, opts.projectId, opts.targetPath);
const session = await connectKicadDoc({ provider: yjsProviderConfig(), room }); const session = await connectKicadDoc({ provider: yjsProviderConfig(), room });
// Use the full doc state (meta + layout + items), NOT just item count: a // Use the full doc state (meta + layout + items), NOT just item count: a
@ -449,6 +450,7 @@ async function maybeStartCollab(
opts: { opts: {
tool: Tool; tool: Tool;
slug: string; slug: string;
scopeId: string;
projectId: string; projectId: string;
targetPath?: string; targetPath?: string;
collabSession?: KicadDocSession; collabSession?: KicadDocSession;
@ -514,7 +516,7 @@ async function maybeStartCollab(
// One room per (project, document). Two tabs of the same build compute the // One room per (project, document). Two tabs of the same build compute the
// same id, so cross-tab BroadcastChannel still works; network providers use it // same id, so cross-tab BroadcastChannel still works; network providers use it
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId). // verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool); const room = collabRoomId(opts.scopeId, opts.projectId, opts.targetPath ?? opts.tool);
clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc); clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc);
const handle = await startKicadCollab(mod, win as unknown as KicadItemsWindow, { const handle = await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
provider, provider,
@ -543,6 +545,7 @@ async function startSheetCollab(
win: ToolWindow, win: ToolWindow,
opts: { opts: {
slug: string; slug: string;
scopeId: string;
projectId: string; projectId: string;
targetPath?: string; targetPath?: string;
files: ToolFile[]; files: ToolFile[];
@ -578,6 +581,7 @@ async function startSheetCollab(
const manager = createSheetCollabManager({ const manager = createSheetCollabManager({
mod, mod,
win: win as unknown as KicadItemsWindow, win: win as unknown as KicadItemsWindow,
scopeId: opts.scopeId,
projectId: opts.projectId, projectId: opts.projectId,
provider: yjsProviderConfig(), provider: yjsProviderConfig(),
seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet), seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet),
@ -679,6 +683,7 @@ async function waitForWxUi(win: ToolWindow, timeoutMs = 25_000): Promise<void> {
export function WasmTool({ export function WasmTool({
tool, tool,
slug, slug,
scopeId,
projectId, projectId,
files, files,
targetPath, targetPath,
@ -692,6 +697,8 @@ export function WasmTool({
}: { }: {
tool: Tool; tool: Tool;
slug: string; slug: string;
/** Owning team's stable id (`"local"` when scope-less) — first room-id segment. */
scopeId: string;
/** Stable project id — used to key the collab room (see @pcbjam/shared). */ /** Stable project id — used to key the collab room (see @pcbjam/shared). */
projectId: string; projectId: string;
files: ToolFile[]; files: ToolFile[];
@ -1182,6 +1189,7 @@ export function WasmTool({
const { session, targetBytes } = await maybeConnectDocSession(win, { const { session, targetBytes } = await maybeConnectDocSession(win, {
docSource, docSource,
tool, tool,
scopeId,
projectId, projectId,
targetPath, targetPath,
log: append, log: append,
@ -1242,6 +1250,7 @@ export function WasmTool({
if ((tool === "pcbnew" || tool === "eeschema") && !collabOptOut && !readOnly) { if ((tool === "pcbnew" || tool === "eeschema") && !collabOptOut && !readOnly) {
crossAppRef.current = crossAppRef.current =
(await startCrossAppPresence({ (await startCrossAppPresence({
scopeId,
projectId, projectId,
provider: yjsProviderConfig(), provider: yjsProviderConfig(),
user: presenceUser(), user: presenceUser(),
@ -1259,6 +1268,7 @@ export function WasmTool({
sheetManagerRef.current = sheetManagerRef.current =
(await startSheetCollab(win, { (await startSheetCollab(win, {
slug, slug,
scopeId,
projectId, projectId,
targetPath, targetPath,
files, files,
@ -1291,6 +1301,7 @@ export function WasmTool({
const collabHandle = await maybeStartCollab(win, { const collabHandle = await maybeStartCollab(win, {
tool, tool,
slug, slug,
scopeId,
projectId, projectId,
targetPath, targetPath,
collabSession: session, collabSession: session,

View file

@ -168,6 +168,7 @@ export function HomePage() {
<WasmTool <WasmTool
tool={launchedTool.tool} tool={launchedTool.tool}
slug="local" slug="local"
scopeId="local"
projectId="local" projectId="local"
files={[]} files={[]}
libsSource={launchedTool.libsSource} libsSource={launchedTool.libsSource}
@ -185,6 +186,7 @@ export function HomePage() {
<WasmTool <WasmTool
tool={launched.tool} tool={launched.tool}
slug="local" slug="local"
scopeId="local"
projectId="local" projectId="local"
files={local.files} files={local.files}
targetPath={launched.target} targetPath={launched.target}

View file

@ -27,6 +27,7 @@ export function LibToolPage() {
<WasmTool <WasmTool
tool={tool} tool={tool}
slug="local" slug="local"
scopeId="local"
projectId="local" projectId="local"
files={[]} files={[]}
libsSource={libsSource} libsSource={libsSource}

View file

@ -70,6 +70,7 @@ export function ToolPage() {
<WasmTool <WasmTool
tool={tool} tool={tool}
slug={slug} slug={slug}
scopeId={data.project.scopeId ?? "local"}
projectId={data.project.id} projectId={data.project.id}
files={data.files} files={data.files}
targetPath={targetPath} targetPath={targetPath}

View file

@ -20,6 +20,7 @@ let projectSeq = 0;
async function join(projectId: string, userId: string, tool: string): Promise<CrossAppHandle> { async function join(projectId: string, userId: string, tool: string): Promise<CrossAppHandle> {
const h = await startCrossAppPresence({ const h = await startCrossAppPresence({
scopeId: "S",
projectId, projectId,
provider: { kind: "broadcastchannel", settleMs: 10 }, provider: { kind: "broadcastchannel", settleMs: 10 },
user: user(userId), user: user(userId),
@ -43,6 +44,7 @@ afterEach(async () => {
describe("startCrossAppPresence", () => { describe("startCrossAppPresence", () => {
it("returns undefined for the none provider", async () => { it("returns undefined for the none provider", async () => {
const h = await startCrossAppPresence({ const h = await startCrossAppPresence({
scopeId: "S",
projectId: "P", projectId: "P",
provider: { kind: "none" }, provider: { kind: "none" },
user: user("alice"), user: user("alice"),

View file

@ -44,6 +44,7 @@ export interface CrossAppHandle {
} }
export async function startCrossAppPresence(opts: { export async function startCrossAppPresence(opts: {
scopeId: string;
projectId: string; projectId: string;
provider: ProviderConfig; provider: ProviderConfig;
user: PresenceUser; user: PresenceUser;
@ -51,7 +52,7 @@ export async function startCrossAppPresence(opts: {
}): Promise<CrossAppHandle | undefined> { }): Promise<CrossAppHandle | undefined> {
if (opts.provider.kind === "none") return undefined; if (opts.provider.kind === "none") return undefined;
const room = presenceRoomId(opts.projectId); const room = presenceRoomId(opts.scopeId, opts.projectId);
const doc = new Y.Doc(); const doc = new Y.Doc();
let provider: YjsProvider; let provider: YjsProvider;
try { try {

View file

@ -10,7 +10,9 @@ const { connectKicadDoc, bindKicadCollab, moduleItemsBridge } = vi.hoisted(() =>
vi.mock("./index", () => ({ connectKicadDoc })); vi.mock("./index", () => ({ connectKicadDoc }));
vi.mock("./kicad-binding", () => ({ bindKicadCollab, moduleItemsBridge })); vi.mock("./kicad-binding", () => ({ bindKicadCollab, moduleItemsBridge }));
vi.mock("@pcbjam/shared", () => ({ collabRoomId: (p: string, d: string) => `${p}:${d}` })); vi.mock("@pcbjam/shared", () => ({
collabRoomId: (s: string, p: string, d: string) => `${s}:${p}:${d}`,
}));
import { createSheetCollabManager } from "./sheet-manager"; import { createSheetCollabManager } from "./sheet-manager";
@ -53,6 +55,7 @@ function makeManager() {
return createSheetCollabManager({ return createSheetCollabManager({
mod: {} as never, mod: {} as never,
win: {} as never, win: {} as never,
scopeId: "S",
projectId: "P", projectId: "P",
provider: { kind: "none" } as never, provider: { kind: "none" } as never,
seedDocForPath: () => undefined, seedDocForPath: () => undefined,
@ -167,13 +170,14 @@ describe("sheet-manager warm pool", () => {
it("uses the pre-connected entry session (ydoc mode) instead of reconnecting", async () => { it("uses the pre-connected entry session (ydoc mode) instead of reconnecting", async () => {
const entryDoc = makeDoc(); const entryDoc = makeDoc();
const entrySession: FakeSession = { const entrySession: FakeSession = {
room: "P:root.kicad_sch", room: "S:P:root.kicad_sch",
doc: entryDoc, doc: entryDoc,
provider: { destroy: vi.fn() }, provider: { destroy: vi.fn() },
}; };
const m = createSheetCollabManager({ const m = createSheetCollabManager({
mod: {} as never, mod: {} as never,
win: {} as never, win: {} as never,
scopeId: "S",
projectId: "P", projectId: "P",
provider: { kind: "none" } as never, provider: { kind: "none" } as never,
seedDocForPath: () => undefined, seedDocForPath: () => undefined,

View file

@ -72,7 +72,9 @@ export interface SheetManagerOptions {
mod: KicadItemsModule; mod: KicadItemsModule;
/** The global the C++ emit side calls into (`window.kicadCollab.onItems`). */ /** The global the C++ emit side calls into (`window.kicadCollab.onItems`). */
win: KicadItemsWindow; win: KicadItemsWindow;
/** Project uuid — keys each room as `collabRoomId(projectId, sheetPath)`. */ /** Owning team's stable id (`"local"` when scope-less) — first room-id segment. */
scopeId: string;
/** Project uuid — keys each room as `collabRoomId(scopeId, projectId, sheetPath)`. */
projectId: string; projectId: string;
/** The env-selected Yjs provider config (same one the single-room path uses). */ /** The env-selected Yjs provider config (same one the single-room path uses). */
provider: ProviderConfig; provider: ProviderConfig;
@ -127,7 +129,7 @@ interface Room {
} }
export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollabManager { export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollabManager {
const { mod, win, projectId, provider, seedDocForPath, log } = opts; const { mod, win, scopeId, projectId, provider, seedDocForPath, log } = opts;
const bridge = moduleItemsBridge(mod, win); const bridge = moduleItemsBridge(mod, win);
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
// In-flight connects, so connectAll() and switchTo() racing on the same sheet (api // In-flight connects, so connectAll() and switchTo() racing on the same sheet (api
@ -179,7 +181,7 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
const pending = (async () => { const pending = (async () => {
const session = await connectKicadDoc({ const session = await connectKicadDoc({
provider, provider,
room: collabRoomId(projectId, sheetPath), room: collabRoomId(scopeId, projectId, sheetPath),
}); });
// Invisible observer (read-only-viewer): drop the provider's initial // Invisible observer (read-only-viewer): drop the provider's initial
// empty awareness state before anyone can see it. // empty awareness state before anyone can see it.

View file

@ -213,6 +213,7 @@ describe("bug 07b — sheet-switch gap: stale onItems writes into the old sheet'
const m = createSheetCollabManager({ const m = createSheetCollabManager({
mod, mod,
win, win,
scopeId: "S",
projectId: "P", projectId: "P",
provider: { kind: "none" } satisfies ProviderConfig, provider: { kind: "none" } satisfies ProviderConfig,
seedDocForPath: () => undefined, seedDocForPath: () => undefined,