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:
parent
c229c75ca6
commit
9315f56760
10 changed files with 649 additions and 11 deletions
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit 2759de60c2acc537781aed6edb9b286f4a755201
|
||||
Subproject commit 03b9e149c17b27d96d93d867b7be28e2a3ea4fe7
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__
|
||||
|
|
|
|||
81
wasm/bindings/pcbjam_libs_reload.h
Normal file
81
wasm/bindings/pcbjam_libs_reload.h
Normal 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__
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
230
web/standalone/src/wasm/libs/synced-source.test.ts
Normal file
230
web/standalone/src/wasm/libs/synced-source.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 subscribe→editor-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),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue