libs: peer lib edits reach the running editor + "placed symbol updated" toast

synced-source subscribes to its SyncStack: remote changes (self-save echoes
consumed via a selfPushed flag) debounce per kind into kicadLibsReload — a
new embind export (pcbjam_libs_reload.h, all three TUs) that drops the lib's
plugin cache (LIBRARY_MANAGER::ReloadLibraryEntry), reloads it, and mails
MAIL_RELOAD_LIB with the nickname so the symbol tree force-refreshes (the
plugin's modify hash is a pinned constant, so a plain sync would skip it).

After the reload, kicadLibsSymbolUsage (new eeschema embind: placed
SCH_SYMBOL count across unique screens) gates LIB_ITEM_UPDATED_EVENT, and
WasmTool shows an amber toast when a PLACED symbol changed — placed copies
keep the previous version until updated from the library.

syncedScopeLibsSource gives PROJECT sessions the synced source under
VITE_LIBS_SOURCE=synced (remote contract for lib listing/createLib, lazy
per-lib SyncStacks for item ops/presync) so realtime reaches open
schematics; previously project sessions silently fell back to the per-item
remote source. Unit tests cover reload debounce, self-echo skip, per-kind
routing, usage-gated event, and the no-Module no-op.

Bumps kicad (MAIL_RELOAD_LIB force-refresh payload).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QRWoXiM9uuo1enXGhAYku
This commit is contained in:
Gergő Törcsvári 2026-07-09 18:21:21 +02:00
commit 9315f56760
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
10 changed files with 649 additions and 11 deletions

2
kicad

@ -1 +1 @@
Subproject commit 2759de60c2acc537781aed6edb9b286f4a755201
Subproject commit 03b9e149c17b27d96d93d867b7be28e2a3ea4fe7

View file

@ -59,6 +59,7 @@
#include "collab_common.h"
#include "collab_presence_core.h"
#include "collab_presence_style.h"
#include "pcbjam_libs_reload.h"
#include <algorithm>
using namespace emscripten;
@ -1195,6 +1196,38 @@ std::string schCollabTestMoveFirst( int aDx, int aDy )
}
// How many placed instances of a library symbol the open schematic holds —
// the JS lib-sync bridge asks after a remote lib update so the editor chrome
// can warn "a symbol you are using changed" (placed SCH_SYMBOLs keep their
// embedded copy across a lib reload, so the user must update explicitly).
// Counts across all unique screens of the hierarchy; 0 without a schematic
// frame (symbol editor / viewer sessions).
int schLibsSymbolUsage( std::string aLibNickname, std::string aSymbolName )
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return 0;
const LIB_ID target( wxString::FromUTF8( aLibNickname.c_str() ),
wxString::FromUTF8( aSymbolName.c_str() ) );
int count = 0;
SCH_SCREENS screens( fr->Schematic().Root() );
for( SCH_SCREEN* screen = screens.GetFirst(); screen; screen = screens.GetNext() )
{
for( SCH_ITEM* item : screen->Items().OfType( SCH_SYMBOL_T ) )
{
if( static_cast<SCH_SYMBOL*>( item )->GetLibId() == target )
count++;
}
}
return count;
}
// Test helper: read an item's position by uuid as "x,y" (internal units).
std::string schCollabGetPos( std::string aId )
{
@ -1715,6 +1748,11 @@ EMSCRIPTEN_BINDINGS(eeschema) {
function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst);
function("kicadCollabTestSelectComponent", &schCollabTestSelectComponent);
function("kicadCollabTestClearSelection", &schCollabTestClearSelection);
// Library reload after a remote (synced) lib edit — r2-idb-sync realtime.
function("kicadLibsReload", &pcbjam_libs::reloadLibrary);
// Placed-instance count for a library symbol (drives the "a symbol you are
// using was updated" toast after a remote lib edit).
function("kicadLibsSymbolUsage", &schLibsSymbolUsage);
#endif // !KICAD_MERGED_EMBIND
}
#endif

View file

@ -37,6 +37,8 @@
#include <kiway.h>
#include <kiway_player.h>
#include "pcbjam_libs_reload.h"
using namespace emscripten;
// Per-editor entry points and frame probes — defined (with external linkage) in
@ -76,6 +78,7 @@ std::string pcbCollabTestSelectFirst();
bool pcbCollabTestClearSelection();
bool schEditorActive();
int schLibsSymbolUsage( std::string aLibNickname, std::string aSymbolName );
void schCollabApply( std::string aJson );
void schCollabApplyItems( std::string aJson );
std::string schCollabSnapshot();
@ -319,6 +322,13 @@ static int collabTestUndoDepth()
return pcbEditorActive() ? pcbCollabTestUndoDepth() : schCollabTestUndoDepth();
}
// Placed-instance count for a library symbol — meaningful only with a schematic
// frame; every other editor answers 0 ("nothing placed here uses it").
static int libsSymbolUsage( std::string aLib, std::string aName )
{
return schEditorActive() ? schLibsSymbolUsage( aLib, aName ) : 0;
}
// Presence shims (collab-presence 0002 pcbnew / 0003 eeschema): route to the live
// editor's implementation, same pattern as the collab bridge shims above.
static void collabPresenceStart()
@ -453,6 +463,12 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
function("kicadCollabTestGetLocked", &collabTestGetLocked);
function("kicadCollabTestSelectFirst", &collabTestSelectFirst);
function("kicadCollabTestClearSelection", &collabTestClearSelection);
// Library reload after a remote (synced) lib edit — r2-idb-sync realtime.
function("kicadLibsReload", &pcbjam_libs::reloadLibrary);
// Placed-instance count for a library symbol (schematic sessions only —
// 0 from any other frame; drives the "symbol you are using was updated"
// toast after a remote lib edit).
function("kicadLibsSymbolUsage", &libsSymbolUsage);
}
#endif // __EMSCRIPTEN__

View file

@ -0,0 +1,81 @@
/*
* JS editor library reload (r2-idb-sync realtime): drop a PCBJAM lib's
* in-plugin cache and re-sync the open editor's library tree after a remote
* (peer) edit landed in the page's SyncStack/IDB.
*
* The lib plugins (SCH_IO_PCBJAM_LIB / PCB_IO_PCBJAM_FP) cache parsed items for
* the plugin instance's lifetime and pin GetModifyHash()/GetLibraryTimestamp()
* to a constant, so nothing upstream ever re-reads the provider. The one full
* invalidation is LIBRARY_MANAGER::ReloadLibraryEntry it erases the LIB_DATA
* entry INCLUDING the plugin instance (and with it the fat-load cache), the
* exact pattern the remote-symbol import flows already use
* (eeschema/widgets/panel_remote_symbol.cpp). LoadLibraryEntry then refetches
* synchronously (the fat "bodies" crossing served from the now-fresh IDB, no
* network), and MAIL_RELOAD_LIB makes an open editor frame rebuild its tree.
*
* Header-only (the collab_common.h pattern); common-code includes only, so the
* merged kicad_editor TU (deliberately eeschema/pcbnew-header-free) can use it.
* Runs on the fiber stack: LoadLibraryEntry Asyncify-suspends in the JS bridge,
* and the tree sync dispatches GAL/tree virtuals that trap off-fiber.
*/
#pragma once
#ifdef __EMSCRIPTEN__
#include <string>
#include <wx/app.h>
#include <wx/string.h>
#include <kiway.h>
#include <kiway_player.h>
#include <libraries/library_manager.h>
#include <mail_type.h>
#include <pgm_base.h>
#include "collab_common.h"
namespace pcbjam_libs {
/**
* Reload one library from its provider and refresh any open editor tree.
* @param aKind "symbol" | "footprint" which lib table the nickname lives in.
* @param aNickname the lib-table row name (LibInfo.name on the JS side).
*/
inline void reloadLibrary( std::string aKind, std::string aNickname )
{
KIWAY_PLAYER* top =
wxTheApp ? dynamic_cast<KIWAY_PLAYER*>( wxTheApp->GetTopWindow() ) : nullptr;
if( !top )
return;
const bool fp = aKind == "footprint";
const wxString nick = wxString::FromUTF8( aNickname.c_str() );
pcbjam_collab::runOnFiber( top, [top, fp, nick]()
{
LIBRARY_MANAGER& mgr = Pgm().GetLibraryManager();
const LIBRARY_TABLE_TYPE type =
fp ? LIBRARY_TABLE_TYPE::FOOTPRINT : LIBRARY_TABLE_TYPE::SYMBOL;
// Drop the LIB_DATA entry (plugin instance + its fat-load cache)…
mgr.ReloadLibraryEntry( type, nick );
// …and bring the lib back to LOADED synchronously, so the tree sync
// below reads fresh items instead of re-listing a LOADING stub.
mgr.LoadLibraryEntry( type, nick );
// An open editor frame re-syncs its tree from the (now fresh) adapter.
// The payload names the lib to FORCE-refresh: the symbol tree gates
// re-enumeration on a modify hash that is a pinned constant for PCBJAM
// plugins (and its mtime component never moves for a remote edit), so a
// plain sync would skip the lib; the named node is rebuilt instead.
// ExpressMail only delivers to frames that exist — no frame is created.
std::string payload( nick.utf8_str() );
top->Kiway().ExpressMail( fp ? FRAME_FOOTPRINT_EDITOR : FRAME_SCH_SYMBOL_EDITOR,
MAIL_RELOAD_LIB, payload );
} );
}
} // namespace pcbjam_libs
#endif // __EMSCRIPTEN__

View file

@ -51,6 +51,7 @@
#include "collab_common.h"
#include "collab_presence_core.h"
#include "collab_presence_style.h"
#include "pcbjam_libs_reload.h"
#include <algorithm>
#include <chrono>
#include <map>
@ -2004,6 +2005,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
function("kicadCollabTestSelectFirst", &pcbCollabTestSelectFirst);
function("kicadCollabTestSelectComponent", &pcbCollabTestSelectComponent);
function("kicadCollabTestClearSelection", &pcbCollabTestClearSelection);
// Library reload after a remote (synced) lib edit — r2-idb-sync realtime.
function("kicadLibsReload", &pcbjam_libs::reloadLibrary);
#endif // !KICAD_MERGED_EMBIND
}
#endif

View file

@ -29,9 +29,11 @@ import { resolveWasmBase } from "@/wasm/wasm-assets";
import {
LIB_BUSY_EVENT,
LIB_ERROR_EVENT,
LIB_ITEM_UPDATED_EVENT,
LIB_LOADING_EVENT,
type LibBusyDetail,
type LibErrorDetail,
type LibItemUpdatedDetail,
type LibLoadingDetail,
type LibsSource,
} from "@/wasm/libs/source";
@ -762,6 +764,9 @@ export function WasmTool({
} | null>(null);
// Last lib error (e.g. a backend 404 on open), shown as a dismissible toast.
const [libError, setLibError] = React.useState<string | null>(null);
// A collaborator updated library items that are PLACED in the open document
// (LIB_ITEM_UPDATED_EVENT) — placed copies keep the previous version, so warn.
const [libUpdate, setLibUpdate] = React.useState<string | null>(null);
// Eager whole-library idb→wasm load in flight (the ~tens-of-seconds fat-load on
// first chooser/editor open). Drives a full-cover overlay so the freeze reads as
// "loading, just slow" rather than a hang. Null when idle; `done/total` count the
@ -816,12 +821,26 @@ export function WasmTool({
const onError = (e: Event) => {
setLibError((e as CustomEvent<LibErrorDetail>).detail.message);
};
const onItemUpdated = (e: Event) => {
const d = (e as CustomEvent<LibItemUpdatedDetail>).detail;
// Only warn when the update touches something PLACED here — the library
// tree already reflects updates to everything else.
if (d.usedNames.length === 0) return;
const names = d.usedNames.map((n) => `"${n}"`).join(", ");
setLibUpdate(
`${d.usedNames.length === 1 ? "Symbol" : "Symbols"} ${names} in "${d.lib}" ` +
`${d.usedNames.length === 1 ? "was" : "were"} updated by a collaborator — ` +
`placed copies keep the previous version until updated from the library.`,
);
};
window.addEventListener(LIB_BUSY_EVENT, onBusy);
window.addEventListener(LIB_ERROR_EVENT, onError);
window.addEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
return () => {
clearTimeout(busyTimer);
window.removeEventListener(LIB_BUSY_EVENT, onBusy);
window.removeEventListener(LIB_ERROR_EVENT, onError);
window.removeEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
};
}, []);
@ -832,6 +851,13 @@ export function WasmTool({
return () => clearTimeout(t);
}, [libError]);
// Auto-dismiss the lib update toast (a touch longer — it carries a caveat).
React.useEffect(() => {
if (!libUpdate) return;
const t = setTimeout(() => setLibUpdate(null), 10_000);
return () => clearTimeout(t);
}, [libUpdate]);
// Full-library eager load overlay. The fat-load fires one loading:true/false
// pair PER library (222 on the full set), and between them the C++ side parses
// with the main thread blocked. Show immediately on `true`, and only hide after
@ -1528,6 +1554,18 @@ export function WasmTool({
</button>
)}
{/* A collaborator updated a symbol PLACED in this document — auto-dismisses. */}
{libUpdate && (
<button
data-testid="lib-update-toast"
className="absolute left-1/2 top-3 z-40 max-w-md -translate-x-1/2 rounded bg-amber-950/95 px-3 py-2 text-center text-xs text-amber-100 shadow-lg ring-1 ring-amber-500/40"
onClick={() => setLibUpdate(null)}
title="Dismiss"
>
{libUpdate}
</button>
)}
{!chromeHidden && (
<div className="absolute bottom-0 left-0 right-0 z-20">
<button

View file

@ -110,7 +110,10 @@ import {
withSpikeWritableLib,
} from "@/wasm/libs/spike-writable";
import { staticLibsSource } from "@/wasm/libs/static-source";
import { syncedLibsSource } from "@/wasm/libs/synced-source";
import {
syncedLibsSource,
syncedScopeLibsSource,
} from "@/wasm/libs/synced-source";
/**
* Which Yjs collab provider this deployment uses (one active per env), and its
@ -151,6 +154,9 @@ export function docSourceConfig(): DocSource {
* "remote" (default) fetch from the backend at `API_BASE_URL` over the
* shared contract (origins served by the registry, or the
* GPL example backend).
* "synced" remote listing + per-lib r2-idb-sync stacks (IDB cache,
* realtime peer updates + editor reload). Also switches
* `libsSourceForLib` to the single-lib synced source.
* "static" built-in offline example symbols (no backend).
* "off" disable libs (empty sym-lib-table).
*/
@ -248,7 +254,20 @@ export function libsSourceConfig(projectId?: string): LibsSource | null {
? CDN_LIBS_MANIFEST_URL
? cdnLibsSource(CDN_LIBS_MANIFEST_URL)
: staticLibsSource() // misconfigured cdn ⇒ offline fallback
: remoteLibsSource(API_BASE_URL, currentScope(), userSlug(), project);
: kind === "synced"
? // Remote listing + per-lib sync stacks (IDB cache, realtime,
// editor reload on peer edits) — see syncedScopeLibsSource.
syncedScopeLibsSource(
remoteLibsSource(API_BASE_URL, currentScope(), userSlug(), project),
{
apiBase: API_BASE_URL,
scope: currentScope(),
user: userSlug(),
project,
log: (m) => console.log(m),
},
)
: remoteLibsSource(API_BASE_URL, currentScope(), userSlug(), project);
// 0004-A spike: `?libwrite=1` adds one in-memory writable user SYMBOL lib so the
// editor save path works with no backend (a dev/test aid). The real remote

View file

@ -143,6 +143,14 @@ export const LIB_ERROR_EVENT = "pcbjam:lib-error";
* as the bytes are handed to the bridge; the consumer coalesces the per-lib run.
*/
export const LIB_LOADING_EVENT = "pcbjam:lib-loading";
/**
* Fired after a REMOTE (peer) lib edit has been applied to the running editor
* (the synced source's subscribe `kicadLibsReload` bridge): names the updated
* items and which of them are placed in the open document (`usedNames`, via
* `kicadLibsSymbolUsage`) so the chrome can warn "a symbol you are using was
* updated" (placed copies keep the previous version until updated explicitly).
*/
export const LIB_ITEM_UPDATED_EVENT = "pcbjam:lib-item-updated";
export interface LibBusyDetail {
busy: boolean;
@ -161,6 +169,15 @@ export interface LibLoadingDetail {
/** Total libs of this kind to load (from listLibs), or 0 if unknown. */
total: number;
}
export interface LibItemUpdatedDetail {
/** Display name of the library (its lib-table nickname). */
lib: string;
kind: string;
/** Every item updated in this burst. */
names: string[];
/** The subset placed in the open document (empty ⇒ informational only). */
usedNames: string[];
}
function emitLibBusy(detail: LibBusyDetail): void {
if (typeof window === "undefined") return;

View file

@ -0,0 +1,230 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
encodeBundle,
encodeFrames,
sha256Hex,
type ServerMsg,
type SyncManifest,
} from "@pcbjam/shared";
import { memStore, type RealtimeChannel } from "@pcbjam/sync-client";
import { syncedLibsSource } from "./synced-source";
/**
* The subscribe editor-reload bridge (r2-idb-sync task E): a REMOTE change
* event from the lib's live layer must call the wasm export
* `Module.kicadLibsReload(kind, nickname)` (debounced), while our OWN saves
* which the stack also reports must not.
*/
const API = "https://api.test";
const LIB_ID = "lib-1";
const ROOM = `${API}/parties/sync-room/org:${LIB_ID}`;
const enc = new TextEncoder();
/** In-memory live-layer server: manifest + bodies + PUT, plus a WS handle the
* test uses to push broadcast messages at the client. */
async function fakeServer(seed: Record<string, string>) {
const bodies = new Map(Object.entries(seed).map(([p, t]) => [p, enc.encode(t)]));
const manifest: SyncManifest = { version: 1, entries: {} };
for (const [path, body] of bodies) {
manifest.entries[path] = {
hash: await sha256Hex(body),
size: body.length,
mtime: 0,
};
}
let onMessage: ((m: ServerMsg) => void) | undefined;
const channel: RealtimeChannel = {
onOpen: (cb) => cb(),
onMessage: (cb) => {
onMessage = cb;
},
send: () => {},
close: () => {},
};
// json() clones: the layer keeps the manifest object it receives, so handing
// out our live reference would leak later server-side mutations into the
// client and defeat its hash-based change dedup.
const json = (obj: unknown) => ({
ok: true,
json: async () => structuredClone(obj),
});
const bin = (bytes: Uint8Array) => ({
ok: true,
arrayBuffer: async () => bytes.buffer,
});
const fetchImpl = (async (input: unknown, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/sync-stack")) {
return json({
lib: { id: LIB_ID, name: "My Lib" },
layers: [
{ namespace: `org:${LIB_ID}`, kind: "live", url: ROOM, writable: true },
],
});
}
if (url === `${ROOM}/manifest`) return json(manifest);
if (url === `${ROOM}/bundle`) {
return bin(encodeBundle(manifest, [...bodies.entries()]));
}
if (url === `${ROOM}/bodies`) {
const { paths } = JSON.parse(String(init?.body)) as { paths: string[] };
return bin(
encodeFrames(
paths.map((p) => [p, bodies.get(p) ?? new Uint8Array()]),
),
);
}
if (url.startsWith(`${ROOM}/body/`) && init?.method === "PUT") {
const path = decodeURIComponent(url.slice(`${ROOM}/body/`.length));
const body = new Uint8Array(init.body as ArrayBuffer | Uint8Array);
bodies.set(path, body);
manifest.version += 1;
const hash = await sha256Hex(body);
manifest.entries[path] = { hash, size: body.length, mtime: 0 };
return json({ version: manifest.version, hash, size: body.length });
}
return { ok: false, status: 404 };
}) as unknown as typeof fetch;
/** Push a body server-side and broadcast the change to the client. */
async function remotePut(path: string, text: string): Promise<void> {
const body = enc.encode(text);
bodies.set(path, body);
manifest.version += 1;
const hash = await sha256Hex(body);
manifest.entries[path] = { hash, size: body.length, mtime: 0 };
onMessage?.({
t: "change",
op: "put",
path,
hash,
size: body.length,
version: manifest.version,
});
}
return { fetchImpl, channel, remotePut };
}
function makeSource(server: Awaited<ReturnType<typeof fakeServer>>) {
return syncedLibsSource(LIB_ID, {
apiBase: API,
scope: "s",
user: "u",
fetchImpl: server.fetchImpl,
storeFactory: () => memStore(),
channelFactory: () => server.channel,
});
}
describe("syncedLibsSource → editor reload bridge", () => {
const reload = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
(globalThis as { Module?: unknown }).Module = { kicadLibsReload: reload };
});
afterEach(() => {
vi.useRealTimers();
reload.mockReset();
delete (globalThis as { Module?: unknown }).Module;
});
it("a remote change calls kicadLibsReload once (debounced) with kind + name", async () => {
const server = await fakeServer({ "symbol/SEED": "(kicad_symbol_lib)" });
const source = makeSource(server);
await source.listItems(LIB_ID); // open the stack
await server.remotePut("symbol/A", "(kicad_symbol_lib A)");
await server.remotePut("symbol/B", "(kicad_symbol_lib B)");
expect(reload).not.toHaveBeenCalled(); // debounced, not immediate
await vi.advanceTimersByTimeAsync(500);
expect(reload).toHaveBeenCalledTimes(1); // burst coalesced
expect(reload).toHaveBeenCalledWith("symbol", "My Lib");
});
it("symbol and footprint changes reload their own kind", async () => {
const server = await fakeServer({});
const source = makeSource(server);
await source.listItems(LIB_ID);
await server.remotePut("symbol/A", "(kicad_symbol_lib A)");
await server.remotePut("footprint/F", "(footprint F)");
await vi.advanceTimersByTimeAsync(500);
expect(reload).toHaveBeenCalledTimes(2);
expect(reload).toHaveBeenCalledWith("symbol", "My Lib");
expect(reload).toHaveBeenCalledWith("footprint", "My Lib");
});
it("our own save does NOT trigger a reload (plugin already self-invalidates)", async () => {
const server = await fakeServer({});
const source = makeSource(server);
await source.listItems(LIB_ID);
const ok = await source.saveItemBody!(LIB_ID, "symbol", "Mine", "(body)");
expect(ok).toBe(true);
await vi.advanceTimersByTimeAsync(1000);
expect(reload).not.toHaveBeenCalled();
});
it("announces used symbols via LIB_ITEM_UPDATED_EVENT after the reload", async () => {
// The event goes to `window` — fake one for the node test env.
const dispatched: Array<{ type: string; detail: unknown }> = [];
(globalThis as { window?: unknown }).window = {
dispatchEvent: (e: CustomEvent) =>
dispatched.push({ type: e.type, detail: e.detail }),
};
// USED_R is placed in the (mock) schematic; NEW_C is not.
const usage = vi.fn((_lib: string, name: string) =>
name === "USED_R" ? 2 : 0,
);
(globalThis as { Module?: unknown }).Module = {
kicadLibsReload: reload,
kicadLibsSymbolUsage: usage,
};
try {
const server = await fakeServer({});
const source = makeSource(server);
await source.listItems(LIB_ID);
await server.remotePut("symbol/USED_R", "(kicad_symbol_lib USED_R)");
await server.remotePut("symbol/NEW_C", "(kicad_symbol_lib NEW_C)");
await vi.advanceTimersByTimeAsync(500);
expect(reload).toHaveBeenCalledTimes(1);
expect(usage).toHaveBeenCalledWith("My Lib", "USED_R");
expect(usage).toHaveBeenCalledWith("My Lib", "NEW_C");
expect(dispatched).toHaveLength(1);
expect(dispatched[0]!.type).toBe("pcbjam:lib-item-updated");
expect(dispatched[0]!.detail).toMatchObject({
lib: "My Lib",
kind: "symbol",
usedNames: ["USED_R"],
});
const names = (dispatched[0]!.detail as { names: string[] }).names;
expect([...names].sort()).toEqual(["NEW_C", "USED_R"]);
} finally {
delete (globalThis as { window?: unknown }).window;
}
});
it("a change before the editor booted (no Module export) is a no-op", async () => {
delete (globalThis as { Module?: unknown }).Module;
const server = await fakeServer({});
const source = makeSource(server);
await source.listItems(LIB_ID);
await server.remotePut("symbol/A", "(kicad_symbol_lib A)");
await vi.advanceTimersByTimeAsync(500); // must not throw
expect(reload).not.toHaveBeenCalled();
});
});

View file

@ -1,6 +1,17 @@
import { PROJECT_HEADER, SCOPE_HEADER, USER_HEADER } from "@pcbjam/shared";
import { SyncStack, type LayerDescriptor } from "@pcbjam/sync-client";
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
import {
SyncStack,
type ChannelFactory,
type LayerDescriptor,
type LayerStore,
} from "@pcbjam/sync-client";
import {
LIB_ITEM_UPDATED_EVENT,
type LibInfo,
type LibItemInfo,
type LibItemUpdatedDetail,
type LibsSource,
} from "./source";
/**
* A one-lib `LibsSource` backed by the r2-idb-sync bridge
@ -22,13 +33,103 @@ export function syncedLibsSource(
user?: string;
project?: string;
log?: (msg: string) => void;
/** Test seams (default: global fetch / IDB stores / real WebSockets). */
fetchImpl?: typeof fetch;
storeFactory?: (namespace: string) => LayerStore;
channelFactory?: ChannelFactory;
},
): LibsSource {
const log = opts.log ?? (() => {});
let opened: Promise<{ stack: SyncStack; info: LibInfo }> | null = null;
// Paths with a local save in flight: the stack echoes our own push as a
// change event, but the plugin already invalidated its cache on save — a
// reload would just re-fat-load the lib for nothing (and race the save flow).
const selfPushed = new Set<string>();
// Trailing per-kind debounce: a burst of remote changes (a peer saving
// several items, a reconnect resync diff) becomes ONE reload per kind.
const reloadTimers = new Map<string, ReturnType<typeof setTimeout>>();
// Item names accumulated per kind while the debounce runs — drives the
// post-reload "is this symbol placed here?" check + the update event.
const pendingNames = new Map<string, Set<string>>();
/**
* A REMOTE change landed in the stack (IDB is already fresh). Tell the
* running editor to drop the lib's WASM plugin cache and re-sync its tree
* the deferred r2-idb-sync task-E wiring. `kicadLibsReload` is the embind
* export (wasm/bindings/pcbjam_libs_reload.h); absent before the runtime
* boots, in which case there is no stale cache to refresh yet. After the
* reload, symbol changes are checked against the open document
* (`kicadLibsSymbolUsage`) and announced via LIB_ITEM_UPDATED_EVENT so the
* chrome can warn when a PLACED symbol changed under the user.
*/
function scheduleEditorReload(info: LibInfo, path: string): void {
const kind = path.slice(0, Math.max(path.indexOf("/"), 0));
if (kind !== "symbol" && kind !== "footprint") return;
const name = path.slice(kind.length + 1);
(pendingNames.get(kind) ?? pendingNames.set(kind, new Set()).get(kind)!).add(
name,
);
clearTimeout(reloadTimers.get(kind));
reloadTimers.set(
kind,
setTimeout(() => {
reloadTimers.delete(kind);
const names = [...(pendingNames.get(kind) ?? [])];
pendingNames.delete(kind);
const mod = (globalThis as { Module?: Record<string, unknown> }).Module;
const reload = mod?.kicadLibsReload;
if (typeof reload !== "function") return;
log(`[synced] remote change → reload ${kind} lib "${info.name}"`);
try {
(reload as (kind: string, nickname: string) => void)(kind, info.name);
} catch (e) {
log(`[synced] editor reload failed: ${String(e)}`);
return;
}
emitItemUpdated(info, kind, names, mod);
}, 400),
);
}
/** Announce the applied update, flagging names placed in the open document. */
function emitItemUpdated(
info: LibInfo,
kind: string,
names: string[],
mod: Record<string, unknown> | undefined,
): void {
if (typeof window === "undefined" || names.length === 0) return;
const usage = mod?.kicadLibsSymbolUsage;
const usedNames =
kind === "symbol" && typeof usage === "function"
? names.filter((n) => {
try {
return (
(usage as (lib: string, name: string) => number)(info.name, n) >
0
);
} catch {
return false;
}
})
: [];
const detail: LibItemUpdatedDetail = { lib: info.name, kind, names, usedNames };
window.dispatchEvent(new CustomEvent(LIB_ITEM_UPDATED_EVENT, { detail }));
}
async function ensure(): Promise<{ stack: SyncStack; info: LibInfo }> {
if (!opened) opened = resolveAndOpen(libId, opts, log);
if (!opened) {
opened = resolveAndOpen(libId, opts, log).then((r) => {
r.stack.subscribe((c) => {
// Consume our own save's echo (exactly one change event per push);
// everything else is a peer's edit.
if (selfPushed.delete(c.path)) return;
scheduleEditorReload(r.info, c.path);
});
return r;
});
}
return opened;
}
@ -72,10 +173,17 @@ export function syncedLibsSource(
},
async saveItemBody(_id, kind, name, body): Promise<boolean> {
const { stack } = await ensure();
const path = pathOf(kind, name);
// A successful push fires exactly one change event (the WS self-echo is
// hash-deduped inside the layer), and the stack delivers it AFTER an
// async merged read — so the flag must outlive this call; the subscriber
// consumes it. A failed push fires none: clear the flag ourselves.
selfPushed.add(path);
try {
await stack.push(pathOf(kind, name), new TextEncoder().encode(body));
await stack.push(path, new TextEncoder().encode(body));
return true;
} catch (e) {
selfPushed.delete(path);
log(`[synced] save failed for ${kind}/${name}: ${String(e)}`);
return false;
}
@ -85,15 +193,24 @@ export function syncedLibsSource(
async function resolveAndOpen(
libId: string,
opts: { apiBase: string; scope: string; user?: string; project?: string },
opts: {
apiBase: string;
scope: string;
user?: string;
project?: string;
fetchImpl?: typeof fetch;
storeFactory?: (namespace: string) => LayerStore;
channelFactory?: ChannelFactory;
},
log: (msg: string) => void,
): Promise<{ stack: SyncStack; info: LibInfo }> {
const baseFetch = opts.fetchImpl ?? fetch;
const headers: Record<string, string> = {
[SCOPE_HEADER]: opts.scope,
...(opts.user ? { [USER_HEADER]: opts.user } : {}),
...(opts.project ? { [PROJECT_HEADER]: opts.project } : {}),
};
const res = await fetch(
const res = await baseFetch(
`${opts.apiBase}/api/scopes/${encodeURIComponent(opts.scope)}/libs/${encodeURIComponent(libId)}/sync-stack`,
// credentials: session-cookie auth, here and on every layer fetch below —
// live layers are membership-gated by the API worker per request.
@ -110,8 +227,13 @@ async function resolveAndOpen(
// with the session cookie, so the stack's fetch must send credentials. The
// realtime WebSocket gets cookies automatically (same-site handshake).
const credentialedFetch: typeof fetch = (input, init) =>
fetch(input, { ...init, credentials: "include" });
const stack = new SyncStack({ layers: body.layers, fetchImpl: credentialedFetch });
baseFetch(input, { ...init, credentials: "include" });
const stack = new SyncStack({
layers: body.layers,
fetchImpl: credentialedFetch,
storeFactory: opts.storeFactory,
channelFactory: opts.channelFactory,
});
await stack.open();
return {
stack,
@ -126,3 +248,77 @@ function splitPath(path: string): LibItemInfo {
? { kind: path, name: "" }
: { kind: path.slice(0, i), name: path.slice(i + 1) };
}
/**
* The whole-scope synced source for PROJECT sessions: library LISTING (and
* `createLib`) stay on the remote contract the backend owns which libs a
* scope/project sees while every per-item read/write routes to a lazy
* per-lib {@link syncedLibsSource} (one `SyncStack` per lib: IDB cache +
* realtime + the subscribeeditor-reload bridge). This is what makes a peer's
* lib edit reach an OPEN SCHEMATIC session (the per-lib source only covers the
* `/libs/<id>` lib-editor pages).
*
* NOTE: writes follow the sync rooms (`sync/<ns>` R2), the v1 store that is
* not yet unified with the items-API DB (docs/features/r2-idb-sync 0001 §5
* "lazy materialization" deferred note) same trade the lib-editor pages
* already make.
*/
export function syncedScopeLibsSource(
remote: LibsSource,
opts: {
apiBase: string;
scope: string;
user?: string;
project?: string;
log?: (msg: string) => void;
fetchImpl?: typeof fetch;
storeFactory?: (namespace: string) => LayerStore;
channelFactory?: ChannelFactory;
},
): LibsSource {
const perLib = new Map<string, LibsSource>();
const forLib = (libId: string): LibsSource => {
let src = perLib.get(libId);
if (!src) {
src = syncedLibsSource(libId, opts);
perLib.set(libId, src);
}
return src;
};
return {
listLibs: (kind) => remote.listLibs(kind),
createLib: remote.createLib?.bind(remote),
getFpIndex: remote.getFpIndex?.bind(remote),
listItems: (libId) => forLib(libId).listItems(libId),
getAllItems: (libId) => forLib(libId).getAllItems!(libId),
getItemBody: (libId, kind, name) =>
forLib(libId).getItemBody(libId, kind, name),
saveItemBody: (libId, kind, name, body) =>
forLib(libId).saveItemBody!(libId, kind, name, body),
async presync(presyncOpts): Promise<void> {
const libs = await remote.listLibs(presyncOpts?.kind);
const total = libs.length;
let done = 0;
presyncOpts?.onProgress?.({ done, total, current: "libraries" });
const concurrency = presyncOpts?.concurrency ?? 8;
const queue = [...libs];
const worker = async (): Promise<void> => {
for (let lib = queue.shift(); lib; lib = queue.shift()) {
if (presyncOpts?.signal?.aborted) return;
try {
// Opening the stack (via any op) hydrates the lib's IDB cache.
await forLib(lib.id).listItems(lib.id);
} catch {
// Best-effort: a lib that fails to presync still loads lazily.
}
done++;
presyncOpts?.onProgress?.({ done, total, current: lib.name });
}
};
await Promise.all(
Array.from({ length: Math.min(concurrency, total) }, worker),
);
},
};
}