feat: libs 0004-B — example backend user-lib write (createLib + item PUT, owner-namespaced files, merged reads); bump pcbjam-shared
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
df4de65ba2
commit
beb4ce0f3f
3 changed files with 280 additions and 5 deletions
|
|
@ -16,6 +16,7 @@ import Fastify from "fastify";
|
|||
import { initServer } from "@ts-rest/fastify";
|
||||
import {
|
||||
contract,
|
||||
OWNER_HEADER,
|
||||
type Project,
|
||||
type ProjectFile,
|
||||
} from "@pcbjam/shared";
|
||||
|
|
@ -26,6 +27,26 @@ import {
|
|||
listLibItems,
|
||||
listLibs,
|
||||
} from "./libs.js";
|
||||
import {
|
||||
createUserLib,
|
||||
DEFAULT_OWNER,
|
||||
listUserItems,
|
||||
listUserLibs,
|
||||
type UserLibsConfig,
|
||||
UserLibError,
|
||||
userItemBodyPath,
|
||||
userLibsConfig,
|
||||
writeUserItem,
|
||||
} from "./user-libs.js";
|
||||
|
||||
/** The (thin, pre-auth) owner from OWNER_HEADER; absent ⇒ the default owner. */
|
||||
function ownerOf(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
): string {
|
||||
const v = headers[OWNER_HEADER];
|
||||
const s = Array.isArray(v) ? v[0] : v;
|
||||
return (s && String(s).trim()) || DEFAULT_OWNER;
|
||||
}
|
||||
|
||||
const PROJECT_DIR = path.resolve(
|
||||
process.cwd(),
|
||||
|
|
@ -129,6 +150,14 @@ async function main(): Promise<void> {
|
|||
app.get("/health", async () => ({ ok: true }));
|
||||
|
||||
const libs: LibsConfig = libsConfig();
|
||||
const userLibs: UserLibsConfig = userLibsConfig();
|
||||
|
||||
// The editor PUTs item bodies as text/plain (a kicad_symbol_lib s-expr).
|
||||
app.addContentTypeParser(
|
||||
"text/plain",
|
||||
{ parseAs: "string" },
|
||||
(_req, body, done) => done(null, body),
|
||||
);
|
||||
|
||||
const s = initServer();
|
||||
const router = s.router(contract, {
|
||||
|
|
@ -148,22 +177,50 @@ async function main(): Promise<void> {
|
|||
}
|
||||
return { status: 200 as const, body: await walk(PROJECT_DIR) };
|
||||
},
|
||||
listLibs: async () => ({ status: 200 as const, body: await listLibs(libs) }),
|
||||
listLibItems: async ({ params }) => {
|
||||
const items = await listLibItems(libs, params.lib);
|
||||
listLibs: async ({ headers }) => {
|
||||
const owner = ownerOf(headers);
|
||||
const [origins, user] = await Promise.all([
|
||||
listLibs(libs),
|
||||
listUserLibs(userLibs, owner),
|
||||
]);
|
||||
return { status: 200 as const, body: [...origins, ...user] };
|
||||
},
|
||||
listLibItems: async ({ params, headers }) => {
|
||||
// User libs win over origins on an id clash (the editor's writable lib).
|
||||
const user = await listUserItems(userLibs, ownerOf(headers), params.lib);
|
||||
const items = user ?? (await listLibItems(libs, params.lib));
|
||||
if (items === null) {
|
||||
return { status: 404 as const, body: { message: "library not found" } };
|
||||
}
|
||||
return { status: 200 as const, body: items };
|
||||
},
|
||||
createLib: async ({ body, headers }) => {
|
||||
try {
|
||||
const lib = await createUserLib(userLibs, ownerOf(headers), body.name);
|
||||
return { status: 201 as const, body: lib };
|
||||
} catch (e) {
|
||||
if (e instanceof UserLibError && e.status === 409) {
|
||||
return { status: 409 as const, body: { message: e.message } };
|
||||
}
|
||||
return {
|
||||
status: 400 as const,
|
||||
body: { message: e instanceof Error ? e.message : "bad request" },
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
await app.register(s.plugin(router));
|
||||
|
||||
// Streamed item-body fetch (text; intentionally not a ts-rest endpoint).
|
||||
// User libs are resolved first (owner-scoped), then read-only origins.
|
||||
app.get<{ Params: { lib: string; kind: string; name: string } }>(
|
||||
"/api/libs/:lib/items/:kind/:name",
|
||||
async (req, reply) => {
|
||||
const abs = itemBodyPath(libs, req.params.lib, req.params.kind, req.params.name);
|
||||
const { lib, kind, name } = req.params;
|
||||
const userPath = userItemBodyPath(userLibs, ownerOf(req.headers), lib, kind, name);
|
||||
const userExists =
|
||||
userPath && (await fs.stat(userPath).then((st) => st.isFile()).catch(() => false));
|
||||
const abs = userExists ? userPath! : itemBodyPath(libs, lib, kind, name);
|
||||
if (!abs) return reply.code(400).send({ message: "invalid item" });
|
||||
const st = await fs.stat(abs).catch(() => null);
|
||||
if (!st?.isFile()) {
|
||||
|
|
@ -175,6 +232,32 @@ async function main(): Promise<void> {
|
|||
},
|
||||
);
|
||||
|
||||
// Item-body WRITE (text; the mirror of the GET above). Owner from OWNER_HEADER.
|
||||
app.put<{ Params: { lib: string; kind: string; name: string } }>(
|
||||
"/api/libs/:lib/items/:kind/:name",
|
||||
async (req, reply) => {
|
||||
const { lib, kind, name } = req.params;
|
||||
const body = typeof req.body === "string" ? req.body : "";
|
||||
if (!body) return reply.code(400).send({ message: "empty body" });
|
||||
try {
|
||||
const item = await writeUserItem(
|
||||
userLibs,
|
||||
ownerOf(req.headers),
|
||||
lib,
|
||||
kind,
|
||||
name,
|
||||
body,
|
||||
);
|
||||
return reply.code(200).send(item);
|
||||
} catch (e) {
|
||||
const status = e instanceof UserLibError ? e.status : 400;
|
||||
return reply
|
||||
.code(status)
|
||||
.send({ message: e instanceof Error ? e.message : "write failed" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Streamed file-byte download (binary; intentionally not a ts-rest endpoint).
|
||||
app.get<{ Params: { project: string; "*": string } }>(
|
||||
"/api/projects/:project/files/*",
|
||||
|
|
|
|||
192
web/backend/src/user-libs.ts
Normal file
192
web/backend/src/user-libs.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// Minimal reference WRITE support for the @pcbjam/shared lib protocol (0004).
|
||||
//
|
||||
// User libraries are created + written by the editor. The closed registry server
|
||||
// stores them per-user in Postgres+R2; this open reference server keeps them as
|
||||
// plain files under USER_LIBS_DIR, owner-namespaced, in the same per-lib layout
|
||||
// the read side uses:
|
||||
//
|
||||
// <USER_LIBS_DIR>/<owner>/<libId>/index.json { items: [...], type, name }
|
||||
// <USER_LIBS_DIR>/<owner>/<libId>/<Symbol>.kicad_sym the saved body (verbatim)
|
||||
//
|
||||
// No parsing: the editor sends fork-native kicad_symbol_lib bytes, stored as-is
|
||||
// (decision: user-saved bodies round-trip without a version shim). Owner comes
|
||||
// from OWNER_HEADER; absent ⇒ "default".
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type { Lib, LibItem } from "@pcbjam/shared";
|
||||
|
||||
export const DEFAULT_OWNER = "default";
|
||||
|
||||
export interface UserLibsConfig {
|
||||
/** Root for owner-namespaced writable user libs; null ⇒ writes disabled. */
|
||||
dir: string | null;
|
||||
}
|
||||
|
||||
export function userLibsConfig(): UserLibsConfig {
|
||||
const dir = process.env.USER_LIBS_DIR ?? ".user-libs";
|
||||
return { dir: path.resolve(process.cwd(), dir) };
|
||||
}
|
||||
|
||||
/** A safe path segment (owner slug, lib id, item name component). */
|
||||
const SAFE = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
||||
|
||||
function safeSeg(s: string): string | null {
|
||||
return SAFE.test(s) ? s : null;
|
||||
}
|
||||
|
||||
/** Derive a stable, filesystem-safe lib id from a display name. */
|
||||
export function slugifyLibName(name: string): string {
|
||||
const slug = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._+-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "lib";
|
||||
}
|
||||
|
||||
interface IndexFile {
|
||||
type?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
items?: {
|
||||
kind: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
keywords?: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
function libDir(cfg: UserLibsConfig, owner: string, lib: string): string | null {
|
||||
if (!cfg.dir) return null;
|
||||
const o = safeSeg(owner);
|
||||
const l = safeSeg(lib);
|
||||
if (!o || !l) return null;
|
||||
return path.join(cfg.dir, o, l);
|
||||
}
|
||||
|
||||
async function readIndex(dir: string): Promise<IndexFile | null> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(path.join(dir, "index.json"), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex(dir: string, idx: IndexFile): Promise<void> {
|
||||
await fs.writeFile(path.join(dir, "index.json"), JSON.stringify(idx, null, 2));
|
||||
}
|
||||
|
||||
export class UserLibError extends Error {
|
||||
constructor(
|
||||
public status: 400 | 404 | 409,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a user lib for an owner. Idempotent-ish: 409 if the id already exists. */
|
||||
export async function createUserLib(
|
||||
cfg: UserLibsConfig,
|
||||
owner: string,
|
||||
name: string,
|
||||
): Promise<Lib> {
|
||||
const id = slugifyLibName(name);
|
||||
const dir = libDir(cfg, owner, id);
|
||||
if (!dir) throw new UserLibError(400, "user libs not configured or bad name");
|
||||
if (await readIndex(dir)) {
|
||||
throw new UserLibError(409, `library "${id}" already exists`);
|
||||
}
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const idx: IndexFile = { type: "user", name, description: null, items: [] };
|
||||
await writeIndex(dir, idx);
|
||||
return { id, name, type: "user", description: null, itemCount: 0 };
|
||||
}
|
||||
|
||||
/** List an owner's user libs. */
|
||||
export async function listUserLibs(
|
||||
cfg: UserLibsConfig,
|
||||
owner: string,
|
||||
): Promise<Lib[]> {
|
||||
const o = cfg.dir && safeSeg(owner);
|
||||
if (!cfg.dir || !o) return [];
|
||||
const root = path.join(cfg.dir, o);
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: Lib[] = [];
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
||||
const idx = await readIndex(path.join(root, e.name));
|
||||
if (!idx) continue;
|
||||
out.push({
|
||||
id: e.name,
|
||||
name: idx.name ?? e.name,
|
||||
type: "user",
|
||||
description: idx.description ?? null,
|
||||
itemCount: idx.items?.length ?? 0,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** List a user lib's items, or null if the lib isn't a user lib for this owner. */
|
||||
export async function listUserItems(
|
||||
cfg: UserLibsConfig,
|
||||
owner: string,
|
||||
lib: string,
|
||||
): Promise<LibItem[] | null> {
|
||||
const dir = libDir(cfg, owner, lib);
|
||||
if (!dir) return null;
|
||||
const idx = await readIndex(dir);
|
||||
if (!idx) return null;
|
||||
return (idx.items ?? []).map((i) => ({
|
||||
kind: i.kind,
|
||||
name: i.name,
|
||||
description: i.description ?? null,
|
||||
keywords: i.keywords ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Resolve the on-disk body path for a user item (read), or null. */
|
||||
export function userItemBodyPath(
|
||||
cfg: UserLibsConfig,
|
||||
owner: string,
|
||||
lib: string,
|
||||
kind: string,
|
||||
name: string,
|
||||
): string | null {
|
||||
const dir = libDir(cfg, owner, lib);
|
||||
if (!dir || kind !== "symbol" || !safeSeg(name)) return null;
|
||||
return path.join(dir, `${name}.kicad_sym`);
|
||||
}
|
||||
|
||||
/** Write one symbol body into a user lib + index it. */
|
||||
export async function writeUserItem(
|
||||
cfg: UserLibsConfig,
|
||||
owner: string,
|
||||
lib: string,
|
||||
kind: string,
|
||||
name: string,
|
||||
body: string,
|
||||
): Promise<LibItem> {
|
||||
if (kind !== "symbol") throw new UserLibError(400, "only symbols for now");
|
||||
const dir = libDir(cfg, owner, lib);
|
||||
if (!dir || !safeSeg(name)) throw new UserLibError(400, "bad lib or item name");
|
||||
const idx = await readIndex(dir);
|
||||
if (!idx) throw new UserLibError(404, `user library "${lib}" not found`);
|
||||
|
||||
await fs.writeFile(path.join(dir, `${name}.kicad_sym`), body, "utf8");
|
||||
|
||||
const items = idx.items ?? [];
|
||||
const existing = items.find((i) => i.kind === kind && i.name === name);
|
||||
if (!existing) items.push({ kind, name });
|
||||
idx.items = items;
|
||||
await writeIndex(dir, idx);
|
||||
|
||||
return { kind, name, description: null, keywords: null };
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit eb806fc14ba1f91d8f99f03186add68d2a19a033
|
||||
Subproject commit a1d18b53a7b976c219a824800027c289b9e9fe2c
|
||||
Loading…
Reference in a new issue