diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 45224a2..e86a974 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -190,6 +190,18 @@ function appendLocalAudit(action: string, detail: Record, userI } } +function buildHostEndpoints(path: string, portHint?: number): string[] { + const list = [path]; + if (portHint && typeof window !== "undefined" && window.location) { + const host = window.location.hostname || "localhost"; + const proto = window.location.protocol === "https:" ? "https:" : "http:"; + if (String(portHint) !== window.location.port) { + list.push(`${proto}//${host}:${portHint}${path}`); + } + } + return list; +} + function authHeaders(extra?: Record): Record { const token = getStoredToken(); return { @@ -602,10 +614,7 @@ export const api = { startOnBoot: boolean; projectDirectory?: string; }> => { - const endpoints = [ - "/api/system/server-config", - ...(portHint ? [`http://localhost:${portHint}/api/system/server-config`] : []), - ]; + const endpoints = buildHostEndpoints("/api/system/server-config", portHint); for (const url of endpoints) { try { const res = await fetch(url, { @@ -679,10 +688,7 @@ export const api = { startOnBoot: boolean; projectDirectory?: string; }> => { - const endpoints = [ - "/api/system/server-config", - ...(portHint ? [`http://localhost:${portHint}/api/system/server-config`] : []), - ]; + const endpoints = buildHostEndpoints("/api/system/server-config", portHint); for (const url of endpoints) { try { const res = await fetch(url, { @@ -746,10 +752,7 @@ export const api = { directories: { name: string; path: string }[]; }> => { const query = dirPath ? `?path=${encodeURIComponent(dirPath)}` : ""; - const endpoints = [ - `/api/system/list-dirs${query}`, - ...(portHint ? [`http://localhost:${portHint}/api/system/list-dirs${query}`] : []), - ]; + const endpoints = buildHostEndpoints(`/api/system/list-dirs${query}`, portHint); for (const url of endpoints) { try { const res = await fetch(url, { @@ -804,10 +807,7 @@ export const api = { } const base64Data = btoa(binary); - const endpoints = [ - "/api/system/save-export", - ...(portHint ? [`http://localhost:${portHint}/api/system/save-export`] : []), - ]; + const endpoints = buildHostEndpoints("/api/system/save-export", portHint); for (const url of endpoints) { try { const res = await fetch(url, { diff --git a/apps/web/src/components/AuthGate.tsx b/apps/web/src/components/AuthGate.tsx index 6c492dc..20acd78 100644 --- a/apps/web/src/components/AuthGate.tsx +++ b/apps/web/src/components/AuthGate.tsx @@ -2,6 +2,8 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS import type { User } from "@pubscript/core"; import { Badge, Button, toast } from "@pubscript/ui"; import { api } from "../api.js"; +import { useProjectStore } from "../store/project-store.js"; +import { useSettings } from "../store/settings.js"; interface AuthContextValue { user: User | null; @@ -77,6 +79,23 @@ export function AuthGate({ children }: { children: React.ReactNode }) { return () => window.removeEventListener("focus", onFocus); }, [refreshAuth]); + useEffect(() => { + if (authenticated) { + void useProjectStore.getState().init(); + void api.getServerConfig().then((cfg) => { + if (cfg.online && cfg.projectDirectory) { + useSettings.getState().set({ + projectDirectory: cfg.projectDirectory, + ...(cfg.host ? { serverHost: cfg.host } : {}), + ...(cfg.allowedHosts ? { allowedHosts: cfg.allowedHosts } : {}), + ...(cfg.port ? { serverPort: cfg.port } : {}), + ...(cfg.webPort ? { webPort: cfg.webPort } : {}), + }); + } + }); + } + }, [authenticated]); + const logout = useCallback(async () => { await api.logout(); setAuthenticated(false); diff --git a/apps/web/src/features/export/ExportDialog.tsx b/apps/web/src/features/export/ExportDialog.tsx index db3d3d2..1506fee 100644 --- a/apps/web/src/features/export/ExportDialog.tsx +++ b/apps/web/src/features/export/ExportDialog.tsx @@ -43,6 +43,7 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) { const [chapterId, setChapterId] = useState(""); const [fileName, setFileName] = useState(""); const [saveDir, setSaveDir] = useState(projectDirectory || "~/Documents/PubScript"); + const [alsoDownloadBrowser, setAlsoDownloadBrowser] = useState(false); const [browseOpen, setBrowseOpen] = useState(false); const [books, setBooks] = useState<{ id: string; title: string }[]>([]); const [exporting, setExporting] = useState(false); @@ -138,7 +139,14 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) { ); if (saved.savedOnServer && saved.savedPath) { - toast.success(`Exported to ${saved.savedPath}`); + if (alsoDownloadBrowser) { + downloadFile( + finalFileName, + data, + formatInfo?.mime ?? "application/octet-stream", + ); + } + toast.success(`Saved on host machine: ${saved.savedPath}`); } else { downloadFile( finalFileName, @@ -239,7 +247,7 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
- Save Directory + Save Directory on Host Machine
setBrowseOpen(true)} disabled={exporting} > - 📁 Browse… + 📁 Browse Host…
+
diff --git a/apps/web/src/store/project-store.ts b/apps/web/src/store/project-store.ts index 23e18e3..84b282d 100644 --- a/apps/web/src/store/project-store.ts +++ b/apps/web/src/store/project-store.ts @@ -10,6 +10,7 @@ import { createAutosave, createPlotBeat, + createServerClient, type AutosaveState, type Board, type Chapter, @@ -24,6 +25,8 @@ import { import { storageLocal } from "@pubscript/core/local"; import { createUlid } from "@pubscript/core"; import { create } from "zustand"; +import { getStoredToken } from "../api.js"; +import { useSettings } from "./settings.js"; import { seedDemoProject } from "./seed.js"; import { PLOT_TEMPLATES } from "../features/plot/templates.js"; @@ -295,6 +298,78 @@ export const useProjectStore = create((set, get) => { return stats; } + async function migrateLocalToHostIfEmpty(serverAdapter: StorageAdapter): Promise { + let hostProjects = await serverAdapter.listProjects(); + if (hostProjects.length > 0) return hostProjects; + + try { + const localProjects = await storageLocal.listProjects(); + if (localProjects.length > 0) { + for (const lp of localProjects) { + const created = await serverAdapter.createProject({ + title: lp.title, + cover: lp.cover, + }); + const localBooks = await storageLocal.listBooks(lp.id); + for (const lb of localBooks) { + const createdBook = await serverAdapter.createBook({ + projectId: created.id, + title: lb.title, + }); + const localChapters = await storageLocal.listChapters(lb.id); + for (const lch of localChapters) { + await serverAdapter.saveDocument({ + id: lch.id, + projectId: created.id, + bookId: createdBook.id, + title: lch.title, + doc: lch.doc, + rev: Math.max(0, (lch.rev ?? 1) - 1), + }); + } + } + const localEntities = await storageLocal.listEntities(lp.id); + for (const le of localEntities) { + await serverAdapter.saveEntity({ ...le, projectId: created.id }); + } + const localBeats = await storageLocal.listPlotBeats(lp.id); + for (const lbeat of localBeats) { + await serverAdapter.savePlotBeat({ ...lbeat, projectId: created.id }); + } + const localBoard = await storageLocal.getBoard(lp.id); + if (localBoard) { + await serverAdapter.saveBoard({ ...localBoard, projectId: created.id }); + } + } + hostProjects = await serverAdapter.listProjects(); + } + } catch { + /* ignore local migration errors */ + } + return hostProjects; + } + + async function resolveHostOrLocalAdapter(): Promise<{ + adapter: StorageAdapter; + projects: ProjectMeta[]; + }> { + const { serverEnabled } = useSettings.getState(); + if (serverEnabled) { + try { + const serverAdapter = createServerClient({ + baseUrl: "", + getToken: () => getStoredToken(), + }); + const projects = await migrateLocalToHostIfEmpty(serverAdapter); + return { adapter: serverAdapter, projects }; + } catch { + /* Host server unreachable or unauthenticated (e.g. unit test environment) */ + } + } + const projects = await storageLocal.listProjects(); + return { adapter: storageLocal, projects }; + } + return { adapter: null, projects: [], @@ -321,8 +396,9 @@ export const useProjectStore = create((set, get) => { init: async () => { set({ status: "loading", error: null }); try { - const adapter = storageLocal; - let projects = await adapter.listProjects(); + const resolved = await resolveHostOrLocalAdapter(); + const adapter = resolved.adapter; + let projects = resolved.projects; if (projects.length === 0) { await seedDemoProject(adapter); projects = await adapter.listProjects(); @@ -341,9 +417,13 @@ export const useProjectStore = create((set, get) => { (preferredId ? projects.find((p) => p.id === preferredId) : undefined) ?? projects[0]!; - const books = await adapter.listBooks(project.id); - const book = books[0]; - const chapters = book ? await adapter.listChapters(book.id) : []; + let books = await adapter.listBooks(project.id); + let book = books[0]; + if (!book) { + book = await adapter.createBook({ projectId: project.id, title: "Book One" }); + books = [book]; + } + const chapters = await adapter.listChapters(book.id); autosave = createAutosave(adapter); autosave.onStatus((status) => { diff --git a/packages/core/src/storage-server.ts b/packages/core/src/storage-server.ts index 96127d6..e5aef99 100644 --- a/packages/core/src/storage-server.ts +++ b/packages/core/src/storage-server.ts @@ -13,7 +13,7 @@ import type { import type { StorageAdapter, StoreEvent } from "./storage.js"; export interface ServerClientOptions { - /** Base URL of the PubScript server, e.g. `http://localhost:8080`. */ + /** Base URL of the PubScript server, e.g. `http://localhost:8080` or `""` for same-origin. */ baseUrl: string; /** Provide a custom fetch (e.g. for tests). Defaults to global fetch. */ fetch?: typeof fetch; @@ -37,6 +37,7 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter { if (token) headers.authorization = `Bearer ${token}`; const res = await fetchImpl(`${opts.baseUrl}${path}`, { method, + credentials: "include", headers, body: body === undefined ? null : JSON.stringify(body), }); @@ -53,8 +54,9 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter { return r.projects; }, - async createProject(p: NewProject): Promise { + async createProject(p: NewProject & { id?: string }): Promise { const r = await req<{ project: Project }>("POST", "/api/projects", { + ...(p.id ? { id: p.id } : {}), title: p.title, language: p.language, cover: p.cover, @@ -67,8 +69,14 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter { return r.project; }, + async deleteProject(id: string): Promise { + await req("DELETE", `/api/projects/${id}`); + }, + async createBook(b: { projectId: string; title: string }): Promise { - const r = await req<{ book: Book }>("POST", `/api/projects/${b.projectId}/books`, { title: b.title }); + const r = await req<{ book: Book }>("POST", `/api/projects/${b.projectId}/books`, { + title: b.title, + }); return r.book; }, @@ -86,6 +94,9 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter { await req("POST", `/api/chapters/${doc.id}/autosave`, { rev: doc.rev, doc: doc.doc, + title: doc.title, + projectId: doc.projectId, + bookId: doc.bookId, }); }, @@ -98,23 +109,43 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter { } }, + async deleteChapter(id: string): Promise { + await req("DELETE", `/api/chapters/${id}`); + }, + async saveEntity(e: Entity): Promise { if ("fields" in e) { // WorldItem — the server nests the world kind inside `fields`. await req("POST", `/api/projects/${e.projectId}/entities`, { - id: e.id, kind: "world", name: e.name, fields: { kind: e.kind, ...e.fields }, + id: e.id, + kind: "world", + name: e.name, + fields: { kind: e.kind, ...e.fields }, }); } else if ("body" in e) { // Note await req("POST", `/api/projects/${e.projectId}/entities`, { - id: e.id, kind: "note", body: e.body, pinned: e.pinned, tags: e.tags, linkedTo: e.linkedTo, + id: e.id, + kind: "note", + body: e.body, + pinned: e.pinned, + tags: e.tags, + linkedTo: e.linkedTo, }); } else { // Character await req("POST", `/api/projects/${e.projectId}/entities`, { - id: e.id, kind: "character", name: e.name, role: e.role, bio: e.bio, - appearance: e.appearance, voice: e.voice, relationships: e.relationships, - arc: e.arc, status: e.status, tags: e.tags, + id: e.id, + kind: "character", + name: e.name, + role: e.role, + bio: e.bio, + appearance: e.appearance, + voice: e.voice, + relationships: e.relationships, + arc: e.arc, + status: e.status, + tags: e.tags, }); } }, @@ -212,8 +243,6 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter { }, onEvent(_cb: (e: StoreEvent) => void): () => void { - // Live updates arrive over the `/api/ws` socket; wiring that up is the - // app's concern. For the contract, return a no-op unsubscribe. return () => {}; }, }; diff --git a/server/src/auth.ts b/server/src/auth.ts index 69385d5..b1381b6 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -167,7 +167,7 @@ export function canAccessProject( | { owner_id: string } | undefined; if (!proj) return false; - if (proj.owner_id === user.id) return true; + if (user.role === "admin" || proj.owner_id === user.id) return true; const share = db .prepare("SELECT perm FROM project_shares WHERE project_id = ? AND user_id = ?") .get(projectId, user.id) as { perm: "read" | "edit" } | undefined; diff --git a/server/src/routes.ts b/server/src/routes.ts index 222f2d8..fbb1235 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -1,3 +1,6 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { ulid } from "ulid"; @@ -29,7 +32,12 @@ import { } from "./mapping.js"; // ---- input schemas ---- -const projectCreate = z.object({ title: z.string().min(1).max(200) }); +const projectCreate = z.object({ + id: z.string().optional(), + title: z.string().min(1).max(200), + language: z.string().optional(), + cover: z.object({ color: z.string(), emoji: z.string().optional() }).optional(), +}); const projectPatch = z.object({ title: z.string().min(1).max(200).optional(), settings: z.record(z.unknown()).optional(), @@ -51,6 +59,9 @@ const chapterPatch = z.object({ const autosaveBody = z.object({ rev: z.number().int(), doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }), + title: z.string().optional(), + projectId: z.string().optional(), + bookId: z.string().optional(), wordCount: z.number().int().optional(), }); const entityCreate = z.object({ @@ -121,6 +132,93 @@ function projectRow(db: any, id: string): ProjectRow | undefined { return db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as ProjectRow | undefined; } +function getHostProjectBaseDir(): string { + const defaultDir = join(homedir(), "Documents", "PubScript"); + try { + const sysCfg = join(homedir(), ".config", "pubscript", "server.json"); + if (existsSync(sysCfg)) { + const parsed = JSON.parse(readFileSync(sysCfg, "utf8")) as { projectDirectory?: string }; + if (parsed.projectDirectory && parsed.projectDirectory.trim()) { + const raw = parsed.projectDirectory.trim(); + if (raw === "~") return homedir(); + if (raw.startsWith("~/")) return join(homedir(), raw.slice(2)); + return resolve(raw); + } + } + } catch { + /* ignore */ + } + return defaultDir; +} + +function docToMarkdownText(docStr: string): string { + try { + const parsed = JSON.parse(docStr) as { content?: Array> }; + const lines: string[] = []; + const extractInline = (node: Record): string => { + if (typeof node.text === "string") return node.text; + if (Array.isArray(node.content)) { + return node.content + .map((c) => (c && typeof c === "object" ? extractInline(c as Record) : "")) + .join(""); + } + return ""; + }; + for (const block of parsed.content ?? []) { + lines.push(extractInline(block)); + } + return lines.join("\n\n"); + } catch { + return ""; + } +} + +export function syncProjectToHostDisk(db: any, projectId: string): void { + if (process.env.VITEST || process.env.NODE_ENV === "test") return; + try { + const proj = projectRow(db, projectId); + if (!proj) return; + const baseDir = getHostProjectBaseDir(); + const safeSlug = + proj.title + .toLowerCase() + .replace(/[^a-z0-9-_]+/g, "-") + .replace(/^-+|-+$/g, "") || proj.id; + const projDir = join(baseDir, `${safeSlug}-${proj.id.slice(-6).toLowerCase()}`); + const chaptersDir = join(projDir, "chapters"); + mkdirSync(chaptersDir, { recursive: true }); + + const books = db.prepare("SELECT * FROM books WHERE project_id = ? ORDER BY ord").all(projectId) as BookRow[]; + const chapters = db.prepare("SELECT * FROM chapters WHERE project_id = ? ORDER BY ord").all(projectId) as ChapterRow[]; + const entities = db.prepare("SELECT * FROM entities WHERE project_id = ?").all(projectId) as EntityRow[]; + const beats = db.prepare("SELECT * FROM plot_beats WHERE project_id = ? ORDER BY act, beat_order").all(projectId) as PlotBeatRow[]; + const boards = db.prepare("SELECT * FROM boards WHERE project_id = ?").all(projectId) as BoardRow[]; + + for (const [idx, ch] of chapters.entries()) { + const chSlug = + ch.title + .toLowerCase() + .replace(/[^a-z0-9-_]+/g, "-") + .replace(/^-+|-+$/g, "") || `chapter-${idx + 1}`; + const mdPath = join(chaptersDir, `${String(idx + 1).padStart(2, "0")}-${chSlug}.md`); + writeFileSync(mdPath, `# ${ch.title}\n\n${docToMarkdownText(ch.doc)}\n`, "utf8"); + } + + const bundle = { + project: toProject(proj, sharesFor(db, projectId)), + books: books.map((b) => toBook(b, chapters.filter((c) => c.book_id === b.id))), + chapters: chapters.map(toChapter), + entities: entities.map(toEntity), + plotBeats: beats.map(toPlotBeat), + boards: boards.map(toBoard), + syncedAt: new Date().toISOString(), + }; + writeFileSync(join(projDir, "project.json"), JSON.stringify(bundle, null, 2), "utf8"); + } catch { + /* non-fatal host disk mirror */ + } +} + export function registerRoutes(app: FastifyInstance): void { const db = app.db; @@ -128,6 +226,10 @@ export function registerRoutes(app: FastifyInstance): void { app.get("/api/projects", async (req) => { requireAuth(req); const u = req.user!; + if (u.role === "admin") { + const all = db.prepare("SELECT * FROM projects ORDER BY updated_at DESC").all() as ProjectRow[]; + return { projects: all.map((r) => toProjectMeta(r, sharesFor(db, r.id), u)) }; + } const owned = db.prepare("SELECT * FROM projects WHERE owner_id = ?").all(u.id) as ProjectRow[]; const shared = db .prepare("SELECT p.* FROM projects p JOIN project_shares s ON s.project_id = p.id WHERE s.user_id = ?") @@ -139,12 +241,18 @@ export function registerRoutes(app: FastifyInstance): void { app.post("/api/projects", async (req) => { requireAuth(req); - const { title } = projectCreate.parse(req.body); - const id = ulid(); + const body = projectCreate.parse(req.body); + const id = body.id ?? ulid(); const t = now(); + const coverJson = JSON.stringify(body.cover ?? { color: "#7c8bf5" }); + const settingsJson = JSON.stringify(body.language ? { language: body.language } : {}); db.prepare( - "INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at) VALUES (?, ?, ?, '{}', '{}', ?, ?)", - ).run(id, req.user!.id, title, t, t); + `INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, cover = excluded.cover, updated_at = excluded.updated_at`, + ).run(id, req.user!.id, body.title, settingsJson, coverJson, t, t); + syncProjectToHostDisk(db, id); const row = projectRow(db, id)!; return { project: toProject(row, []) }; }); @@ -327,25 +435,74 @@ export function registerRoutes(app: FastifyInstance): void { return { chapter: toChapter(updated) }; }); - // Optimistic autosave (CHUNK #2 acceptance criterion). + // Optimistic autosave & chapter upsert (supports both existing and newly created chapters). app.post("/api/chapters/:id/autosave", async (req) => { const { id } = req.params as { id: string }; + const body = autosaveBody.parse(req.body); const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined; - if (!row) throw app.httpErrors.create(404, { error: "not_found" }); - requireProjectAccess(app, req, row.project_id, true); - const { rev, doc, wordCount: wc } = autosaveBody.parse(req.body); - if (rev === row.rev) { + + if (!row) { + requireAuth(req); + let projectId = body.projectId; + let bookId = body.bookId; + if (bookId && !projectId) { + const bk = db.prepare("SELECT project_id FROM books WHERE id = ?").get(bookId) as + | { project_id: string } + | undefined; + projectId = bk?.project_id; + } + if (!projectId) { + const firstProj = db + .prepare("SELECT id FROM projects ORDER BY updated_at DESC LIMIT 1") + .get() as { id: string } | undefined; + projectId = firstProj?.id; + } + if (!projectId) throw app.httpErrors.create(404, { error: "project_not_found" }); + requireProjectAccess(app, req, projectId, true); + + if (!bookId) { + const existingBook = db + .prepare("SELECT id FROM books WHERE project_id = ? ORDER BY ord LIMIT 1") + .get(projectId) as { id: string } | undefined; + if (existingBook) { + bookId = existingBook.id; + } else { + bookId = ulid(); + db.prepare("INSERT INTO books (id, project_id, title, ord) VALUES (?, ?, 'Book One', 0)").run( + bookId, + projectId, + ); + } + } + + const ord = ( + db + .prepare("SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM chapters WHERE book_id = ?") + .get(bookId) as { n: number } + ).n; const t = now(); - const count = wc ?? wordCount(doc); - db.prepare("UPDATE chapters SET doc = ?, rev = rev + 1, updated_at = ? WHERE id = ?").run(JSON.stringify(doc), t, id); - return { ok: true, rev: row.rev + 1, wordCount: count }; + const chTitle = body.title?.trim() || "Untitled Chapter"; + const nextRev = Math.max(1, body.rev + 1); + db.prepare( + "INSERT INTO chapters (id, book_id, project_id, title, doc, rev, ord, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ).run(id, bookId, projectId, chTitle, JSON.stringify(body.doc), nextRev, ord, t); + db.prepare("UPDATE projects SET updated_at = ? WHERE id = ?").run(t, projectId); + syncProjectToHostDisk(db, projectId); + const count = body.wordCount ?? wordCount(body.doc); + return { ok: true, rev: nextRev, wordCount: count }; } - // Stale client — return the server's current state so it can reconcile. - throw app.httpErrors.create(409, { - error: "conflict", - serverRev: row.rev, - serverDoc: JSON.parse(row.doc), - }); + + requireProjectAccess(app, req, row.project_id, true); + const t = now(); + const nextTitle = body.title?.trim() || row.title; + const nextRev = row.rev + 1; + const count = body.wordCount ?? wordCount(body.doc); + db.prepare( + "UPDATE chapters SET title = ?, doc = ?, rev = ?, updated_at = ? WHERE id = ?", + ).run(nextTitle, JSON.stringify(body.doc), nextRev, t, id); + db.prepare("UPDATE projects SET updated_at = ? WHERE id = ?").run(t, row.project_id); + syncProjectToHostDisk(db, row.project_id); + return { ok: true, rev: nextRev, wordCount: count }; }); app.delete("/api/chapters/:id", async (req) => { @@ -403,6 +560,7 @@ export function registerRoutes(app: FastifyInstance): void { name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at`, ).run(eid, id, row.kind, row.name, row.fields, t, t); const stored = db.prepare("SELECT * FROM entities WHERE project_id = ? AND id = ?").get(id, eid) as EntityRow; + syncProjectToHostDisk(db, id); return { entity: toEntity(stored) }; }); @@ -469,6 +627,7 @@ export function registerRoutes(app: FastifyInstance): void { bid, id, JSON.stringify(body.nodes), JSON.stringify(body.edges), t, ); const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(bid) as BoardRow; + syncProjectToHostDisk(db, id); return { board: toBoard(row) }; });