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: {
docSource?: DocSource;
tool: Tool;
scopeId: string;
projectId: string;
targetPath?: string;
log: (m: string) => void;
@ -414,7 +415,7 @@ async function maybeConnectDocSession(
if (!opts.targetPath || !COLLAB_TOOLS.has(opts.tool)) return {};
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 });
// Use the full doc state (meta + layout + items), NOT just item count: a
@ -449,6 +450,7 @@ async function maybeStartCollab(
opts: {
tool: Tool;
slug: string;
scopeId: string;
projectId: string;
targetPath?: string;
collabSession?: KicadDocSession;
@ -514,7 +516,7 @@ async function maybeStartCollab(
// 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
// 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);
const handle = await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
provider,
@ -543,6 +545,7 @@ async function startSheetCollab(
win: ToolWindow,
opts: {
slug: string;
scopeId: string;
projectId: string;
targetPath?: string;
files: ToolFile[];
@ -578,6 +581,7 @@ async function startSheetCollab(
const manager = createSheetCollabManager({
mod,
win: win as unknown as KicadItemsWindow,
scopeId: opts.scopeId,
projectId: opts.projectId,
provider: yjsProviderConfig(),
seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet),
@ -679,6 +683,7 @@ async function waitForWxUi(win: ToolWindow, timeoutMs = 25_000): Promise<void> {
export function WasmTool({
tool,
slug,
scopeId,
projectId,
files,
targetPath,
@ -692,6 +697,8 @@ export function WasmTool({
}: {
tool: Tool;
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). */
projectId: string;
files: ToolFile[];
@ -1182,6 +1189,7 @@ export function WasmTool({
const { session, targetBytes } = await maybeConnectDocSession(win, {
docSource,
tool,
scopeId,
projectId,
targetPath,
log: append,
@ -1242,6 +1250,7 @@ export function WasmTool({
if ((tool === "pcbnew" || tool === "eeschema") && !collabOptOut && !readOnly) {
crossAppRef.current =
(await startCrossAppPresence({
scopeId,
projectId,
provider: yjsProviderConfig(),
user: presenceUser(),
@ -1259,6 +1268,7 @@ export function WasmTool({
sheetManagerRef.current =
(await startSheetCollab(win, {
slug,
scopeId,
projectId,
targetPath,
files,
@ -1291,6 +1301,7 @@ export function WasmTool({
const collabHandle = await maybeStartCollab(win, {
tool,
slug,
scopeId,
projectId,
targetPath,
collabSession: session,

View file

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

View file

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

View file

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

View file

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

View file

@ -44,6 +44,7 @@ export interface CrossAppHandle {
}
export async function startCrossAppPresence(opts: {
scopeId: string;
projectId: string;
provider: ProviderConfig;
user: PresenceUser;
@ -51,7 +52,7 @@ export async function startCrossAppPresence(opts: {
}): Promise<CrossAppHandle | 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();
let provider: YjsProvider;
try {

View file

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

View file

@ -72,7 +72,9 @@ export interface SheetManagerOptions {
mod: KicadItemsModule;
/** The global the C++ emit side calls into (`window.kicadCollab.onItems`). */
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;
/** The env-selected Yjs provider config (same one the single-room path uses). */
provider: ProviderConfig;
@ -127,7 +129,7 @@ interface Room {
}
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 rooms = new Map<string, Room>();
// 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 session = await connectKicadDoc({
provider,
room: collabRoomId(projectId, sheetPath),
room: collabRoomId(scopeId, projectId, sheetPath),
});
// Invisible observer (read-only-viewer): drop the provider's initial
// 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({
mod,
win,
scopeId: "S",
projectId: "P",
provider: { kind: "none" } satisfies ProviderConfig,
seedDocForPath: () => undefined,