From db913c5a740eb54a36231aa723a1f92baf20a78d Mon Sep 17 00:00:00 2001 From: src-opn Date: Thu, 28 May 2026 18:27:33 -0700 Subject: [PATCH 1/3] feat: migrate server + desktop state to a local SQLite DB Introduce @openwork/desktop-db (Drizzle + SQLite, runtime-adaptive between better-sqlite3 on Node/Electron and bun:sqlite under the server) as the single source of truth for OpenWork-owned state on the user's machine. Every table uses TypeID ids via a desktop-local registry (separate from the cloud ee/utils one). Migrated to the DB (with one-time import, .pre-db.bak snapshots so source files are preserved for revert, and a migration_state fingerprint guard so we never re-import on every start): - server.json: server config + workspace registry + authorizedRoots - tokens.json: scoped API tokens (hashed) - audit/*.jsonl: audit log - Electron openwork-workspaces.json: workspace list + selection - Electron openwork-server-tokens.json: per-workspace server tokens - Electron openwork-server-state.json: preferred ports - Renderer "real state" localStorage keys: model prefs, drafts, shell-config, onboarding flags, etc. via boot hydration + write-through mirror (web unchanged) Not yet migrated (deferred): opencode.json/jsonc (MCP/plugins/providers), env.json, .opencode/openwork.json sections, Google Workspace OAuth vault. Skills/commands/ agents stay as files (OpenCode + reload watcher depend on them). --- apps/app/src/app/lib/desktop.ts | 5 + apps/app/src/index.react.tsx | 4 + .../src/react-app/shell/desktop-pref-sync.ts | 119 ++ apps/desktop/electron/desktop-db.mjs | 258 +++ apps/desktop/electron/main.mjs | 54 +- apps/desktop/electron/preload.mjs | 11 + apps/desktop/electron/runtime.mjs | 82 +- apps/desktop/package.json | 1 + apps/server/bunfig.toml | 2 + apps/server/package.json | 3 +- apps/server/src/audit.ts | 118 +- apps/server/src/cli.ts | 2 + apps/server/src/db.ts | 141 ++ apps/server/src/embedded.ts | 4 + apps/server/src/server.ts | 124 +- apps/server/src/test-preload.ts | 25 + apps/server/src/tokens.ts | 161 +- .../server/src/workspace-activate.e2e.test.ts | 73 +- .../src/workspace-import-preview.test.ts | 30 +- packages/desktop-db/drizzle.config.ts | 17 + .../drizzle/0000_giant_shatterstar.sql | 219 +++ .../drizzle/0001_shocking_mastermind.sql | 8 + .../drizzle/0002_flimsy_chamber.sql | 2 + .../drizzle/meta/0000_snapshot.json | 1399 ++++++++++++++++ .../drizzle/meta/0001_snapshot.json | 1453 ++++++++++++++++ .../drizzle/meta/0002_snapshot.json | 1467 +++++++++++++++++ .../desktop-db/drizzle/meta/_journal.json | 27 + packages/desktop-db/package.json | 71 + packages/desktop-db/src/client.ts | 165 ++ packages/desktop-db/src/columns.ts | 68 + packages/desktop-db/src/desktop-db.test.ts | 128 ++ .../desktop-db/src/desktop-import.test.ts | 139 ++ packages/desktop-db/src/drizzle.ts | 18 + packages/desktop-db/src/import-once.test.ts | 89 + packages/desktop-db/src/import/audit-jsonl.ts | 82 + packages/desktop-db/src/import/desktop.ts | 295 ++++ packages/desktop-db/src/import/fingerprint.ts | 47 + packages/desktop-db/src/import/helpers.ts | 30 + packages/desktop-db/src/import/import-once.ts | 204 +++ packages/desktop-db/src/import/index.ts | 68 + packages/desktop-db/src/import/paths.ts | 35 + packages/desktop-db/src/import/server-json.ts | 171 ++ packages/desktop-db/src/import/tokens-json.ts | 61 + packages/desktop-db/src/index.ts | 50 + packages/desktop-db/src/preferences.test.ts | 59 + packages/desktop-db/src/preferences.ts | 86 + packages/desktop-db/src/schema/audit.ts | 36 + packages/desktop-db/src/schema/env.ts | 26 + packages/desktop-db/src/schema/extensions.ts | 48 + packages/desktop-db/src/schema/index.ts | 9 + .../desktop-db/src/schema/migration-state.ts | 28 + .../desktop-db/src/schema/opencode-config.ts | 83 + .../desktop-db/src/schema/server-config.ts | 25 + packages/desktop-db/src/schema/sessions.ts | 69 + packages/desktop-db/src/schema/tokens.ts | 53 + packages/desktop-db/src/schema/workspaces.ts | 136 ++ packages/desktop-db/src/typeid.ts | 222 +++ packages/desktop-db/tsconfig.json | 17 + packages/desktop-db/tsup.config.ts | 25 + pnpm-lock.yaml | 174 +- 60 files changed, 8215 insertions(+), 411 deletions(-) create mode 100644 apps/app/src/react-app/shell/desktop-pref-sync.ts create mode 100644 apps/desktop/electron/desktop-db.mjs create mode 100644 apps/server/bunfig.toml create mode 100644 apps/server/src/db.ts create mode 100644 apps/server/src/test-preload.ts create mode 100644 packages/desktop-db/drizzle.config.ts create mode 100644 packages/desktop-db/drizzle/0000_giant_shatterstar.sql create mode 100644 packages/desktop-db/drizzle/0001_shocking_mastermind.sql create mode 100644 packages/desktop-db/drizzle/0002_flimsy_chamber.sql create mode 100644 packages/desktop-db/drizzle/meta/0000_snapshot.json create mode 100644 packages/desktop-db/drizzle/meta/0001_snapshot.json create mode 100644 packages/desktop-db/drizzle/meta/0002_snapshot.json create mode 100644 packages/desktop-db/drizzle/meta/_journal.json create mode 100644 packages/desktop-db/package.json create mode 100644 packages/desktop-db/src/client.ts create mode 100644 packages/desktop-db/src/columns.ts create mode 100644 packages/desktop-db/src/desktop-db.test.ts create mode 100644 packages/desktop-db/src/desktop-import.test.ts create mode 100644 packages/desktop-db/src/drizzle.ts create mode 100644 packages/desktop-db/src/import-once.test.ts create mode 100644 packages/desktop-db/src/import/audit-jsonl.ts create mode 100644 packages/desktop-db/src/import/desktop.ts create mode 100644 packages/desktop-db/src/import/fingerprint.ts create mode 100644 packages/desktop-db/src/import/helpers.ts create mode 100644 packages/desktop-db/src/import/import-once.ts create mode 100644 packages/desktop-db/src/import/index.ts create mode 100644 packages/desktop-db/src/import/paths.ts create mode 100644 packages/desktop-db/src/import/server-json.ts create mode 100644 packages/desktop-db/src/import/tokens-json.ts create mode 100644 packages/desktop-db/src/index.ts create mode 100644 packages/desktop-db/src/preferences.test.ts create mode 100644 packages/desktop-db/src/preferences.ts create mode 100644 packages/desktop-db/src/schema/audit.ts create mode 100644 packages/desktop-db/src/schema/env.ts create mode 100644 packages/desktop-db/src/schema/extensions.ts create mode 100644 packages/desktop-db/src/schema/index.ts create mode 100644 packages/desktop-db/src/schema/migration-state.ts create mode 100644 packages/desktop-db/src/schema/opencode-config.ts create mode 100644 packages/desktop-db/src/schema/server-config.ts create mode 100644 packages/desktop-db/src/schema/sessions.ts create mode 100644 packages/desktop-db/src/schema/tokens.ts create mode 100644 packages/desktop-db/src/schema/workspaces.ts create mode 100644 packages/desktop-db/src/typeid.ts create mode 100644 packages/desktop-db/tsconfig.json create mode 100644 packages/desktop-db/tsup.config.ts diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index a534c23ccd..cbbd5dbe4c 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -118,6 +118,11 @@ declare global { onPanelOpened?: (callback: () => void) => () => void; onPanelClosed?: (callback: () => void) => () => void; }; + preferences?: { + getAll?: () => Promise>; + set?: (key: string, value: string) => Promise; + remove?: (key: string) => Promise; + }; meta?: { initialDeepLinks?: string[]; platform?: "darwin" | "linux" | "windows"; diff --git a/apps/app/src/index.react.tsx b/apps/app/src/index.react.tsx index 4ea7da98fb..ca6d3a9daf 100644 --- a/apps/app/src/index.react.tsx +++ b/apps/app/src/index.react.tsx @@ -18,11 +18,15 @@ import { import { AppProviders } from "./react-app/shell/providers"; import { AppRoot } from "./react-app/shell/app-root"; import { startDeepLinkBridge } from "./react-app/shell/startup-deep-links"; +import { initDesktopPreferenceSync } from "./react-app/shell/desktop-pref-sync"; import "./app/index.css"; bootstrapTheme(); initLocale(); startDeepLinkBridge(); +// Hydrate localStorage from the desktop DB (and install write-through) BEFORE any +// store/provider reads localStorage. No-op on web. +await initDesktopPreferenceSync(); await initializeDenBootstrapConfig(); const root = document.getElementById("root"); diff --git a/apps/app/src/react-app/shell/desktop-pref-sync.ts b/apps/app/src/react-app/shell/desktop-pref-sync.ts new file mode 100644 index 0000000000..08d2c12cdc --- /dev/null +++ b/apps/app/src/react-app/shell/desktop-pref-sync.ts @@ -0,0 +1,119 @@ +/** + * Desktop preference sync. + * + * On the desktop (Electron) shell, the SQLite DB is the durable source of truth for + * "real state" renderer preferences (connection topology, model prefs, drafts, shell + * config, onboarding flags, etc.). localStorage stays the synchronous fast-path every + * store/provider already uses; this module keeps the two in sync without rewriting any + * consumer: + * + * 1. hydrate(): on boot, copy DB preferences -> localStorage (DB wins) BEFORE any store + * reads localStorage. + * 2. installWriteThrough(): patch window.localStorage so writes/removes of MIRRORED + * keys are also pushed to the DB via the Electron `preferences` IPC. + * + * On the web (no Electron bridge) this is a no-op and localStorage behaves normally. + * + * Purely-ephemeral UI keys (scroll, sidebar widths, debug toggles) are intentionally + * NOT mirrored — see MIRRORED_PREFERENCE_KEYS / _PREFIXES. + */ + +// Keep in sync with packages/desktop-db/src/preferences.ts +const MIRRORED_PREFERENCE_KEYS: readonly string[] = [ + "openwork.server.list", + "openwork.server.active", + "openwork.server.remoteAccessEnabled", + "openwork.react.workspaceOrder", + "openwork.session-drafts.v1", + "openwork.shell-config", + "openwork.preferences", + "openwork.defaultModel", + "openwork.hiddenModels", + "openwork.skills.hubRepos.v1", + "openwork.acknowledgedProviders", + "openwork.seenProviderIds", + "openwork.orgOnboardingSeen", +]; + +const MIRRORED_PREFERENCE_PREFIXES: readonly string[] = [ + "openwork.sessionModels", + "openwork.modelVariant", + "openwork.extension.", +]; + +export function isMirroredPreferenceKey(key: string): boolean { + if (MIRRORED_PREFERENCE_KEYS.includes(key)) return true; + return MIRRORED_PREFERENCE_PREFIXES.some((prefix) => key.startsWith(prefix)); +} + +type PreferencesBridge = { + getAll?: () => Promise>; + set?: (key: string, value: string) => Promise; + remove?: (key: string) => Promise; +}; + +function getBridge(): PreferencesBridge | null { + if (typeof window === "undefined") return null; + const bridge = (window as unknown as { + __OPENWORK_ELECTRON__?: { preferences?: PreferencesBridge }; + }).__OPENWORK_ELECTRON__; + return bridge?.preferences ?? null; +} + +let installed = false; + +/** + * Hydrate localStorage from the desktop DB (DB wins), then install the write-through + * mirror. Safe to call once at boot; no-op on web. Never throws (best-effort). + */ +export async function initDesktopPreferenceSync(): Promise { + const bridge = getBridge(); + if (!bridge?.getAll) return; + + try { + const prefs = await bridge.getAll(); + for (const [key, value] of Object.entries(prefs ?? {})) { + if (typeof value !== "string") continue; + try { + window.localStorage.setItem(key, value); + } catch { + // storage may be unavailable; skip. + } + } + } catch { + // DB unavailable; fall back to whatever is already in localStorage. + } + + installWriteThrough(); +} + +/** + * Patch localStorage.setItem/removeItem so mutations to mirrored keys also write the DB. + * Idempotent. Exported separately for tests. + */ +export function installWriteThrough(): void { + if (installed) return; + const bridge = getBridge(); + if (!bridge?.set || !bridge?.remove) return; + if (typeof window === "undefined" || !window.localStorage) return; + + const storage = window.localStorage; + const originalSet = storage.setItem.bind(storage); + const originalRemove = storage.removeItem.bind(storage); + + storage.setItem = (key: string, value: string) => { + originalSet(key, value); + if (isMirroredPreferenceKey(key)) { + void bridge.set?.(key, value)?.catch?.(() => undefined); + } + }; + + storage.removeItem = (key: string) => { + originalRemove(key); + if (isMirroredPreferenceKey(key)) { + void bridge.remove?.(key)?.catch?.(() => undefined); + } + }; + + installed = true; +} diff --git a/apps/desktop/electron/desktop-db.mjs b/apps/desktop/electron/desktop-db.mjs new file mode 100644 index 0000000000..793de53c47 --- /dev/null +++ b/apps/desktop/electron/desktop-db.mjs @@ -0,0 +1,258 @@ +// DB-backed desktop state for the Electron main process. +// +// Opens the SAME SQLite DB the OpenWork server uses (next to server.json), so the +// desktop workspace list, per-workspace server tokens, and preferred ports are a single +// source of truth shared with the server. The original Electron JSON files +// (openwork-workspaces.json, openwork-server-tokens.json, openwork-server-state.json) +// are imported once and preserved as `.pre-db.bak` snapshots for revert. + +import { + openDb, + resolveDbPathForServerConfig, + runDesktopImportOnce, + workspaceTable, + workspaceServerTokenTable, + workspacePortTable, + preferenceTable, + authorizedRootTable, + drizzle, + DESKTOP_SELECTED_WORKSPACE_PREF, + DESKTOP_WATCHED_WORKSPACE_PREF, + DESKTOP_PREFERRED_PORT_PREF, + getAllMirroredPreferences, + getPreference as getPreferenceFromDb, + setPreference as setPreferenceInDb, + removePreference as removePreferenceFromDb, +} from "@openwork/desktop-db"; + +const { eq, asc } = drizzle; + +let dbPromise = null; +let importedFor = null; + +/** + * Open (and migrate) the desktop DB, then run the one-time import of the Electron state + * files. `serverConfigPath` pins the DB next to server.json. `userDataDir` locates the + * three legacy JSON files. Cached per process. + */ +export async function getDesktopDb({ serverConfigPath, userDataDir }) { + const dbPath = resolveDbPathForServerConfig(serverConfigPath); + if (!dbPromise) { + dbPromise = openDb({ path: dbPath }); + } + const db = await dbPromise; + + if (userDataDir && importedFor !== dbPath) { + importedFor = dbPath; + const path = await import("node:path"); + await runDesktopImportOnce(db, { + workspacesPath: path.join(userDataDir, "openwork-workspaces.json"), + serverTokensPath: path.join(userDataDir, "openwork-server-tokens.json"), + serverStatePath: path.join(userDataDir, "openwork-server-state.json"), + }).catch((error) => { + console.warn("[desktop-db] one-time import failed", error); + }); + } + + return db; +} + +function rowToWorkspaceEntry(row) { + const isLocal = row.workspaceType !== "remote"; + return { + id: row.id, + name: row.name, + path: row.path, + preset: row.preset ?? "starter", + workspaceType: row.workspaceType ?? "local", + remoteType: row.remoteType ?? null, + baseUrl: !isLocal ? row.baseUrl ?? null : null, + directory: !isLocal ? row.directory ?? null : null, + displayName: row.displayName ?? null, + openworkHostUrl: row.openworkHostUrl ?? null, + openworkToken: row.openworkToken ?? null, + openworkClientToken: row.openworkClientToken ?? null, + openworkHostToken: row.openworkHostToken ?? null, + openworkWorkspaceId: row.openworkWorkspaceId ?? null, + openworkWorkspaceName: row.openworkWorkspaceName ?? null, + sandboxBackend: row.sandboxBackend ?? null, + sandboxRunId: row.sandboxRunId ?? null, + sandboxContainerName: row.sandboxContainerName ?? null, + }; +} + +async function readPreference(db, key) { + const rows = await db.select().from(preferenceTable).where(eq(preferenceTable.key, key)); + return rows[0]?.value ?? null; +} + +function writePreference(db, key, value, now) { + db.insert(preferenceTable) + .values({ key, value, createdAt: now, updatedAt: now }) + .onConflictDoUpdate({ target: preferenceTable.key, set: { value, updatedAt: now } }) + .run(); +} + +/** Read the desktop workspace state (workspaces + selection) from the DB. */ +export async function readWorkspaceStateFromDb(db) { + const rows = await db.select().from(workspaceTable).orderBy(asc(workspaceTable.sortOrder)); + const workspaces = rows.map(rowToWorkspaceEntry); + const selectedId = String((await readPreference(db, DESKTOP_SELECTED_WORKSPACE_PREF)) ?? ""); + const watchedRaw = await readPreference(db, DESKTOP_WATCHED_WORKSPACE_PREF); + const watchedId = watchedRaw ? String(watchedRaw) : null; + return { + selectedId, + selectedWorkspaceId: selectedId, + watchedId, + watchedWorkspaceId: watchedId, + activeId: selectedId || null, + workspaces, + }; +} + +/** Replace the desktop workspace state (workspaces + selection) in the DB. */ +export async function writeWorkspaceStateToDb(db, nextState) { + const now = Date.now(); + const workspaces = Array.isArray(nextState?.workspaces) ? nextState.workspaces : []; + const selectedId = String(nextState?.selectedId ?? nextState?.activeId ?? ""); + const watchedId = typeof nextState?.watchedId === "string" ? nextState.watchedId : ""; + + db.transaction((tx) => { + tx.delete(workspaceTable).run(); + workspaces.forEach((ws, index) => { + const id = String(ws?.id ?? "").trim(); + if (!id) return; + const isLocal = ws.workspaceType !== "remote"; + tx.insert(workspaceTable) + .values({ + id, + path: ws.path ?? "", + name: ws.name ?? ws.path ?? "Workspace", + preset: ws.preset ?? null, + workspaceType: ws.workspaceType ?? "local", + remoteType: ws.remoteType ?? null, + baseUrl: !isLocal ? ws.baseUrl ?? null : null, + directory: !isLocal ? ws.directory ?? null : null, + displayName: ws.displayName ?? null, + openworkHostUrl: ws.openworkHostUrl ?? null, + openworkToken: ws.openworkToken ?? null, + openworkClientToken: ws.openworkClientToken ?? null, + openworkHostToken: ws.openworkHostToken ?? null, + openworkWorkspaceId: ws.openworkWorkspaceId ?? null, + openworkWorkspaceName: ws.openworkWorkspaceName ?? null, + sandboxBackend: ws.sandboxBackend ?? null, + sandboxRunId: ws.sandboxRunId ?? null, + sandboxContainerName: ws.sandboxContainerName ?? null, + sortOrder: index, + createdAt: now, + updatedAt: now, + }) + .run(); + }); + writePreference(tx, DESKTOP_SELECTED_WORKSPACE_PREF, selectedId, now); + writePreference(tx, DESKTOP_WATCHED_WORKSPACE_PREF, watchedId, now); + }); + + return readWorkspaceStateFromDb(db); +} + +// --- Per-workspace server tokens (workspace_server_token table) --- + +export async function loadWorkspaceTokensFromDb(db, workspaceKey) { + const rows = await db + .select() + .from(workspaceServerTokenTable) + .where(eq(workspaceServerTokenTable.workspaceKey, workspaceKey)); + const row = rows[0]; + if (!row) return null; + return { + clientToken: row.clientToken, + hostToken: row.hostToken, + ownerToken: row.ownerToken ?? null, + updatedAt: row.updatedAt, + }; +} + +export async function saveWorkspaceTokensToDb(db, workspaceKey, tokens) { + const now = Date.now(); + await db + .insert(workspaceServerTokenTable) + .values({ + workspaceKey, + clientToken: tokens.clientToken ?? null, + hostToken: tokens.hostToken ?? null, + ownerToken: tokens.ownerToken ?? null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: workspaceServerTokenTable.workspaceKey, + set: { + clientToken: tokens.clientToken ?? null, + hostToken: tokens.hostToken ?? null, + ownerToken: tokens.ownerToken ?? null, + updatedAt: now, + }, + }) + .run(); +} + +export async function setWorkspaceOwnerTokenInDb(db, workspaceKey, ownerToken) { + const existing = await loadWorkspaceTokensFromDb(db, workspaceKey); + if (!existing) return; + await db + .update(workspaceServerTokenTable) + .set({ ownerToken, updatedAt: Date.now() }) + .where(eq(workspaceServerTokenTable.workspaceKey, workspaceKey)) + .run(); +} + +// --- Preferred ports (workspace_port table + preference) --- + +export async function readPreferredPortFromDb(db, workspaceKey) { + if (workspaceKey) { + const rows = await db + .select() + .from(workspacePortTable) + .where(eq(workspacePortTable.workspaceKey, workspaceKey)); + if (rows[0]) return rows[0].port; + } + const pref = await readPreference(db, DESKTOP_PREFERRED_PORT_PREF); + return typeof pref === "number" ? pref : null; +} + +export async function persistPreferredPortInDb(db, workspaceKey, port) { + const now = Date.now(); + if (workspaceKey) { + await db + .insert(workspacePortTable) + .values({ workspaceKey, port, createdAt: now, updatedAt: now }) + .onConflictDoUpdate({ target: workspacePortTable.workspaceKey, set: { port, updatedAt: now } }) + .run(); + // Clear the global preferred-port preference (matches old port-state semantics). + writePreference(db, DESKTOP_PREFERRED_PORT_PREF, null, now); + } else { + writePreference(db, DESKTOP_PREFERRED_PORT_PREF, port, now); + } +} + +// --- Renderer preference mirror (preference table) --- + +/** All mirrored renderer preferences as `key -> rawString` (for boot hydration). */ +export async function getAllPreferences(db) { + return getAllMirroredPreferences(db); +} + +export async function getPreference(db, key) { + return getPreferenceFromDb(db, key); +} + +export async function setPreference(db, key, value) { + await setPreferenceInDb(db, key, String(value)); +} + +export async function removePreference(db, key) { + await removePreferenceFromDb(db, key); +} + +export { authorizedRootTable }; diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index baa4d821d0..213e968c74 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -22,7 +22,15 @@ import { fileURLToPath } from "node:url"; import { app, BrowserWindow, Menu, WebContentsView, clipboard, dialog, ipcMain, nativeImage, nativeTheme, session, shell, systemPreferences } from "electron"; import { configureFakeMediaForTests, installMediaPermissionHandlers } from "./media-permissions.mjs"; import { registerMigrationIpc } from "./migration.mjs"; -import { createRuntimeManager } from "./runtime.mjs"; +import { createRuntimeManager, resolveOpenworkServerConfigPath } from "./runtime.mjs"; +import { + getDesktopDb, + readWorkspaceStateFromDb, + writeWorkspaceStateToDb, + getAllPreferences, + setPreference as setDesktopPreference, + removePreference as removeDesktopPreference, +} from "./desktop-db.mjs"; import { registerUpdaterIpc } from "./updater.mjs"; import { exportWorkspaceConfig, importWorkspaceConfig } from "./workspace-archive.mjs"; import { @@ -1178,6 +1186,15 @@ function workspaceStatePath() { return path.join(app.getPath("userData"), "openwork-workspaces.json"); } +// Open the shared desktop DB (next to server.json). The one-time import of the legacy +// Electron JSON files runs on first call; the originals are preserved as .pre-db.bak. +function desktopDb() { + return getDesktopDb({ + serverConfigPath: resolveOpenworkServerConfigPath(process.env), + userDataDir: app.getPath("userData"), + }); +} + // Earlier Electron alpha builds copied Tauri's openwork-workspaces.json into an // Electron-only workspace-state.json. Keep importing that file when the shared // canonical file is missing, but write openwork-workspaces.json going forward so @@ -1586,7 +1603,8 @@ async function writeWorkspaceOpenworkConfig(workspacePath, config) { } async function readWorkspaceState() { - const state = await readJsonFile(workspaceStatePath(), EMPTY_WORKSPACE_LIST); + const db = await desktopDb(); + const state = await readWorkspaceStateFromDb(db); const selectedId = typeof state?.selectedId === "string" ? state.selectedId @@ -1672,21 +1690,17 @@ async function readWorkspaceState() { } async function writeWorkspaceState(nextState) { - const outputPath = workspaceStatePath(); const selectedId = String(nextState?.selectedId ?? nextState?.activeId ?? ""); const watchedId = typeof nextState?.watchedId === "string" ? nextState.watchedId : ""; - const output = { + const db = await desktopDb(); + // DB is the source of truth (shared with the server). The legacy + // openwork-workspaces.json is no longer written; its .pre-db.bak snapshot remains + // for revert. + const output = await writeWorkspaceStateToDb(db, { ...nextState, - // Tauri's Rust state uses selectedWorkspaceId/watchedWorkspaceId on disk - // (with activeId as a legacy alias). Keep Electron's selectedId/watchedId - // too so older Electron builds can still read the same file. selectedId, - selectedWorkspaceId: selectedId, - watchedId: watchedId || null, - watchedWorkspaceId: watchedId, - activeId: selectedId || null, - }; - await writeJsonFileAtomic(outputPath, output); + watchedId, + }); return output; } @@ -2144,6 +2158,20 @@ function applyNativeTheme(mode) { async function handleDesktopInvoke(event, command, ...args) { switch (command) { + case "preferenceGetAll": + return getAllPreferences(await desktopDb()); + case "preferenceSet": { + const key = typeof args[0] === "string" ? args[0] : ""; + if (!key) return false; + await setDesktopPreference(await desktopDb(), key, args[1] ?? ""); + return true; + } + case "preferenceRemove": { + const key = typeof args[0] === "string" ? args[0] : ""; + if (!key) return false; + await removeDesktopPreference(await desktopDb(), key); + return true; + } case "workspaceBootstrap": return readWorkspaceState(); case "workspaceSetSelected": diff --git a/apps/desktop/electron/preload.mjs b/apps/desktop/electron/preload.mjs index 73a951c3ef..68428bd639 100644 --- a/apps/desktop/electron/preload.mjs +++ b/apps/desktop/electron/preload.mjs @@ -135,6 +135,17 @@ contextBridge.exposeInMainWorld("__OPENWORK_ELECTRON__", { return () => ipcRenderer.removeListener("openwork:browser:panel-closed", handler); }, }, + preferences: { + getAll() { + return ipcRenderer.invoke("openwork:desktop", "preferenceGetAll"); + }, + set(key, value) { + return ipcRenderer.invoke("openwork:desktop", "preferenceSet", key, value); + }, + remove(key) { + return ipcRenderer.invoke("openwork:desktop", "preferenceRemove", key); + }, + }, meta: { initialDeepLinks: [], platform: normalizePlatform(process.platform), diff --git a/apps/desktop/electron/runtime.mjs b/apps/desktop/electron/runtime.mjs index 8919ffeb5b..0f05723d0c 100644 --- a/apps/desktop/electron/runtime.mjs +++ b/apps/desktop/electron/runtime.mjs @@ -7,6 +7,14 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { pathToFileURL } from "node:url"; +import { + getDesktopDb, + loadWorkspaceTokensFromDb, + saveWorkspaceTokensToDb, + setWorkspaceOwnerTokenInDb, + readPreferredPortFromDb, + persistPreferredPortInDb, +} from "./desktop-db.mjs"; const __runtimeDir = path.dirname(fileURLToPath(import.meta.url)); @@ -475,6 +483,15 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths return path.join(userDataDir, "openwork-server-state.json"); } + // Shared desktop DB (next to server.json). Per-workspace tokens and preferred ports + // live here now; the legacy JSON files are imported once and kept as .pre-db.bak. + function runtimeDb() { + return getDesktopDb({ + serverConfigPath: resolveOpenworkServerConfigPath(process.env), + userDataDir, + }); + } + function managedOpencodeWorkdir() { return path.join(userDataDir, "managed-opencode-workdir"); } @@ -526,78 +543,43 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths } } - async function loadTokenStore() { - return readJsonFile(openworkServerTokenStorePath(), { version: 1, workspaces: {} }); - } - - async function saveTokenStore(store) { - const filePath = openworkServerTokenStorePath(); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, `${JSON.stringify(store, null, 2)}\n`, "utf8"); - } - - async function loadPortState() { - return readJsonFile(openworkServerStatePath(), { - version: 3, - workspacePorts: {}, - preferredPort: null, - }); - } - - async function savePortState(state) { - const filePath = openworkServerStatePath(); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - } + // Per-workspace server tokens + preferred ports are now stored in the shared desktop + // DB. The legacy openwork-server-tokens.json / openwork-server-state.json files are + // imported once and preserved as .pre-db.bak snapshots. async function loadOrCreateWorkspaceTokens(workspaceKey) { - const store = await loadTokenStore(); + const db = await runtimeDb(); const normalized = normalizeWorkspaceKey(workspaceKey); - if (store.workspaces?.[normalized]) { - return store.workspaces[normalized]; + const existing = await loadWorkspaceTokensFromDb(db, normalized); + if (existing && existing.clientToken && existing.hostToken) { + return existing; } const next = { clientToken: randomUUID(), hostToken: randomUUID(), - ownerToken: null, + ownerToken: existing?.ownerToken ?? null, updatedAt: nowMs(), }; - store.workspaces ??= {}; - store.workspaces[normalized] = next; - await saveTokenStore(store); + await saveWorkspaceTokensToDb(db, normalized, next); return next; } async function persistWorkspaceOwnerToken(workspaceKey, ownerToken) { - const store = await loadTokenStore(); + const db = await runtimeDb(); const normalized = normalizeWorkspaceKey(workspaceKey); - if (!store.workspaces?.[normalized]) return; - store.workspaces[normalized].ownerToken = ownerToken; - store.workspaces[normalized].updatedAt = nowMs(); - await saveTokenStore(store); + await setWorkspaceOwnerTokenInDb(db, normalized, ownerToken); } async function readPreferredOpenworkPort(workspaceKey) { - const state = await loadPortState(); + const db = await runtimeDb(); const normalized = normalizeWorkspaceKey(workspaceKey); - if (normalized && state.workspacePorts?.[normalized]) { - return state.workspacePorts[normalized]; - } - return state.preferredPort ?? null; + return readPreferredPortFromDb(db, normalized); } async function persistPreferredOpenworkPort(workspaceKey, port) { - const state = await loadPortState(); + const db = await runtimeDb(); const normalized = normalizeWorkspaceKey(workspaceKey); - state.version = 3; - state.workspacePorts ??= {}; - if (normalized) { - state.workspacePorts[normalized] = port; - state.preferredPort = null; - } else { - state.preferredPort = port; - } - await savePortState(state); + await persistPreferredPortInDb(db, normalized, port); } async function resolveOpenworkPort(host, workspaceKey) { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ba72758487..ae749eb2f7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@opencode-ai/sdk": "^1.14.38", + "@openwork/desktop-db": "workspace:*", "better-sqlite3": "^11.10.0", "electron-updater": "^6.3.9", "jsonc-parser": "^3.2.1", diff --git a/apps/server/bunfig.toml b/apps/server/bunfig.toml new file mode 100644 index 0000000000..0a8c0cafde --- /dev/null +++ b/apps/server/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./src/test-preload.ts"] diff --git a/apps/server/package.json b/apps/server/package.json index 99a26e1bc9..932fbfec46 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,7 +8,7 @@ }, "scripts": { "dev": "OPENWORK_DEV_MODE=1 bun src/cli.ts", - "test": "bun test", + "test": "bun test src/", "test:artifacts": "bun test src/artifact-files.e2e.test.ts src/workspace-init.test.ts src/server.normalizeWorkspaceRelativePath.test.ts", "build": "tsc -p tsconfig.json", "build:bin": "bun build --compile src/cli.ts --outfile dist/bin/openwork-server", @@ -47,6 +47,7 @@ }, "dependencies": { "@opencode-ai/sdk": "^1.14.38", + "@openwork/desktop-db": "workspace:*", "better-sqlite3": "^11.10.0", "jsonc-parser": "^3.2.1", "minimatch": "^10.0.1", diff --git a/apps/server/src/audit.ts b/apps/server/src/audit.ts index 3358838534..0c59ad04ed 100644 --- a/apps/server/src/audit.ts +++ b/apps/server/src/audit.ts @@ -1,13 +1,11 @@ -import { dirname, join } from "node:path"; -import { appendFile, readFile } from "node:fs/promises"; +import { join } from "node:path"; import { homedir } from "node:os"; import type { AuditEntry } from "./types.js"; -import { ensureDir, exists } from "./utils.js"; +import { getDb } from "./db.js"; +import { auditTable, createDesktopTypeId, drizzle } from "@openwork/desktop-db"; function expandHome(value: string): string { - if (value.startsWith("~/")) { - return join(homedir(), value.slice(2)); - } + if (value.startsWith("~/")) return join(homedir(), value.slice(2)); return value; } @@ -17,68 +15,84 @@ function resolveOpenworkDataDir(): string { return join(homedir(), ".openwork", "openwork-server"); } +/** + * @deprecated Audit is now stored in the SQLite DB; this only points at the legacy + * JSONL location (preserved on disk for revert). Kept for path/back-compat references. + */ export function auditLogPath(workspaceId: string): string { return join(resolveOpenworkDataDir(), "audit", `${workspaceId}.jsonl`); } +/** @deprecated See {@link auditLogPath}. */ export function legacyAuditLogPath(workspaceRoot: string): string { return join(workspaceRoot, ".opencode", "openwork", "audit.jsonl"); } -async function resolveReadableAuditPath(workspaceRoot: string, workspaceId: string): Promise { - const primary = auditLogPath(workspaceId); - if (await exists(primary)) return primary; - const legacy = legacyAuditLogPath(workspaceRoot); - if (await exists(legacy)) return legacy; - return null; +/** + * DB-backed audit log (replaces `~/.openwork/openwork-server/audit/.jsonl`). + * + * The original JSONL files are preserved on disk (snapshotted to `audit-pre-db-bak/`) + * and imported once into the `audit` table. New entries are written to the DB only. + * + * `recordAudit` keeps its `(workspaceRoot, entry)` signature for call-site + * compatibility; `workspaceRoot` is no longer used (the empty-workspaceId "legacy" + * case is preserved as `workspaceId = ""`). + */ +export async function recordAudit(_workspaceRoot: string, entry: AuditEntry): Promise { + const db = await getDb(); + await db + .insert(auditTable) + .values({ + id: createDesktopTypeId("audit"), + sourceId: entry.id ?? null, + workspaceId: entry.workspaceId?.trim() ?? "", + actor: entry.actor, + action: entry.action, + target: entry.target, + summary: entry.summary, + timestamp: entry.timestamp, + }) + .run(); } -export async function recordAudit(workspaceRoot: string, entry: AuditEntry): Promise { - const workspaceId = entry.workspaceId?.trim(); - if (!workspaceId) { - const path = legacyAuditLogPath(workspaceRoot); - await ensureDir(dirname(path)); - await appendFile(path, JSON.stringify(entry) + "\n", "utf8"); - return; - } - - const path = auditLogPath(workspaceId); - await ensureDir(dirname(path)); - await appendFile(path, JSON.stringify(entry) + "\n", "utf8"); +function rowToEntry(row: typeof auditTable.$inferSelect): AuditEntry { + return { + id: row.sourceId ?? row.id, + workspaceId: row.workspaceId, + actor: row.actor, + action: row.action, + target: row.target, + summary: row.summary, + timestamp: row.timestamp, + }; } -export async function readLastAudit(workspaceRoot: string, workspaceId: string): Promise { - const path = await resolveReadableAuditPath(workspaceRoot, workspaceId); - if (!path) return null; - const content = await readFile(path, "utf8"); - const lines = content.trim().split("\n"); - const last = lines[lines.length - 1]; - if (!last) return null; - try { - return JSON.parse(last) as AuditEntry; - } catch { - return null; - } +export async function readLastAudit( + _workspaceRoot: string, + workspaceId: string, +): Promise { + const db = await getDb(); + const rows = await db + .select() + .from(auditTable) + .where(drizzle.eq(auditTable.workspaceId, workspaceId)) + .orderBy(drizzle.desc(auditTable.timestamp)) + .limit(1); + const row = rows[0]; + return row ? rowToEntry(row) : null; } export async function readAuditEntries( - workspaceRoot: string, + _workspaceRoot: string, workspaceId: string, limit = 50, ): Promise { - const path = await resolveReadableAuditPath(workspaceRoot, workspaceId); - if (!path) return []; - const content = await readFile(path, "utf8"); - const rawLines = content.trim().split("\n").filter(Boolean); - if (!rawLines.length) return []; - const slice = rawLines.slice(-Math.max(1, limit)); - const entries: AuditEntry[] = []; - for (let i = slice.length - 1; i >= 0; i -= 1) { - try { - entries.push(JSON.parse(slice[i]) as AuditEntry); - } catch { - // ignore malformed entry - } - } - return entries; + const db = await getDb(); + const rows = await db + .select() + .from(auditTable) + .where(drizzle.eq(auditTable.workspaceId, workspaceId)) + .orderBy(drizzle.desc(auditTable.timestamp)) + .limit(Math.max(1, limit)); + return rows.map(rowToEntry); } diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 4d2b6695cf..1a9acfc1dd 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -3,6 +3,7 @@ import { mkdir } from "node:fs/promises"; import { parseCliArgs, printHelp, resolveServerConfig } from "./config.js"; +import { reconcileConfigWithDb } from "./db.js"; import { createManagedOpencodeServer, type ManagedOpencodeServer } from "./managed-opencode.js"; import { createServerLogger, startServer } from "./server.js"; import { ensureWorkspaceFiles } from "./workspace-init.js"; @@ -22,6 +23,7 @@ if (args.version) { } const config = await resolveServerConfig(args); +await reconcileConfigWithDb(config); const logger = createServerLogger(config); const serverUrl = `http://${config.host === "0.0.0.0" ? "127.0.0.1" : config.host}:${config.port}`; let managedOpencode: ManagedOpencodeServer | null = null; diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts new file mode 100644 index 0000000000..38712b2348 --- /dev/null +++ b/apps/server/src/db.ts @@ -0,0 +1,141 @@ +/** + * Shared accessor for the OpenWork desktop SQLite DB. + * + * The DB is the runtime source of truth for OpenWork-owned state (server config, + * workspace registry, tokens, audit). The original JSON/JSONL files are preserved + * (and snapshotted to `.pre-db.bak`) for revert; they are no longer written at runtime. + */ +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { + openDb, + closeDb, + resolveDefaultDbPath, + runPhase1ImportOnce, + workspaceTable, + authorizedRootTable, + drizzle, + type DesktopDb, + type ImportOnceReport, +} from "@openwork/desktop-db"; +import type { ServerConfig, WorkspaceConfig } from "./types.js"; +import { buildWorkspaceInfos } from "./workspaces.js"; + +let dbPromise: Promise | null = null; +let dbPath: string | null = null; +let importedFor: string | null = null; + +/** + * Resolve the DB path from the server config so it sits next to `server.json` + * (or honors `OPENWORK_DB`). Falls back to the package default. + */ +function resolveDbPath(config?: ServerConfig): string { + // A per-workspace config path wins so each server instance gets its own DB next to + // its server.json. `OPENWORK_DB` / platform defaults are the no-config fallback. + const configPath = config?.configPath?.trim(); + if (configPath) return join(dirname(configPath), "openwork.db"); + return resolveDefaultDbPath(); +} + +/** Open (and migrate) the desktop DB, cached per-process and keyed by resolved path. */ +export async function getDb(config?: ServerConfig): Promise { + const path = resolveDbPath(config); + if (dbPromise && dbPath === path) return dbPromise; + if (dbPromise && dbPath !== path) { + // Path changed (e.g. between tests). Reset the package singleton. + closeDb(); + importedFor = null; + } + dbPath = path; + dbPromise = openDb({ path }); + return dbPromise; +} + +/** Reset the cached DB connection (tests only). */ +export function resetDbForTests(): void { + closeDb(); + dbPromise = null; + dbPath = null; + importedFor = null; +} + +/** + * Run the one-time file -> DB import (guarded by `migration_state`). Resolves the + * source paths from the server config so a custom `--config` / data dir is honored. + * Safe and cheap to call on every startup. + */ +export async function ensureImported(config: ServerConfig): Promise { + const db = await getDb(config); + const key = config.configPath ?? ""; + if (importedFor === key) return null; + importedFor = key; + + const serverJsonPath = config.configPath?.trim() || join(homedir(), ".config", "openwork", "server.json"); + return runPhase1ImportOnce(db, { + serverJsonPath, + // tokens.json + audit dir resolve from env/defaults inside the importer. + }); +} + +/** + * Load the workspace registry from the DB as `WorkspaceConfig[]` (ordered by + * `sortOrder`, so index 0 = active). Returns null if the DB has no workspaces yet + * (caller should fall back to the file-derived config). + */ +export async function loadWorkspaceRegistryFromDb( + config: ServerConfig, +): Promise<{ workspaces: WorkspaceConfig[]; authorizedRoots: string[] } | null> { + const db = await getDb(config); + const rows = await db + .select() + .from(workspaceTable) + .orderBy(drizzle.asc(workspaceTable.sortOrder)); + if (rows.length === 0) return null; + + const workspaces: WorkspaceConfig[] = rows.map((row) => ({ + id: row.id, + path: row.path, + name: row.name, + preset: row.preset ?? undefined, + workspaceType: (row.workspaceType as WorkspaceConfig["workspaceType"]) ?? "local", + remoteType: (row.remoteType as WorkspaceConfig["remoteType"]) ?? undefined, + baseUrl: row.baseUrl ?? undefined, + directory: row.directory ?? undefined, + displayName: row.displayName ?? undefined, + openworkHostUrl: row.openworkHostUrl ?? undefined, + openworkToken: row.openworkToken ?? undefined, + openworkWorkspaceId: row.openworkWorkspaceId ?? undefined, + openworkWorkspaceName: row.openworkWorkspaceName ?? undefined, + sandboxBackend: row.sandboxBackend ?? undefined, + sandboxRunId: row.sandboxRunId ?? undefined, + sandboxContainerName: row.sandboxContainerName ?? undefined, + opencodeUsername: row.opencodeUsername ?? undefined, + opencodePassword: row.opencodePassword ?? undefined, + })); + + const rootRows = await db.select().from(authorizedRootTable); + const authorizedRoots = rootRows.map((row) => row.path); + + return { workspaces, authorizedRoots }; +} + +/** + * Reconcile a freshly-resolved `ServerConfig` with the DB: + * 1. Run the one-time file -> DB import (preserves source files; gated by fingerprint). + * 2. If the DB holds a workspace registry, make it authoritative — rebuild + * `config.workspaces` and `config.authorizedRoots` from the DB. + * + * Call this right after `resolveServerConfig`, BEFORE managed-OpenCode wiring (so its + * mutations apply to the DB-derived workspaces). Mutates `config` in place. + */ +export async function reconcileConfigWithDb(config: ServerConfig): Promise { + await ensureImported(config); + const registry = await loadWorkspaceRegistryFromDb(config); + if (!registry) return; + + const configDir = config.configPath ? dirname(config.configPath) : process.cwd(); + config.workspaces = buildWorkspaceInfos(registry.workspaces, configDir); + if (registry.authorizedRoots.length > 0) { + config.authorizedRoots = registry.authorizedRoots; + } +} diff --git a/apps/server/src/embedded.ts b/apps/server/src/embedded.ts index a99f2f45da..ef3656ceef 100644 --- a/apps/server/src/embedded.ts +++ b/apps/server/src/embedded.ts @@ -7,6 +7,7 @@ */ import { mkdir } from "node:fs/promises"; import { resolveServerConfig, type CliArgs } from "./config.js"; +import { reconcileConfigWithDb } from "./db.js"; import { createManagedOpencodeServer, type ManagedOpencodeServer } from "./managed-opencode.js"; import { startServer } from "./server.js"; import { ensureWorkspaceFiles } from "./workspace-init.js"; @@ -36,6 +37,9 @@ export type EmbeddedServerHandle = { export async function startEmbeddedServer(options: EmbeddedServerOptions): Promise { const config = await resolveServerConfig(options); + // Import OpenWork-owned state into the DB once, then make the DB the source of truth + // for the workspace registry. Source files are preserved (snapshotted to .pre-db.bak). + await reconcileConfigWithDb(config); const serverUrl = `http://${config.host === "0.0.0.0" ? "127.0.0.1" : config.host}:${config.port}`; const opencodeModelsUrl = process.env.OPENWORK_DEV_MODE === "1" ? "http://localhost:8791/models" diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 758ad5036b..14d1dc2812 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -23,6 +23,8 @@ import { workspaceIdForPath, workspaceIdForRemote } from "./workspaces.js"; import { ensureWorkspaceFiles, readRawOpencodeConfig } from "./workspace-init.js"; import { sanitizeCommandName, validateMcpName } from "./validators.js"; import { TokenService } from "./tokens.js"; +import { getDb, ensureImported } from "./db.js"; +import { workspaceTable, authorizedRootTable, createDesktopTypeId } from "@openwork/desktop-db"; import { EnvService, EnvStoreReadError, InvalidEnvKeyError, isValidEnvKey } from "./env-file.js"; import { TOY_UI_CSS, TOY_UI_FAVICON_SVG, TOY_UI_HTML, TOY_UI_JS, cssResponse, htmlResponse, jsResponse, svgResponse } from "./toy-ui.js"; import { FileSessionStore } from "./file-sessions.js"; @@ -4226,75 +4228,65 @@ function ensurePlainObject(value: unknown): Record { return value as Record; } -type OpenworkServerConfigFile = Record & { - workspaces?: Array>; - authorizedRoots?: string[]; -}; - -async function readServerConfigFile(configPath: string): Promise { - if (!(await exists(configPath))) { - return {}; - } - - try { - const raw = await readFile(configPath, "utf8"); - return ensurePlainObject(JSON.parse(raw)) as OpenworkServerConfigFile; - } catch (error) { - throw new ApiError(422, "invalid_json", "Failed to parse server config", { - path: configPath, - error: String(error), - }); - } -} - -function serializeWorkspaceConfigEntry(workspace: WorkspaceInfo): Record { - const isLocalWorkspace = workspace.workspaceType !== "remote"; - return { - id: workspace.id, - path: workspace.path, - name: workspace.name, - preset: workspace.preset, - workspaceType: workspace.workspaceType, - ...(workspace.remoteType ? { remoteType: workspace.remoteType } : {}), - ...(!isLocalWorkspace && workspace.baseUrl ? { baseUrl: workspace.baseUrl } : {}), - ...(!isLocalWorkspace && workspace.directory ? { directory: workspace.directory } : {}), - ...(workspace.displayName ? { displayName: workspace.displayName } : {}), - ...(workspace.openworkHostUrl ? { openworkHostUrl: workspace.openworkHostUrl } : {}), - ...(workspace.openworkToken ? { openworkToken: workspace.openworkToken } : {}), - ...(workspace.openworkWorkspaceId ? { openworkWorkspaceId: workspace.openworkWorkspaceId } : {}), - ...(workspace.openworkWorkspaceName ? { openworkWorkspaceName: workspace.openworkWorkspaceName } : {}), - ...(workspace.sandboxBackend ? { sandboxBackend: workspace.sandboxBackend } : {}), - ...(workspace.sandboxRunId ? { sandboxRunId: workspace.sandboxRunId } : {}), - ...(workspace.sandboxContainerName ? { sandboxContainerName: workspace.sandboxContainerName } : {}), - ...(!isLocalWorkspace && workspace.opencodeUsername ? { opencodeUsername: workspace.opencodeUsername } : {}), - ...(!isLocalWorkspace && workspace.opencodePassword ? { opencodePassword: workspace.opencodePassword } : {}), - }; -} - +/** + * Persist the workspace registry + authorizedRoots to the SQLite DB. + * + * The original `server.json` is preserved on disk (snapshotted to `.pre-db.bak` on + * first import) and is NO LONGER written at runtime — the DB is the source of truth. + */ async function persistServerWorkspaceState(config: ServerConfig): Promise { - const configPath = config.configPath?.trim() ?? ""; - if (!configPath) return false; - - const parsed = await readServerConfigFile(configPath); - const next: OpenworkServerConfigFile = { - ...parsed, - workspaces: config.workspaces.map(serializeWorkspaceConfigEntry), - authorizedRoots: Array.from(new Set(config.authorizedRoots.map((root) => resolve(root)))), - }; + const db = await getDb(config); + + db.transaction((tx) => { + // Replace the full workspace set, preserving array order via sortOrder. + tx.delete(authorizedRootTable).run(); + tx.delete(workspaceTable).run(); + + const now = Date.now(); + config.workspaces.forEach((workspace, index) => { + const isLocal = workspace.workspaceType !== "remote"; + tx.insert(workspaceTable) + .values({ + id: workspace.id, + path: workspace.path, + name: workspace.name, + preset: workspace.preset ?? null, + workspaceType: workspace.workspaceType ?? "local", + remoteType: workspace.remoteType ?? null, + baseUrl: !isLocal ? workspace.baseUrl ?? null : null, + directory: !isLocal ? workspace.directory ?? null : null, + displayName: workspace.displayName ?? null, + openworkHostUrl: workspace.openworkHostUrl ?? null, + openworkToken: workspace.openworkToken ?? null, + openworkWorkspaceId: workspace.openworkWorkspaceId ?? null, + openworkWorkspaceName: workspace.openworkWorkspaceName ?? null, + sandboxBackend: workspace.sandboxBackend ?? null, + sandboxRunId: workspace.sandboxRunId ?? null, + sandboxContainerName: workspace.sandboxContainerName ?? null, + opencodeUsername: !isLocal ? workspace.opencodeUsername ?? null : null, + opencodePassword: !isLocal ? workspace.opencodePassword ?? null : null, + sortOrder: index, + createdAt: now, + updatedAt: now, + }) + .run(); + }); - await ensureDir(dirname(configPath)); - const tmpPath = `${configPath}.tmp.${shortId()}`; - try { - await writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); - await rename(tmpPath, configPath); - return true; - } finally { - try { - await rm(tmpPath); - } catch { - // ignore + const roots = Array.from(new Set(config.authorizedRoots.map((root) => resolve(root)))); + for (const root of roots) { + tx.insert(authorizedRootTable) + .values({ + id: createDesktopTypeId("authorizedRoot"), + workspaceId: null, + path: root, + createdAt: now, + updatedAt: now, + }) + .run(); } - } + }); + + return true; } function normalizeOpencodeScope(value: string | null | undefined): "project" | "global" { diff --git a/apps/server/src/test-preload.ts b/apps/server/src/test-preload.ts new file mode 100644 index 0000000000..19c315fd14 --- /dev/null +++ b/apps/server/src/test-preload.ts @@ -0,0 +1,25 @@ +/** + * Bun test preload: isolate all on-disk state to a per-process temp dir so tests never + * touch the real `~/.config/openwork` or `~/.openwork`. + * + * Registered via bunfig.toml `[test] preload`. Tests that pass an explicit + * `config.configPath` still get their own DB at `/openwork.db`; tests without + * one fall back to this isolated temp DB instead of the user's real config dir. + */ +import { afterEach } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const dir = mkdtempSync(join(tmpdir(), "openwork-test-state-")); + +process.env.OPENWORK_DB ??= join(dir, "openwork.db"); +process.env.OPENWORK_DATA_DIR ??= join(dir, "openwork-data"); +process.env.OPENWORK_TOKEN_STORE ??= join(dir, "tokens.json"); +process.env.OPENWORK_ENV_STORE ??= join(dir, "env.json"); + +const { resetDbForTests } = await import("./db.js"); + +afterEach(() => { + resetDbForTests(); +}); diff --git a/apps/server/src/tokens.ts b/apps/server/src/tokens.ts index 1c8c82b924..e3e74c16ed 100644 --- a/apps/server/src/tokens.ts +++ b/apps/server/src/tokens.ts @@ -1,9 +1,7 @@ -import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { readFile, writeFile } from "node:fs/promises"; - import type { ServerConfig, TokenScope } from "./types.js"; -import { ensureDir, exists, hashToken, shortId } from "./utils.js"; +import { hashToken, shortId } from "./utils.js"; +import { getDb } from "./db.js"; +import { tokenTable, createDesktopTypeId, isDesktopTypeId, normalizeDesktopTypeId, drizzle } from "@openwork/desktop-db"; export type TokenRecord = { id: string; @@ -13,128 +11,69 @@ export type TokenRecord = { label?: string; }; -type TokenStoreFile = { - schemaVersion: number; - updatedAt: number; - tokens: TokenRecord[]; -}; - function normalizeScope(value: unknown): TokenScope | null { if (value === "owner" || value === "collaborator" || value === "viewer") return value; return null; } -function resolveTokenStorePath(config: ServerConfig): string { - const override = (process.env.OPENWORK_TOKEN_STORE ?? "").trim(); - if (override) return resolve(override); - - const configPath = config.configPath?.trim(); - const configDir = configPath ? dirname(configPath) : join(homedir(), ".config", "openwork"); - return join(configDir, "tokens.json"); -} - -async function readTokenStore(path: string): Promise { - if (!(await exists(path))) { - return { schemaVersion: 1, updatedAt: Date.now(), tokens: [] }; - } - try { - const raw = await readFile(path, "utf8"); - const parsed = JSON.parse(raw) as Partial; - const tokens = Array.isArray(parsed.tokens) - ? parsed.tokens - .map((token) => { - const record = token as Partial; - const id = typeof record.id === "string" ? record.id : ""; - const hash = typeof record.hash === "string" ? record.hash : ""; - const scope = normalizeScope(record.scope); - const createdAt = typeof record.createdAt === "number" ? record.createdAt : Date.now(); - const label = typeof record.label === "string" ? record.label : undefined; - if (!id || !hash || !scope) return null; - const parsedRecord: TokenRecord = { - id, - hash, - scope, - createdAt, - ...(label ? { label } : {}), - }; - return parsedRecord; - }) - .filter((token): token is TokenRecord => Boolean(token)) - : []; - return { - schemaVersion: typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1, - updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(), - tokens, - }; - } catch { - return { schemaVersion: 1, updatedAt: Date.now(), tokens: [] }; - } -} - -async function writeTokenStore(path: string, tokens: TokenRecord[]): Promise { - await ensureDir(dirname(path)); - const payload: TokenStoreFile = { - schemaVersion: 1, - updatedAt: Date.now(), - tokens, - }; - await writeFile(path, JSON.stringify(payload, null, 2) + "\n", "utf8"); -} - +/** + * DB-backed scoped API tokens (replaces `tokens.json`). + * + * Same public interface as before; the original `tokens.json` is preserved on disk + * (snapshotted to `.pre-db.bak`) and imported once into the `token` table. + * + * Only the SHA-256 hash is stored; the built-in `config.token` still resolves to + * "collaborator" without a DB lookup. + */ export class TokenService { private config: ServerConfig; - private path: string; - private loaded = false; - private tokens: TokenRecord[] = []; - private byHash = new Map(); constructor(config: ServerConfig) { this.config = config; - this.path = resolveTokenStorePath(config); - } - - private async ensureLoaded(): Promise { - if (this.loaded) return; - const store = await readTokenStore(this.path); - this.tokens = store.tokens; - this.byHash = new Map(store.tokens.map((token) => [token.hash, token])); - this.loaded = true; } async list(): Promise>> { - await this.ensureLoaded(); - return this.tokens.map(({ hash: _hash, ...rest }) => rest); + const db = await getDb(this.config); + const rows = await db + .select() + .from(tokenTable) + .orderBy(drizzle.desc(tokenTable.createdAt)); + return rows.map((row) => ({ + id: row.id, + scope: (normalizeScope(row.scope) ?? "viewer") as TokenScope, + createdAt: row.createdAt, + ...(row.label ? { label: row.label } : {}), + })); } - async create(scope: TokenScope, options?: { label?: string }): Promise<{ id: string; token: string; scope: TokenScope; createdAt: number; label?: string }> { - await this.ensureLoaded(); - - const id = shortId(); + async create( + scope: TokenScope, + options?: { label?: string }, + ): Promise<{ id: string; token: string; scope: TokenScope; createdAt: number; label?: string }> { + const db = await getDb(this.config); + const id = createDesktopTypeId("token"); const token = `owt_${shortId().replace(/-/g, "")}`; const createdAt = Date.now(); - const record: TokenRecord = { - id, - hash: hashToken(token), - scope, - createdAt, - label: options?.label?.trim() || undefined, - }; + const label = options?.label?.trim() || undefined; + + await db + .insert(tokenTable) + .values({ id, hash: hashToken(token), scope, label: label ?? null, createdAt }) + .run(); - this.tokens = [record, ...this.tokens]; - this.byHash.set(record.hash, record); - await writeTokenStore(this.path, this.tokens); - return { id, token, scope, createdAt, label: record.label }; + return { id, token, scope, createdAt, label }; } async revoke(id: string): Promise { - await this.ensureLoaded(); - const index = this.tokens.findIndex((token) => token.id === id); - if (index === -1) return false; - const [removed] = this.tokens.splice(index, 1); - if (removed) { - this.byHash.delete(removed.hash); - } - await writeTokenStore(this.path, this.tokens); + if (!isDesktopTypeId("token", id)) return false; + const db = await getDb(this.config); + const tokenId = normalizeDesktopTypeId("token", id); + const existing = await db + .select({ id: tokenTable.id }) + .from(tokenTable) + .where(drizzle.eq(tokenTable.id, tokenId)); + if (existing.length === 0) return false; + await db.delete(tokenTable).where(drizzle.eq(tokenTable.id, tokenId)).run(); return true; } @@ -142,8 +81,12 @@ export class TokenService { const trimmed = token.trim(); if (!trimmed) return null; if (trimmed === this.config.token) return "collaborator"; - await this.ensureLoaded(); - const found = this.byHash.get(hashToken(trimmed)); - return found?.scope ?? null; + const db = await getDb(this.config); + const rows = await db + .select() + .from(tokenTable) + .where(drizzle.eq(tokenTable.hash, hashToken(trimmed))); + const found = rows[0]; + return found ? (normalizeScope(found.scope) ?? null) : null; } } diff --git a/apps/server/src/workspace-activate.e2e.test.ts b/apps/server/src/workspace-activate.e2e.test.ts index f944e7164e..487f96808f 100644 --- a/apps/server/src/workspace-activate.e2e.test.ts +++ b/apps/server/src/workspace-activate.e2e.test.ts @@ -1,11 +1,50 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { startServer } from "./server.js"; +import { getDb, resetDbForTests } from "./db.js"; +import { workspaceTable, authorizedRootTable, drizzle } from "@openwork/desktop-db"; import type { ServerConfig } from "./types.js"; +/** + * Read the persisted workspace registry from the DB (the source of truth after the + * server-state-to-db migration). `configPath` determines the DB location + * (`/openwork.db`), mirroring `resolveDbPath` in db.ts. + */ +async function readPersistedRegistry(configPath: string): Promise<{ + workspaces: Array>; + authorizedRoots: string[]; +}> { + const db = await getDb({ configPath } as ServerConfig); + const rows = await db + .select() + .from(workspaceTable) + .orderBy(drizzle.asc(workspaceTable.sortOrder)); + const workspaces = rows.map((row) => { + const isLocal = row.workspaceType !== "remote"; + return { + id: row.id, + path: row.path, + name: row.name, + preset: row.preset ?? undefined, + workspaceType: row.workspaceType, + remoteType: row.remoteType ?? undefined, + baseUrl: !isLocal ? row.baseUrl ?? undefined : undefined, + directory: !isLocal ? row.directory ?? undefined : undefined, + displayName: row.displayName ?? undefined, + openworkWorkspaceId: row.openworkWorkspaceId ?? undefined, + openworkWorkspaceName: row.openworkWorkspaceName ?? undefined, + sandboxRunId: row.sandboxRunId ?? undefined, + opencodeUsername: !isLocal ? row.opencodeUsername ?? undefined : undefined, + opencodePassword: !isLocal ? row.opencodePassword ?? undefined : undefined, + } as Record; + }); + const rootRows = await db.select().from(authorizedRootTable); + return { workspaces, authorizedRoots: rootRows.map((r) => r.path) }; +} + type Served = { port: number; stop: (closeActiveConnections?: boolean) => void | Promise; @@ -18,6 +57,7 @@ afterEach(async () => { while (stops.length) { await stops.pop()?.(); } + resetDbForTests(); while (roots.length) { await rm(roots.pop()!, { recursive: true, force: true }); } @@ -34,37 +74,28 @@ function hostAuth(token: string) { return { "X-OpenWork-Host-Token": token }; } -function workspaceIdsFromConfig(value: unknown): string[] { - if (!value || typeof value !== "object" || Array.isArray(value)) return []; - if (!("workspaces" in value) || !Array.isArray(value.workspaces)) return []; +type PersistedRegistry = { workspaces: Array>; authorizedRoots: string[] }; + +function workspaceIdsFromConfig(value: PersistedRegistry): string[] { return value.workspaces.flatMap((workspace) => - workspace && typeof workspace === "object" && !Array.isArray(workspace) && "id" in workspace && typeof workspace.id === "string" - ? [workspace.id] - : [], + typeof workspace.id === "string" ? [workspace.id] : [], ); } -function workspacesFromConfig(value: unknown): Array> { - if (!value || typeof value !== "object" || Array.isArray(value)) return []; - if (!("workspaces" in value) || !Array.isArray(value.workspaces)) return []; - return value.workspaces.filter( - (workspace): workspace is Record => - Boolean(workspace) && typeof workspace === "object" && !Array.isArray(workspace), - ); +function workspacesFromConfig(value: PersistedRegistry): Array> { + return value.workspaces; } -function authorizedRootsFromConfig(value: unknown): string[] { - if (!value || typeof value !== "object" || Array.isArray(value)) return []; - if (!("authorizedRoots" in value) || !Array.isArray(value.authorizedRoots)) return []; - return value.authorizedRoots.filter((root): root is string => typeof root === "string"); +function authorizedRootsFromConfig(value: PersistedRegistry): string[] { + return value.authorizedRoots; } async function readPersistedWorkspaceIds(configPath: string) { - return workspaceIdsFromConfig(JSON.parse(await readFile(configPath, "utf8"))); + return workspaceIdsFromConfig(await readPersistedRegistry(configPath)); } -async function readPersistedConfig(configPath: string): Promise { - return JSON.parse(await readFile(configPath, "utf8")); +async function readPersistedConfig(configPath: string): Promise { + return readPersistedRegistry(configPath); } function startMockOpencode() { diff --git a/apps/server/src/workspace-import-preview.test.ts b/apps/server/src/workspace-import-preview.test.ts index 54c1afce43..44249e8ff1 100644 --- a/apps/server/src/workspace-import-preview.test.ts +++ b/apps/server/src/workspace-import-preview.test.ts @@ -3,10 +3,24 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os"; import { join } from "node:path"; -import { auditLogPath } from "./audit.js"; import { buildCommandContent } from "./commands.js"; import { startServer } from "./server.js"; import { buildSkillContent } from "./skills.js"; +import { getDb } from "./db.js"; +import { auditTable, drizzle } from "@openwork/desktop-db"; + +/** + * Read audit summaries for a workspace from the DB (audit moved from JSONL to SQLite). + * `dataDir` is the test's config dir, so the DB is `/openwork.db`. + */ +async function auditSummaries(dataDir: string, workspaceId: string): Promise { + const db = await getDb({ configPath: join(dataDir, "config.json") } as ServerConfig); + const rows = await db + .select() + .from(auditTable) + .where(drizzle.eq(auditTable.workspaceId, workspaceId)); + return rows.map((row) => row.summary); +} import type { ServerConfig } from "./types.js"; import { buildWorkspaceImportPreview, @@ -342,7 +356,7 @@ describe("workspace import preview", () => { expect(importResponse.status).toBe(200); const imported = await importResponse.json() as Record; expect(imported.preview).toEqual(preview); - expect(await pathExists(auditLogPath("workspace"))).toBe(false); + expect((await auditSummaries(dataDir, "workspace")).length).toBe(0); } finally { server.stop(true); if (originalDataDir === undefined) { @@ -381,7 +395,7 @@ describe("workspace import preview", () => { expect(response.status).toBe(400); const body = await response.json() as { code: string }; expect(body.code).toBe("invalid_workspace_import_preview_fingerprint"); - expect(await pathExists(auditLogPath("workspace"))).toBe(false); + expect((await auditSummaries(dataDir, "workspace")).length).toBe(0); } finally { server.stop(true); if (originalDataDir === undefined) { @@ -424,7 +438,7 @@ describe("workspace import preview", () => { expect(typeof body.preview.fingerprint).toBe("string"); expect(body.preview.summary.create).toBe(1); expect(await pathExists(join(workspace, "opencode.jsonc"))).toBe(false); - expect(await pathExists(auditLogPath("workspace"))).toBe(false); + expect((await auditSummaries(dataDir, "workspace")).length).toBe(0); } finally { server.stop(true); if (originalDataDir === undefined) { @@ -470,7 +484,7 @@ describe("workspace import preview", () => { expect(await readFile(join(workspace, "opencode.jsonc"), "utf8")).toContain('"plugin"'); expect(await readFile(join(workspace, ".opencode", "skills", "demo", "SKILL.md"), "utf8")).toContain("Demo skill"); expect(await readFile(join(workspace, ".opencode", "agents", "demo.md"), "utf8")).toBe("Demo agent\n"); - expect(await readFile(auditLogPath("workspace"), "utf8")).toContain("Imported workspace config"); + expect((await auditSummaries(dataDir, "workspace")).join("\n")).toContain("Imported workspace config"); } finally { server.stop(true); if (originalDataDir === undefined) { @@ -541,7 +555,7 @@ describe("workspace import preview", () => { expect(await readFile(join(workspace, ".opencode", "skills", "keep", "SKILL.md"), "utf8")).toBe(keepSkillContent); expect(await readFile(join(workspace, ".opencode", "commands", "keep-command.md"), "utf8")).toBe(keepCommandContent); expect(await readFile(join(workspace, ".opencode", "tools", "shared.ts"), "utf8")).toBe("shared tool\n"); - expect(await readFile(auditLogPath("workspace"), "utf8")).toContain("Imported workspace config (remove 3)"); + expect((await auditSummaries(dataDir, "workspace")).join("\n")).toContain("Imported workspace config (remove 3)"); } finally { server.stop(true); if (originalDataDir === undefined) { @@ -644,7 +658,7 @@ describe("workspace import preview", () => { expect(rejected.code).toBe("workspace_import_preview_stale"); expect(rejected.preview.fingerprint).not.toBe(preview.fingerprint); expect(await readFile(join(workspace, "opencode.jsonc"), "utf8")).toContain("changed-after-preview"); - expect(await pathExists(auditLogPath("workspace"))).toBe(false); + expect((await auditSummaries(dataDir, "workspace")).length).toBe(0); } finally { server.stop(true); if (originalDataDir === undefined) { @@ -705,7 +719,7 @@ describe("workspace import preview", () => { expect(rejected.code).toBe("workspace_import_preview_stale"); expect(rejected.preview.fingerprint).not.toBe(preview.fingerprint); expect(await readFile(join(workspace, "opencode.jsonc"), "utf8")).toContain("changed-during-approval"); - expect(await pathExists(auditLogPath("workspace"))).toBe(false); + expect((await auditSummaries(dataDir, "workspace")).length).toBe(0); } finally { server.stop(true); if (originalDataDir === undefined) { diff --git a/packages/desktop-db/drizzle.config.ts b/packages/desktop-db/drizzle.config.ts new file mode 100644 index 0000000000..b193c820f6 --- /dev/null +++ b/packages/desktop-db/drizzle.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + schema: [ + "./src/schema/workspaces.ts", + "./src/schema/tokens.ts", + "./src/schema/server-config.ts", + "./src/schema/env.ts", + "./src/schema/audit.ts", + "./src/schema/sessions.ts", + "./src/schema/opencode-config.ts", + "./src/schema/extensions.ts", + "./src/schema/migration-state.ts", + ], + out: "./drizzle", +}); diff --git a/packages/desktop-db/drizzle/0000_giant_shatterstar.sql b/packages/desktop-db/drizzle/0000_giant_shatterstar.sql new file mode 100644 index 0000000000..ce73a80fd0 --- /dev/null +++ b/packages/desktop-db/drizzle/0000_giant_shatterstar.sql @@ -0,0 +1,219 @@ +CREATE TABLE `authorized_root` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text, + `path` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `authorized_root_path_unique` ON `authorized_root` (`path`);--> statement-breakpoint +CREATE TABLE `blueprint_session` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text NOT NULL, + `template_id` text NOT NULL, + `session_id` text NOT NULL, + `hydrated_at` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `blueprint_session_ws_template_unique` ON `blueprint_session` (`workspace_id`,`template_id`);--> statement-breakpoint +CREATE TABLE `desktop_cloud_sync` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text NOT NULL, + `context_key` text NOT NULL, + `organization_id` text NOT NULL, + `org_member_id` text NOT NULL, + `data` text NOT NULL, + `fetched_at` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `desktop_cloud_sync_ws_context_unique` ON `desktop_cloud_sync` (`workspace_id`,`context_key`);--> statement-breakpoint +CREATE TABLE `workspace_meta` ( + `workspace_id` text PRIMARY KEY NOT NULL, + `version` integer DEFAULT 1 NOT NULL, + `workspace_name` text, + `preset` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `workspace` ( + `id` text PRIMARY KEY NOT NULL, + `path` text NOT NULL, + `name` text NOT NULL, + `preset` text, + `workspace_type` text DEFAULT 'local' NOT NULL, + `remote_type` text, + `base_url` text, + `directory` text, + `display_name` text, + `openwork_host_url` text, + `openwork_token` text, + `openwork_workspace_id` text, + `openwork_workspace_name` text, + `sandbox_backend` text, + `sandbox_run_id` text, + `sandbox_container_name` text, + `opencode_username` text, + `opencode_password` text, + `sort_order` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `workspace_path_unique` ON `workspace` (`path`);--> statement-breakpoint +CREATE INDEX `workspace_sort_order_idx` ON `workspace` (`sort_order`);--> statement-breakpoint +CREATE TABLE `token` ( + `id` text PRIMARY KEY NOT NULL, + `hash` text NOT NULL, + `scope` text NOT NULL, + `label` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `token_hash_unique` ON `token` (`hash`);--> statement-breakpoint +CREATE TABLE `workspace_port` ( + `workspace_key` text PRIMARY KEY NOT NULL, + `port` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `workspace_port_port_idx` ON `workspace_port` (`port`);--> statement-breakpoint +CREATE TABLE `workspace_server_token` ( + `workspace_key` text PRIMARY KEY NOT NULL, + `client_token` text, + `host_token` text, + `owner_token` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `server_config` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `env_var` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `env_var_key_unique` ON `env_var` (`key`);--> statement-breakpoint +CREATE TABLE `audit` ( + `id` text PRIMARY KEY NOT NULL, + `source_id` text, + `workspace_id` text DEFAULT '' NOT NULL, + `actor` text NOT NULL, + `action` text NOT NULL, + `target` text NOT NULL, + `summary` text NOT NULL, + `timestamp` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `audit_workspace_timestamp_idx` ON `audit` (`workspace_id`,`timestamp`);--> statement-breakpoint +CREATE TABLE `file_session_event` ( + `id` text PRIMARY KEY NOT NULL, + `seq` integer NOT NULL, + `workspace_id` text NOT NULL, + `type` text NOT NULL, + `path` text NOT NULL, + `to_path` text, + `revision` text, + `timestamp` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `file_session_event_ws_seq_unique` ON `file_session_event` (`workspace_id`,`seq`);--> statement-breakpoint +CREATE TABLE `file_session` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text NOT NULL, + `workspace_root` text NOT NULL, + `actor_token_hash` text NOT NULL, + `actor_scope` text NOT NULL, + `can_write` integer DEFAULT false NOT NULL, + `created_at` integer NOT NULL, + `expires_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `file_session_workspace_idx` ON `file_session` (`workspace_id`);--> statement-breakpoint +CREATE TABLE `session_pref` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `workspace_id` text NOT NULL, + `key` text NOT NULL, + `value` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `session_pref_session_key_unique` ON `session_pref` (`session_id`,`key`);--> statement-breakpoint +CREATE INDEX `session_pref_workspace_idx` ON `session_pref` (`workspace_id`);--> statement-breakpoint +CREATE TABLE `mcp_server` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text, + `scope` text DEFAULT 'project' NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `enabled` integer, + `config` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `mcp_server_ws_name_unique` ON `mcp_server` (`workspace_id`,`name`);--> statement-breakpoint +CREATE TABLE `opencode_config` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text, + `scope` text DEFAULT 'project' NOT NULL, + `key` text NOT NULL, + `value` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `opencode_config_ws_scope_key_unique` ON `opencode_config` (`workspace_id`,`scope`,`key`);--> statement-breakpoint +CREATE TABLE `plugin_entry` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text, + `scope` text DEFAULT 'project' NOT NULL, + `spec` text NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `plugin_entry_ws_spec_unique` ON `plugin_entry` (`workspace_id`,`spec`);--> statement-breakpoint +CREATE INDEX `plugin_entry_sort_order_idx` ON `plugin_entry` (`sort_order`);--> statement-breakpoint +CREATE TABLE `extension_state` ( + `extension_id` text PRIMARY KEY NOT NULL, + `enabled` integer, + `hidden` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `google_workspace_vault` ( + `id` text PRIMARY KEY NOT NULL, + `account_sub` text, + `data` text NOT NULL, + `encrypted` integer DEFAULT true NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `preference` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); diff --git a/packages/desktop-db/drizzle/0001_shocking_mastermind.sql b/packages/desktop-db/drizzle/0001_shocking_mastermind.sql new file mode 100644 index 0000000000..38f505aec1 --- /dev/null +++ b/packages/desktop-db/drizzle/0001_shocking_mastermind.sql @@ -0,0 +1,8 @@ +CREATE TABLE `migration_state` ( + `source` text PRIMARY KEY NOT NULL, + `status` text NOT NULL, + `fingerprint` text DEFAULT '' NOT NULL, + `row_count` integer DEFAULT 0 NOT NULL, + `backup_path` text, + `imported_at` integer NOT NULL +); diff --git a/packages/desktop-db/drizzle/0002_flimsy_chamber.sql b/packages/desktop-db/drizzle/0002_flimsy_chamber.sql new file mode 100644 index 0000000000..f20636ca1d --- /dev/null +++ b/packages/desktop-db/drizzle/0002_flimsy_chamber.sql @@ -0,0 +1,2 @@ +ALTER TABLE `workspace` ADD `openwork_client_token` text;--> statement-breakpoint +ALTER TABLE `workspace` ADD `openwork_host_token` text; \ No newline at end of file diff --git a/packages/desktop-db/drizzle/meta/0000_snapshot.json b/packages/desktop-db/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000000..f7142dcbef --- /dev/null +++ b/packages/desktop-db/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1399 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1e401508-588e-453e-b8e6-358576b3733e", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "authorized_root": { + "name": "authorized_root", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "authorized_root_path_unique": { + "name": "authorized_root_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": { + "authorized_root_workspace_id_workspace_id_fk": { + "name": "authorized_root_workspace_id_workspace_id_fk", + "tableFrom": "authorized_root", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "blueprint_session": { + "name": "blueprint_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hydrated_at": { + "name": "hydrated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "blueprint_session_ws_template_unique": { + "name": "blueprint_session_ws_template_unique", + "columns": [ + "workspace_id", + "template_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "blueprint_session_workspace_id_workspace_id_fk": { + "name": "blueprint_session_workspace_id_workspace_id_fk", + "tableFrom": "blueprint_session", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "desktop_cloud_sync": { + "name": "desktop_cloud_sync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_key": { + "name": "context_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_member_id": { + "name": "org_member_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "desktop_cloud_sync_ws_context_unique": { + "name": "desktop_cloud_sync_ws_context_unique", + "columns": [ + "workspace_id", + "context_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "desktop_cloud_sync_workspace_id_workspace_id_fk": { + "name": "desktop_cloud_sync_workspace_id_workspace_id_fk", + "tableFrom": "desktop_cloud_sync", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_meta": { + "name": "workspace_meta", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preset": { + "name": "preset", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_meta_workspace_id_workspace_id_fk": { + "name": "workspace_meta_workspace_id_workspace_id_fk", + "tableFrom": "workspace_meta", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "preset": { + "name": "preset", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_type": { + "name": "workspace_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "remote_type": { + "name": "remote_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "directory": { + "name": "directory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_host_url": { + "name": "openwork_host_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_token": { + "name": "openwork_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_workspace_id": { + "name": "openwork_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_workspace_name": { + "name": "openwork_workspace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_backend": { + "name": "sandbox_backend", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_run_id": { + "name": "sandbox_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_container_name": { + "name": "sandbox_container_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_username": { + "name": "opencode_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_password": { + "name": "opencode_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_path_unique": { + "name": "workspace_path_unique", + "columns": [ + "path" + ], + "isUnique": true + }, + "workspace_sort_order_idx": { + "name": "workspace_sort_order_idx", + "columns": [ + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token": { + "name": "token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "token_hash_unique": { + "name": "token_hash_unique", + "columns": [ + "hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_port": { + "name": "workspace_port", + "columns": { + "workspace_key": { + "name": "workspace_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_port_port_idx": { + "name": "workspace_port_port_idx", + "columns": [ + "port" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_server_token": { + "name": "workspace_server_token", + "columns": { + "workspace_key": { + "name": "workspace_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_token": { + "name": "client_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_token": { + "name": "host_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "server_config": { + "name": "server_config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "env_var": { + "name": "env_var", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "env_var_key_unique": { + "name": "env_var_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit": { + "name": "audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_workspace_timestamp_idx": { + "name": "audit_workspace_timestamp_idx", + "columns": [ + "workspace_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_session_event": { + "name": "file_session_event", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_path": { + "name": "to_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_session_event_ws_seq_unique": { + "name": "file_session_event_ws_seq_unique", + "columns": [ + "workspace_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_session": { + "name": "file_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_root": { + "name": "workspace_root", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_token_hash": { + "name": "actor_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_scope": { + "name": "actor_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "can_write": { + "name": "can_write", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_session_workspace_idx": { + "name": "file_session_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_pref": { + "name": "session_pref", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_pref_session_key_unique": { + "name": "session_pref_session_key_unique", + "columns": [ + "session_id", + "key" + ], + "isUnique": true + }, + "session_pref_workspace_idx": { + "name": "session_pref_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_server": { + "name": "mcp_server", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mcp_server_ws_name_unique": { + "name": "mcp_server_ws_name_unique", + "columns": [ + "workspace_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "opencode_config": { + "name": "opencode_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "opencode_config_ws_scope_key_unique": { + "name": "opencode_config_ws_scope_key_unique", + "columns": [ + "workspace_id", + "scope", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_entry": { + "name": "plugin_entry", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "spec": { + "name": "spec", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_entry_ws_spec_unique": { + "name": "plugin_entry_ws_spec_unique", + "columns": [ + "workspace_id", + "spec" + ], + "isUnique": true + }, + "plugin_entry_sort_order_idx": { + "name": "plugin_entry_sort_order_idx", + "columns": [ + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "extension_state": { + "name": "extension_state", + "columns": { + "extension_id": { + "name": "extension_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden": { + "name": "hidden", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_workspace_vault": { + "name": "google_workspace_vault", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_sub": { + "name": "account_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "preference": { + "name": "preference", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/desktop-db/drizzle/meta/0001_snapshot.json b/packages/desktop-db/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000000..0438fbd9f7 --- /dev/null +++ b/packages/desktop-db/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1453 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b370e05c-6a91-437b-8f03-0fdb17bac893", + "prevId": "1e401508-588e-453e-b8e6-358576b3733e", + "tables": { + "authorized_root": { + "name": "authorized_root", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "authorized_root_path_unique": { + "name": "authorized_root_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": { + "authorized_root_workspace_id_workspace_id_fk": { + "name": "authorized_root_workspace_id_workspace_id_fk", + "tableFrom": "authorized_root", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "blueprint_session": { + "name": "blueprint_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hydrated_at": { + "name": "hydrated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "blueprint_session_ws_template_unique": { + "name": "blueprint_session_ws_template_unique", + "columns": [ + "workspace_id", + "template_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "blueprint_session_workspace_id_workspace_id_fk": { + "name": "blueprint_session_workspace_id_workspace_id_fk", + "tableFrom": "blueprint_session", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "desktop_cloud_sync": { + "name": "desktop_cloud_sync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_key": { + "name": "context_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_member_id": { + "name": "org_member_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "desktop_cloud_sync_ws_context_unique": { + "name": "desktop_cloud_sync_ws_context_unique", + "columns": [ + "workspace_id", + "context_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "desktop_cloud_sync_workspace_id_workspace_id_fk": { + "name": "desktop_cloud_sync_workspace_id_workspace_id_fk", + "tableFrom": "desktop_cloud_sync", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_meta": { + "name": "workspace_meta", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preset": { + "name": "preset", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_meta_workspace_id_workspace_id_fk": { + "name": "workspace_meta_workspace_id_workspace_id_fk", + "tableFrom": "workspace_meta", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "preset": { + "name": "preset", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_type": { + "name": "workspace_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "remote_type": { + "name": "remote_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "directory": { + "name": "directory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_host_url": { + "name": "openwork_host_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_token": { + "name": "openwork_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_workspace_id": { + "name": "openwork_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_workspace_name": { + "name": "openwork_workspace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_backend": { + "name": "sandbox_backend", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_run_id": { + "name": "sandbox_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_container_name": { + "name": "sandbox_container_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_username": { + "name": "opencode_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_password": { + "name": "opencode_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_path_unique": { + "name": "workspace_path_unique", + "columns": [ + "path" + ], + "isUnique": true + }, + "workspace_sort_order_idx": { + "name": "workspace_sort_order_idx", + "columns": [ + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token": { + "name": "token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "token_hash_unique": { + "name": "token_hash_unique", + "columns": [ + "hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_port": { + "name": "workspace_port", + "columns": { + "workspace_key": { + "name": "workspace_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_port_port_idx": { + "name": "workspace_port_port_idx", + "columns": [ + "port" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_server_token": { + "name": "workspace_server_token", + "columns": { + "workspace_key": { + "name": "workspace_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_token": { + "name": "client_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_token": { + "name": "host_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "server_config": { + "name": "server_config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "env_var": { + "name": "env_var", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "env_var_key_unique": { + "name": "env_var_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit": { + "name": "audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_workspace_timestamp_idx": { + "name": "audit_workspace_timestamp_idx", + "columns": [ + "workspace_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_session_event": { + "name": "file_session_event", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_path": { + "name": "to_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_session_event_ws_seq_unique": { + "name": "file_session_event_ws_seq_unique", + "columns": [ + "workspace_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_session": { + "name": "file_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_root": { + "name": "workspace_root", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_token_hash": { + "name": "actor_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_scope": { + "name": "actor_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "can_write": { + "name": "can_write", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_session_workspace_idx": { + "name": "file_session_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_pref": { + "name": "session_pref", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_pref_session_key_unique": { + "name": "session_pref_session_key_unique", + "columns": [ + "session_id", + "key" + ], + "isUnique": true + }, + "session_pref_workspace_idx": { + "name": "session_pref_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_server": { + "name": "mcp_server", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mcp_server_ws_name_unique": { + "name": "mcp_server_ws_name_unique", + "columns": [ + "workspace_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "opencode_config": { + "name": "opencode_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "opencode_config_ws_scope_key_unique": { + "name": "opencode_config_ws_scope_key_unique", + "columns": [ + "workspace_id", + "scope", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_entry": { + "name": "plugin_entry", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "spec": { + "name": "spec", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_entry_ws_spec_unique": { + "name": "plugin_entry_ws_spec_unique", + "columns": [ + "workspace_id", + "spec" + ], + "isUnique": true + }, + "plugin_entry_sort_order_idx": { + "name": "plugin_entry_sort_order_idx", + "columns": [ + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "extension_state": { + "name": "extension_state", + "columns": { + "extension_id": { + "name": "extension_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden": { + "name": "hidden", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_workspace_vault": { + "name": "google_workspace_vault", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_sub": { + "name": "account_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "preference": { + "name": "preference", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "migration_state": { + "name": "migration_state", + "columns": { + "source": { + "name": "source", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backup_path": { + "name": "backup_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/desktop-db/drizzle/meta/0002_snapshot.json b/packages/desktop-db/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000000..aae4037874 --- /dev/null +++ b/packages/desktop-db/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1467 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6bb7df69-b547-44c8-afc0-85e27846fe3f", + "prevId": "b370e05c-6a91-437b-8f03-0fdb17bac893", + "tables": { + "authorized_root": { + "name": "authorized_root", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "authorized_root_path_unique": { + "name": "authorized_root_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": { + "authorized_root_workspace_id_workspace_id_fk": { + "name": "authorized_root_workspace_id_workspace_id_fk", + "tableFrom": "authorized_root", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "blueprint_session": { + "name": "blueprint_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hydrated_at": { + "name": "hydrated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "blueprint_session_ws_template_unique": { + "name": "blueprint_session_ws_template_unique", + "columns": [ + "workspace_id", + "template_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "blueprint_session_workspace_id_workspace_id_fk": { + "name": "blueprint_session_workspace_id_workspace_id_fk", + "tableFrom": "blueprint_session", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "desktop_cloud_sync": { + "name": "desktop_cloud_sync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_key": { + "name": "context_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_member_id": { + "name": "org_member_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "desktop_cloud_sync_ws_context_unique": { + "name": "desktop_cloud_sync_ws_context_unique", + "columns": [ + "workspace_id", + "context_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "desktop_cloud_sync_workspace_id_workspace_id_fk": { + "name": "desktop_cloud_sync_workspace_id_workspace_id_fk", + "tableFrom": "desktop_cloud_sync", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_meta": { + "name": "workspace_meta", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preset": { + "name": "preset", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_meta_workspace_id_workspace_id_fk": { + "name": "workspace_meta_workspace_id_workspace_id_fk", + "tableFrom": "workspace_meta", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "preset": { + "name": "preset", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_type": { + "name": "workspace_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "remote_type": { + "name": "remote_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "directory": { + "name": "directory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_host_url": { + "name": "openwork_host_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_token": { + "name": "openwork_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_client_token": { + "name": "openwork_client_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_host_token": { + "name": "openwork_host_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_workspace_id": { + "name": "openwork_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_workspace_name": { + "name": "openwork_workspace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_backend": { + "name": "sandbox_backend", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_run_id": { + "name": "sandbox_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_container_name": { + "name": "sandbox_container_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_username": { + "name": "opencode_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_password": { + "name": "opencode_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_path_unique": { + "name": "workspace_path_unique", + "columns": [ + "path" + ], + "isUnique": true + }, + "workspace_sort_order_idx": { + "name": "workspace_sort_order_idx", + "columns": [ + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token": { + "name": "token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "token_hash_unique": { + "name": "token_hash_unique", + "columns": [ + "hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_port": { + "name": "workspace_port", + "columns": { + "workspace_key": { + "name": "workspace_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_port_port_idx": { + "name": "workspace_port_port_idx", + "columns": [ + "port" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_server_token": { + "name": "workspace_server_token", + "columns": { + "workspace_key": { + "name": "workspace_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_token": { + "name": "client_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_token": { + "name": "host_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "server_config": { + "name": "server_config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "env_var": { + "name": "env_var", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "env_var_key_unique": { + "name": "env_var_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit": { + "name": "audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_workspace_timestamp_idx": { + "name": "audit_workspace_timestamp_idx", + "columns": [ + "workspace_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_session_event": { + "name": "file_session_event", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_path": { + "name": "to_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_session_event_ws_seq_unique": { + "name": "file_session_event_ws_seq_unique", + "columns": [ + "workspace_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_session": { + "name": "file_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_root": { + "name": "workspace_root", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_token_hash": { + "name": "actor_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_scope": { + "name": "actor_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "can_write": { + "name": "can_write", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_session_workspace_idx": { + "name": "file_session_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_pref": { + "name": "session_pref", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_pref_session_key_unique": { + "name": "session_pref_session_key_unique", + "columns": [ + "session_id", + "key" + ], + "isUnique": true + }, + "session_pref_workspace_idx": { + "name": "session_pref_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_server": { + "name": "mcp_server", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mcp_server_ws_name_unique": { + "name": "mcp_server_ws_name_unique", + "columns": [ + "workspace_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "opencode_config": { + "name": "opencode_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "opencode_config_ws_scope_key_unique": { + "name": "opencode_config_ws_scope_key_unique", + "columns": [ + "workspace_id", + "scope", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_entry": { + "name": "plugin_entry", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "spec": { + "name": "spec", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_entry_ws_spec_unique": { + "name": "plugin_entry_ws_spec_unique", + "columns": [ + "workspace_id", + "spec" + ], + "isUnique": true + }, + "plugin_entry_sort_order_idx": { + "name": "plugin_entry_sort_order_idx", + "columns": [ + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "extension_state": { + "name": "extension_state", + "columns": { + "extension_id": { + "name": "extension_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden": { + "name": "hidden", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_workspace_vault": { + "name": "google_workspace_vault", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_sub": { + "name": "account_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "preference": { + "name": "preference", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "migration_state": { + "name": "migration_state", + "columns": { + "source": { + "name": "source", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backup_path": { + "name": "backup_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/desktop-db/drizzle/meta/_journal.json b/packages/desktop-db/drizzle/meta/_journal.json new file mode 100644 index 0000000000..f8c9314ef3 --- /dev/null +++ b/packages/desktop-db/drizzle/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1780012089645, + "tag": "0000_giant_shatterstar", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1780013417000, + "tag": "0001_shocking_mastermind", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1780014910749, + "tag": "0002_flimsy_chamber", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/desktop-db/package.json b/packages/desktop-db/package.json new file mode 100644 index 0000000000..e61c82891c --- /dev/null +++ b/packages/desktop-db/package.json @@ -0,0 +1,71 @@ +{ + "name": "@openwork/desktop-db", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "source": "./src/index.ts", + "exports": { + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./typeid": { + "development": "./src/typeid.ts", + "types": "./dist/typeid.d.ts", + "default": "./dist/typeid.js" + }, + "./schema": { + "development": "./src/schema/index.ts", + "types": "./dist/schema/index.d.ts", + "default": "./dist/schema/index.js" + }, + "./client": { + "development": "./src/client.ts", + "types": "./dist/client.d.ts", + "default": "./dist/client.js" + }, + "./drizzle": { + "development": "./src/drizzle.ts", + "types": "./dist/drizzle.d.ts", + "default": "./dist/drizzle.js" + }, + "./import": { + "development": "./src/import/index.ts", + "types": "./dist/import/index.d.ts", + "default": "./dist/import/index.js" + }, + "./preferences": { + "development": "./src/preferences.ts", + "types": "./dist/preferences.d.ts", + "default": "./dist/preferences.js" + } + }, + "files": [ + "src", + "dist", + "drizzle" + ], + "scripts": { + "build": "tsup", + "test": "bun test", + "typecheck": "tsc -p tsconfig.json --noEmit", + "db:generate": "drizzle-kit generate --config drizzle.config.ts" + }, + "dependencies": { + "better-sqlite3": "^11.10.0", + "drizzle-orm": "^0.45.1", + "typeid-js": "^1.2.0", + "uuid": "^11.1.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22.10.2", + "bun-types": "^1.3.6", + "drizzle-kit": "^0.31.9", + "tsup": "^8.5.0", + "typescript": "^5.6.3" + } +} diff --git a/packages/desktop-db/src/client.ts b/packages/desktop-db/src/client.ts new file mode 100644 index 0000000000..5fde67f29e --- /dev/null +++ b/packages/desktop-db/src/client.ts @@ -0,0 +1,165 @@ +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { mkdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"; +import * as schema from "./schema/index"; + +/** + * Runtime-adaptive SQLite client. + * + * - Under **Bun** (the server runs `bun src/cli.ts`): uses `bun:sqlite` + + * `drizzle-orm/bun-sqlite` (better-sqlite3 native bindings are NOT supported in Bun). + * - Under **Node / Electron**: uses `better-sqlite3` + `drizzle-orm/better-sqlite3`. + * + * Both expose the same Drizzle SQLite query API, so `DesktopDb` is the shared base type. + */ +export type DesktopDb = BaseSQLiteDatabase<"sync", unknown, typeof schema>; + +export interface OpenDbOptions { + /** + * Path to the SQLite file. Defaults to `resolveDefaultDbPath()`. + * Use `:memory:` for tests. + */ + path?: string; + /** Directory containing generated drizzle migrations. Defaults to the bundled `drizzle/` dir. */ + migrationsFolder?: string; + /** Run migrations on open. Default true. */ + migrate?: boolean; + /** Enable WAL journal mode. Default true (ignored for in-memory). */ + wal?: boolean; +} + +const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"; + +/** + * Resolve the default desktop DB path. + * + * Honors `OPENWORK_DB` (absolute or relative), then falls back to + * `/openwork.db` where the config dir matches `server.json`'s location: + * - `OPENWORK_SERVER_CONFIG`'s dirname, if set + * - Windows: `%APPDATA%/openwork` + * - POSIX: `~/.config/openwork` + */ +export function resolveDefaultDbPath(): string { + const override = process.env.OPENWORK_DB?.trim(); + if (override) return resolve(override); + + const serverConfig = process.env.OPENWORK_SERVER_CONFIG?.trim(); + if (serverConfig) return join(dirname(resolve(serverConfig)), "openwork.db"); + + if (process.platform === "win32") { + const appData = process.env.APPDATA?.trim() || join(homedir(), "AppData", "Roaming"); + return join(appData, "openwork", "openwork.db"); + } + const xdg = process.env.XDG_CONFIG_HOME?.trim(); + const base = xdg ? resolve(xdg) : join(homedir(), ".config"); + return join(base, "openwork", "openwork.db"); +} + +/** + * Resolve the DB path that sits next to a given `server.json`. Use this from the + * desktop/Electron shell so it opens the SAME DB the server uses + * (`/openwork.db`). `OPENWORK_DB` still wins if set. + */ +export function resolveDbPathForServerConfig(serverConfigPath: string): string { + const override = process.env.OPENWORK_DB?.trim(); + if (override) return resolve(override); + return join(dirname(resolve(serverConfigPath)), "openwork.db"); +} + +/** + * Locate the bundled migrations folder. When running from source the folder is + * `/drizzle`; when bundled it sits next to `dist/`. + */ +function defaultMigrationsFolder(): string { + // import.meta.url points at src/client.ts (dev) or dist/client.js (built). + const here = dirname(fileURLToPath(import.meta.url)); + // both src/ and dist/ are one level below the package root. + return resolve(here, "..", "drizzle"); +} + +interface RawHandle { + pragma(sql: string): void; + close(): void; +} + +let cached: { path: string; db: DesktopDb; raw: RawHandle } | null = null; + +async function openWithBun(path: string, wal: boolean): Promise<{ db: DesktopDb; raw: RawHandle }> { + const { Database } = await import("bun:sqlite"); + const { drizzle } = await import("drizzle-orm/bun-sqlite"); + const sqlite = new Database(path); + sqlite.exec("PRAGMA foreign_keys = ON"); + if (path !== ":memory:" && wal) sqlite.exec("PRAGMA journal_mode = WAL"); + const db = drizzle(sqlite, { schema }) as unknown as DesktopDb; + const raw: RawHandle = { + pragma: (sql) => sqlite.exec(`PRAGMA ${sql}`), + close: () => sqlite.close(), + }; + return { db, raw }; +} + +async function openWithBetterSqlite( + path: string, + wal: boolean, +): Promise<{ db: DesktopDb; raw: RawHandle }> { + const { default: Database } = await import("better-sqlite3"); + const { drizzle } = await import("drizzle-orm/better-sqlite3"); + const sqlite = new Database(path); + sqlite.pragma("foreign_keys = ON"); + if (path !== ":memory:" && wal) sqlite.pragma("journal_mode = WAL"); + const db = drizzle(sqlite, { schema }) as unknown as DesktopDb; + const raw: RawHandle = { + pragma: (sql) => { + sqlite.pragma(sql); + }, + close: () => sqlite.close(), + }; + return { db, raw }; +} + +async function runMigrations(db: DesktopDb, migrationsFolder: string): Promise { + if (isBun) { + const { migrate } = await import("drizzle-orm/bun-sqlite/migrator"); + migrate(db as never, { migrationsFolder }); + } else { + const { migrate } = await import("drizzle-orm/better-sqlite3/migrator"); + migrate(db as never, { migrationsFolder }); + } +} + +/** + * Open (and migrate) the desktop SQLite DB. Returns a cached singleton per path so + * server, electron, and frontend share one connection within a process. + */ +export async function openDb(options: OpenDbOptions = {}): Promise { + const path = options.path ?? resolveDefaultDbPath(); + if (cached && cached.path === path) return cached.db; + + if (path !== ":memory:") { + mkdirSync(dirname(path), { recursive: true }); + } + + const wal = options.wal !== false; + const { db, raw } = isBun + ? await openWithBun(path, wal) + : await openWithBetterSqlite(path, wal); + + if (options.migrate !== false) { + await runMigrations(db, options.migrationsFolder ?? defaultMigrationsFolder()); + } + + cached = { path, db, raw }; + return db; +} + +/** Close the cached connection (mainly for tests / clean shutdown). */ +export function closeDb(): void { + if (cached) { + cached.raw.close(); + cached = null; + } +} + +export { schema }; diff --git a/packages/desktop-db/src/columns.ts b/packages/desktop-db/src/columns.ts new file mode 100644 index 0000000000..74c13d3bb6 --- /dev/null +++ b/packages/desktop-db/src/columns.ts @@ -0,0 +1,68 @@ +import { customType, integer, text } from "drizzle-orm/sqlite-core"; +import { + type DesktopTypeId, + type DesktopTypeIdName, + normalizeDesktopTypeId, +} from "./typeid"; + +/** + * SQLite column helpers for the desktop DB. + * + * Mirrors the conventions in `ee/packages/den-db/src/columns.ts` but targets SQLite + * (better-sqlite3) instead of MySQL/PlanetScale. + */ + +/** + * A TypeID-backed text primary/foreign key column. Values are normalized (validated) + * on the way in and out, so the column always stores a canonical `_`. + */ +export const typeIdColumn = (name: TName, columnName: string) => + customType<{ data: DesktopTypeId; driverData: string }>({ + dataType() { + return "text"; + }, + toDriver(value) { + return normalizeDesktopTypeId(name, value); + }, + fromDriver(value) { + return normalizeDesktopTypeId(name, value); + }, + })(columnName); + +/** + * A JSON column stored as TEXT, transparently (de)serialized. + */ +export const jsonColumn = (columnName: string) => + customType<{ data: TData; driverData: string }>({ + dataType() { + return "text"; + }, + toDriver(value) { + return JSON.stringify(value); + }, + fromDriver(value) { + return JSON.parse(value) as TData; + }, + })(columnName); + +/** + * Epoch-milliseconds timestamp helper. + */ +export const epochMs = (columnName: string) => integer(columnName, { mode: "number" }); + +/** + * Standard created_at / updated_at columns (epoch ms). Defaults are applied in code + * (better-sqlite3 has no `ON UPDATE`), see `withTimestamps` in the DAL. + */ +export const timestamps = { + createdAt: epochMs("created_at").notNull(), + updatedAt: epochMs("updated_at").notNull(), +}; + +/** + * A nullable secret text column. SQLite stores it as plaintext TEXT; encryption is a + * separate concern (see plan.md open question on at-rest encryption). This wrapper + * exists so secret-bearing columns are easy to grep and to later swap for an encrypted + * customType without touching the schema. + */ +export const secretText = (columnName: string) => text(columnName); diff --git a/packages/desktop-db/src/desktop-db.test.ts b/packages/desktop-db/src/desktop-db.test.ts new file mode 100644 index 0000000000..c76a8a80b1 --- /dev/null +++ b/packages/desktop-db/src/desktop-db.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { closeDb, openDb } from "./client"; +import { auditTable, tokenTable, workspaceTable, serverConfigTable } from "./schema/index"; +import { createDesktopTypeId, isDesktopTypeId, normalizeDesktopTypeId } from "./typeid"; +import { eq } from "drizzle-orm"; +import { runPhase1Import } from "./import/index"; + +let tmp: string | null = null; + +afterEach(() => { + closeDb(); + if (tmp) { + rmSync(tmp, { recursive: true, force: true }); + tmp = null; + } +}); + +function freshDir(): string { + tmp = mkdtempSync(join(tmpdir(), "owdb-")); + return tmp; +} + +describe("typeid", () => { + test("generates and validates desktop type ids", () => { + const id = createDesktopTypeId("workspaceMeta"); + expect(id.startsWith("wsmeta_")).toBe(true); + expect(isDesktopTypeId("workspaceMeta", id)).toBe(true); + expect(isDesktopTypeId("token", id)).toBe(false); + }); + + test("normalize rejects prefix mismatch", () => { + const tokenId = createDesktopTypeId("token"); + expect(() => normalizeDesktopTypeId("audit", tokenId)).toThrow(); + }); +}); + +describe("client", () => { + test("opens, migrates, and round-trips a typeid column", async () => { + const dir = freshDir(); + const db = await openDb({ path: join(dir, "test.db") }); + const id = createDesktopTypeId("token"); + const now = Date.now(); + db.insert(tokenTable).values({ id, hash: "abc", scope: "owner", createdAt: now }).run(); + const rows = db.select().from(tokenTable).where(eq(tokenTable.hash, "abc")).all(); + expect(rows.length).toBe(1); + expect(rows[0]!.id).toBe(id); + expect(rows[0]!.scope).toBe("owner"); + }); +}); + +describe("phase 1 import", () => { + test("imports server.json, tokens.json, and audit jsonl", async () => { + const dir = freshDir(); + const serverJsonPath = join(dir, "server.json"); + const tokensJsonPath = join(dir, "tokens.json"); + const auditDir = join(dir, "audit"); + mkdirSync(auditDir, { recursive: true }); + + writeFileSync( + serverJsonPath, + JSON.stringify({ + host: "127.0.0.1", + port: 8787, + token: "secret-token", + approval: { mode: "auto", timeoutMs: 1000 }, + workspaces: [ + { id: "ws_aaa", path: "/tmp/a", name: "A", preset: "default" }, + { id: "ws_bbb", path: "/tmp/b", name: "B", workspaceType: "remote", remoteType: "openwork" }, + ], + authorizedRoots: ["/tmp/a", "/tmp/b"], + }), + ); + writeFileSync( + tokensJsonPath, + JSON.stringify({ + schemaVersion: 1, + updatedAt: Date.now(), + tokens: [ + { id: "uuid-1", hash: "hash-1", scope: "owner", createdAt: 1 }, + { id: "uuid-2", hash: "hash-2", scope: "viewer", createdAt: 2, label: "ci" }, + ], + }), + ); + writeFileSync( + join(auditDir, "ws_aaa.jsonl"), + [ + JSON.stringify({ id: "a1", workspaceId: "ws_aaa", actor: { type: "host" }, action: "workspace.create", target: "/tmp/a", summary: "created", timestamp: 10 }), + JSON.stringify({ id: "a2", workspaceId: "ws_aaa", actor: { type: "remote", scope: "owner" }, action: "config.write", target: "opencode.json", summary: "wrote", timestamp: 20 }), + ].join("\n") + "\n", + ); + + const db = await openDb({ path: join(dir, "test.db") }); + const report = await runPhase1Import(db, { serverJsonPath, tokensJsonPath, auditDir }); + + expect(report.serverJson!.found).toBe(true); + expect(report.tokensJson!.found).toBe(true); + expect(report.audit!.found).toBe(true); + + const workspaces = db.select().from(workspaceTable).all(); + expect(workspaces.length).toBe(2); + const wsA = workspaces.find((w) => w.id === "ws_aaa"); + expect(wsA?.sortOrder).toBe(0); + expect(wsA?.name).toBe("A"); + + const tokens = db.select().from(tokenTable).all(); + expect(tokens.length).toBe(2); + + const portRow = db + .select() + .from(serverConfigTable) + .where(eq(serverConfigTable.key, "port")) + .all(); + expect(portRow[0]!.value).toBe(8787); + + const audits = db.select().from(auditTable).all(); + expect(audits.length).toBe(2); + expect(audits[0]!.sourceId).toBe("a1"); + + // idempotent re-run + const report2 = await runPhase1Import(db, { serverJsonPath, tokensJsonPath, auditDir }); + expect(report2.serverJson!.found).toBe(true); + expect(db.select().from(workspaceTable).all().length).toBe(2); + expect(db.select().from(tokenTable).all().length).toBe(2); + }); +}); diff --git a/packages/desktop-db/src/desktop-import.test.ts b/packages/desktop-db/src/desktop-import.test.ts new file mode 100644 index 0000000000..806a4b916e --- /dev/null +++ b/packages/desktop-db/src/desktop-import.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { closeDb, openDb } from "./client"; +import { + workspaceTable, + workspaceServerTokenTable, + workspacePortTable, + preferenceTable, +} from "./schema/index"; +import { + runDesktopImportOnce, + DESKTOP_SELECTED_WORKSPACE_PREF, + DESKTOP_PREFERRED_PORT_PREF, +} from "./import/index"; + +let tmp: string | null = null; + +afterEach(() => { + closeDb(); + if (tmp) { + rmSync(tmp, { recursive: true, force: true }); + tmp = null; + } +}); + +function setup() { + tmp = mkdtempSync(join(tmpdir(), "owdesktop-")); + const workspacesPath = join(tmp, "openwork-workspaces.json"); + const serverTokensPath = join(tmp, "openwork-server-tokens.json"); + const serverStatePath = join(tmp, "openwork-server-state.json"); + + writeFileSync( + workspacesPath, + JSON.stringify({ + selectedId: "ws_a", + watchedId: "ws_a", + workspaces: [ + { id: "ws_a", name: "A", path: "/tmp/a", workspaceType: "local" }, + { + id: "rem_x", + name: "Remote", + path: "/remote/x", + workspaceType: "remote", + remoteType: "openwork", + baseUrl: "http://host", + openworkClientToken: "ct", + openworkHostToken: "ht", + openworkWorkspaceId: "x", + }, + ], + }), + ); + writeFileSync( + serverTokensPath, + JSON.stringify({ + version: 1, + workspaces: { + "/tmp/a": { clientToken: "c1", hostToken: "h1", ownerToken: "o1", updatedAt: 5 }, + }, + }), + ); + writeFileSync( + serverStatePath, + JSON.stringify({ version: 3, workspacePorts: { "/tmp/a": 48123 }, preferredPort: null }), + ); + + return { workspacesPath, serverTokensPath, serverStatePath }; +} + +describe("runDesktopImportOnce", () => { + test("imports workspaces, tokens, ports; snapshots; idempotent", async () => { + const { workspacesPath, serverTokensPath, serverStatePath } = setup(); + const db = await openDb({ path: join(tmp!, "test.db") }); + + const report = await runDesktopImportOnce(db, { + workspacesPath, + serverTokensPath, + serverStatePath, + }); + expect(report["electron:openwork-workspaces.json"]!.status).toBe("imported"); + expect(report["electron:openwork-server-tokens.json"]!.status).toBe("imported"); + expect(report["electron:openwork-server-state.json"]!.status).toBe("imported"); + + expect(existsSync(`${workspacesPath}.pre-db.bak`)).toBe(true); + + const workspaces = db.select().from(workspaceTable).all(); + expect(workspaces.length).toBe(2); + const remote = workspaces.find((w) => w.id === "rem_x"); + expect(remote?.openworkClientToken).toBe("ct"); + expect(remote?.openworkHostToken).toBe("ht"); + + const tokens = db + .select() + .from(workspaceServerTokenTable) + .where(eq(workspaceServerTokenTable.workspaceKey, "/tmp/a")) + .all(); + expect(tokens[0]?.ownerToken).toBe("o1"); + + const ports = db.select().from(workspacePortTable).all(); + expect(ports[0]?.port).toBe(48123); + + const selected = db + .select() + .from(preferenceTable) + .where(eq(preferenceTable.key, DESKTOP_SELECTED_WORKSPACE_PREF)) + .all(); + expect(selected[0]?.value).toBe("ws_a"); + + // Re-run: already-done, no duplicates. + const second = await runDesktopImportOnce(db, { + workspacesPath, + serverTokensPath, + serverStatePath, + }); + expect(second["electron:openwork-workspaces.json"]!.status).toBe("already-done"); + expect(db.select().from(workspaceTable).all().length).toBe(2); + }); + + test("preferred port preference imported when set", async () => { + tmp = mkdtempSync(join(tmpdir(), "owdesktop-")); + const serverStatePath = join(tmp, "openwork-server-state.json"); + writeFileSync(serverStatePath, JSON.stringify({ version: 3, preferredPort: 49000 })); + const db = await openDb({ path: join(tmp, "test.db") }); + await runDesktopImportOnce(db, { + workspacesPath: join(tmp, "missing-workspaces.json"), + serverTokensPath: join(tmp, "missing-tokens.json"), + serverStatePath, + }); + const pref = db + .select() + .from(preferenceTable) + .where(eq(preferenceTable.key, DESKTOP_PREFERRED_PORT_PREF)) + .all(); + expect(pref[0]?.value).toBe(49000); + }); +}); diff --git a/packages/desktop-db/src/drizzle.ts b/packages/desktop-db/src/drizzle.ts new file mode 100644 index 0000000000..ac9cb01013 --- /dev/null +++ b/packages/desktop-db/src/drizzle.ts @@ -0,0 +1,18 @@ +export { + and, + asc, + count, + desc, + eq, + gt, + gte, + inArray, + isNotNull, + isNull, + like, + lt, + lte, + ne, + or, + sql, +} from "drizzle-orm"; diff --git a/packages/desktop-db/src/import-once.test.ts b/packages/desktop-db/src/import-once.test.ts new file mode 100644 index 0000000000..6e1fb39f58 --- /dev/null +++ b/packages/desktop-db/src/import-once.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { closeDb, openDb } from "./client"; +import { migrationStateTable, tokenTable, workspaceTable } from "./schema/index"; +import { runPhase1ImportOnce } from "./import/index"; + +let tmp: string | null = null; + +afterEach(() => { + closeDb(); + if (tmp) { + rmSync(tmp, { recursive: true, force: true }); + tmp = null; + } +}); + +function setup() { + tmp = mkdtempSync(join(tmpdir(), "owimport-")); + const serverJsonPath = join(tmp, "server.json"); + const tokensJsonPath = join(tmp, "tokens.json"); + const auditDir = join(tmp, "audit"); + mkdirSync(auditDir, { recursive: true }); + writeFileSync( + serverJsonPath, + JSON.stringify({ + port: 8787, + workspaces: [{ id: "ws_a", path: "/tmp/a", name: "A" }], + authorizedRoots: ["/tmp/a"], + }), + ); + writeFileSync( + tokensJsonPath, + JSON.stringify({ schemaVersion: 1, tokens: [{ id: "u1", hash: "h1", scope: "owner" }] }), + ); + writeFileSync( + join(auditDir, "ws_a.jsonl"), + JSON.stringify({ id: "a1", workspaceId: "ws_a", action: "x", target: "y", summary: "z", timestamp: 1 }) + "\n", + ); + return { serverJsonPath, tokensJsonPath, auditDir }; +} + +describe("runPhase1ImportOnce", () => { + test("imports once, snapshots .pre-db.bak, and skips on re-run", async () => { + const { serverJsonPath, tokensJsonPath, auditDir } = setup(); + const db = await openDb({ path: join(tmp!, "test.db") }); + + const first = await runPhase1ImportOnce(db, { serverJsonPath, tokensJsonPath, auditDir }); + expect(first["server.json"]!.status).toBe("imported"); + expect(first["tokens.json"]!.status).toBe("imported"); + expect(first.audit!.status).toBe("imported"); + + // Source files are preserved + a .pre-db.bak snapshot exists. + expect(existsSync(serverJsonPath)).toBe(true); + expect(existsSync(`${serverJsonPath}.pre-db.bak`)).toBe(true); + expect(existsSync(`${tokensJsonPath}.pre-db.bak`)).toBe(true); + expect(readFileSync(`${serverJsonPath}.pre-db.bak`, "utf8")).toBe( + readFileSync(serverJsonPath, "utf8"), + ); + + expect(db.select().from(workspaceTable).all().length).toBe(1); + expect(db.select().from(tokenTable).all().length).toBe(1); + + // migration_state recorded. + const state = db.select().from(migrationStateTable).all(); + expect(state.find((s) => s.source === "server.json")?.status).toBe("imported"); + + // Re-run with unchanged sources -> already-done, no duplicate rows. + const second = await runPhase1ImportOnce(db, { serverJsonPath, tokensJsonPath, auditDir }); + expect(second["server.json"]!.status).toBe("already-done"); + expect(second.audit!.status).toBe("already-done"); + expect(db.select().from(workspaceTable).all().length).toBe(1); + expect(db.select().from(tokenTable).all().length).toBe(1); + }); + + test("reports missing sources without error", async () => { + tmp = mkdtempSync(join(tmpdir(), "owimport-")); + const db = await openDb({ path: join(tmp, "test.db") }); + const report = await runPhase1ImportOnce(db, { + serverJsonPath: join(tmp, "nope.json"), + tokensJsonPath: join(tmp, "nope-tokens.json"), + auditDir: join(tmp, "no-audit"), + }); + expect(report["server.json"]!.status).toBe("missing"); + expect(report["tokens.json"]!.status).toBe("missing"); + expect(report.audit!.status).toBe("missing"); + }); +}); diff --git a/packages/desktop-db/src/import/audit-jsonl.ts b/packages/desktop-db/src/import/audit-jsonl.ts new file mode 100644 index 0000000000..a6fe5cd9e9 --- /dev/null +++ b/packages/desktop-db/src/import/audit-jsonl.ts @@ -0,0 +1,82 @@ +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import type { DesktopDb } from "../client"; +import { auditTable } from "../schema/index"; +import { createDesktopTypeId } from "../typeid"; +import { type ImportResult, readLines } from "./helpers"; + +/** + * Import audit JSONL files into the `audit` table. + * + * Source layout: `/audit/.jsonl` (one JSON object per line). + * The original `id` (randomUUID) is preserved as `sourceId`; the row gets a TypeID. + * Dedupe is by `sourceId` so re-running doesn't duplicate. + * + * `auditDir` should be the `audit/` directory (e.g. `~/.openwork/openwork-server/audit`). + */ + +interface AuditJsonlEntry { + id?: string; + workspaceId?: string; + actor?: { + type?: "remote" | "host"; + clientId?: string; + tokenHash?: string; + scope?: "owner" | "collaborator" | "viewer"; + }; + action?: string; + target?: string; + summary?: string; + timestamp?: number; +} + +export async function importAuditDir(db: DesktopDb, auditDir: string): Promise { + let files: string[]; + try { + files = (await readdir(auditDir)).filter((f) => f.endsWith(".jsonl")); + } catch { + return { count: 0, found: false }; + } + if (files.length === 0) return { count: 0, found: true }; + + let count = 0; + + for (const file of files) { + const lines = await readLines(join(auditDir, file)); + const entries: AuditJsonlEntry[] = []; + for (const line of lines) { + try { + entries.push(JSON.parse(line) as AuditJsonlEntry); + } catch { + // skip malformed lines + } + } + if (entries.length === 0) continue; + + db.transaction((tx) => { + for (const entry of entries) { + const actor = { + type: entry.actor?.type ?? "remote", + clientId: entry.actor?.clientId, + tokenHash: entry.actor?.tokenHash, + scope: entry.actor?.scope, + } as const; + tx.insert(auditTable) + .values({ + id: createDesktopTypeId("audit"), + sourceId: entry.id ?? null, + workspaceId: entry.workspaceId ?? "", + actor, + action: entry.action ?? "", + target: entry.target ?? "", + summary: entry.summary ?? "", + timestamp: entry.timestamp ?? Date.now(), + }) + .run(); + count += 1; + } + }); + } + + return { count, found: true }; +} diff --git a/packages/desktop-db/src/import/desktop.ts b/packages/desktop-db/src/import/desktop.ts new file mode 100644 index 0000000000..f9c70ebf1a --- /dev/null +++ b/packages/desktop-db/src/import/desktop.ts @@ -0,0 +1,295 @@ +import { eq } from "drizzle-orm"; +import type { DesktopDb } from "../client"; +import { + migrationStateTable, + preferenceTable, + workspacePortTable, + workspaceServerTokenTable, + workspaceTable, +} from "../schema/index"; +import { type ImportResult, readJsonFile } from "./helpers"; +import { fileFingerprint, snapshotOnce } from "./fingerprint"; + +/** + * Importers for the Electron desktop-only state files (under `app.getPath("userData")`): + * - `openwork-workspaces.json` -> workspace table + selection (preference rows) + * - `openwork-server-tokens.json` -> workspace_server_token table + * - `openwork-server-state.json` -> workspace_port table + preferred port preference + * + * Source files are preserved; `runDesktopImportOnce` snapshots `.pre-db.bak` and gates + * via `migration_state` (same pattern as the server-side Phase 1 import). + */ + +interface ElectronWorkspaceEntry { + id?: string; + name?: string; + path?: string; + preset?: string; + workspaceType?: string; + remoteType?: string | null; + baseUrl?: string | null; + directory?: string | null; + displayName?: string | null; + openworkHostUrl?: string | null; + openworkToken?: string | null; + openworkClientToken?: string | null; + openworkHostToken?: string | null; + openworkWorkspaceId?: string | null; + openworkWorkspaceName?: string | null; + sandboxBackend?: string | null; + sandboxRunId?: string | null; + sandboxContainerName?: string | null; +} + +interface ElectronWorkspaceState { + selectedId?: string; + selectedWorkspaceId?: string; + watchedId?: string | null; + watchedWorkspaceId?: string | null; + activeId?: string | null; + workspaces?: ElectronWorkspaceEntry[]; +} + +export const DESKTOP_SELECTED_WORKSPACE_PREF = "desktop.selectedWorkspaceId"; +export const DESKTOP_WATCHED_WORKSPACE_PREF = "desktop.watchedWorkspaceId"; +export const DESKTOP_PREFERRED_PORT_PREF = "desktop.preferredPort"; + +function setPreference(db: DesktopDb, key: string, value: unknown, now: number) { + db.insert(preferenceTable) + .values({ key, value, createdAt: now, updatedAt: now }) + .onConflictDoUpdate({ target: preferenceTable.key, set: { value, updatedAt: now } }) + .run(); +} + +export async function importElectronWorkspaces(db: DesktopDb, path: string): Promise { + const parsed = await readJsonFile(path); + if (!parsed) return { count: 0, found: false }; + + const now = Date.now(); + let count = 0; + const workspaces = parsed.workspaces ?? []; + const selectedId = parsed.selectedId ?? parsed.selectedWorkspaceId ?? parsed.activeId ?? ""; + const watchedId = parsed.watchedId ?? parsed.watchedWorkspaceId ?? ""; + + db.transaction((tx) => { + workspaces.forEach((ws, index) => { + const id = String(ws.id ?? "").trim(); + if (!id) return; + const isLocal = ws.workspaceType !== "remote"; + const values = { + id, + path: ws.path ?? "", + name: ws.name ?? ws.path ?? "Workspace", + preset: ws.preset ?? null, + workspaceType: ws.workspaceType ?? "local", + remoteType: ws.remoteType ?? null, + baseUrl: !isLocal ? ws.baseUrl ?? null : null, + directory: !isLocal ? ws.directory ?? null : null, + displayName: ws.displayName ?? null, + openworkHostUrl: ws.openworkHostUrl ?? null, + openworkToken: ws.openworkToken ?? null, + openworkClientToken: ws.openworkClientToken ?? null, + openworkHostToken: ws.openworkHostToken ?? null, + openworkWorkspaceId: ws.openworkWorkspaceId ?? null, + openworkWorkspaceName: ws.openworkWorkspaceName ?? null, + sandboxBackend: ws.sandboxBackend ?? null, + sandboxRunId: ws.sandboxRunId ?? null, + sandboxContainerName: ws.sandboxContainerName ?? null, + sortOrder: index, + updatedAt: now, + }; + tx.insert(workspaceTable) + .values({ ...values, createdAt: now }) + .onConflictDoUpdate({ target: workspaceTable.id, set: values }) + .run(); + count += 1; + }); + + setPreference(tx as unknown as DesktopDb, DESKTOP_SELECTED_WORKSPACE_PREF, selectedId, now); + setPreference(tx as unknown as DesktopDb, DESKTOP_WATCHED_WORKSPACE_PREF, watchedId, now); + }); + + return { count, found: true }; +} + +interface ElectronTokenStore { + version?: number; + workspaces?: Record< + string, + { clientToken?: string | null; hostToken?: string | null; ownerToken?: string | null; updatedAt?: number } + >; +} + +export async function importElectronServerTokens(db: DesktopDb, path: string): Promise { + const parsed = await readJsonFile(path); + if (!parsed) return { count: 0, found: false }; + + const now = Date.now(); + let count = 0; + const entries = Object.entries(parsed.workspaces ?? {}); + + db.transaction((tx) => { + for (const [workspaceKey, tokens] of entries) { + const values = { + workspaceKey, + clientToken: tokens.clientToken ?? null, + hostToken: tokens.hostToken ?? null, + ownerToken: tokens.ownerToken ?? null, + createdAt: tokens.updatedAt ?? now, + updatedAt: tokens.updatedAt ?? now, + }; + tx.insert(workspaceServerTokenTable) + .values(values) + .onConflictDoUpdate({ + target: workspaceServerTokenTable.workspaceKey, + set: { + clientToken: values.clientToken, + hostToken: values.hostToken, + ownerToken: values.ownerToken, + updatedAt: values.updatedAt, + }, + }) + .run(); + count += 1; + } + }); + + return { count, found: true }; +} + +interface ElectronPortState { + version?: number; + workspacePorts?: Record; + preferredPort?: number | null; +} + +export async function importElectronServerState(db: DesktopDb, path: string): Promise { + const parsed = await readJsonFile(path); + if (!parsed) return { count: 0, found: false }; + + const now = Date.now(); + let count = 0; + const ports = Object.entries(parsed.workspacePorts ?? {}); + + db.transaction((tx) => { + for (const [workspaceKey, port] of ports) { + if (typeof port !== "number") continue; + tx.insert(workspacePortTable) + .values({ workspaceKey, port, createdAt: now, updatedAt: now }) + .onConflictDoUpdate({ + target: workspacePortTable.workspaceKey, + set: { port, updatedAt: now }, + }) + .run(); + count += 1; + } + if (typeof parsed.preferredPort === "number") { + setPreference(tx as unknown as DesktopDb, DESKTOP_PREFERRED_PORT_PREF, parsed.preferredPort, now); + count += 1; + } + }); + + return { count, found: true }; +} + +export interface DesktopImportOptions { + workspacesPath: string; + serverTokensPath: string; + serverStatePath: string; +} + +export type DesktopImportStatus = "imported" | "already-done" | "missing" | "error"; + +export interface DesktopImportEntry { + source: string; + status: DesktopImportStatus; + fingerprint: string; + rowCount: number; + backupPath: string | null; + error?: string; +} + +export type DesktopImportReport = Record; + +async function getState(db: DesktopDb, source: string) { + const rows = await db + .select() + .from(migrationStateTable) + .where(eq(migrationStateTable.source, source)); + return rows[0] ?? null; +} + +function recordState( + db: DesktopDb, + entry: { source: string; status: string; fingerprint: string; rowCount: number; backupPath: string | null }, +) { + const now = Date.now(); + db.insert(migrationStateTable) + .values({ ...entry, importedAt: now }) + .onConflictDoUpdate({ + target: migrationStateTable.source, + set: { + status: entry.status, + fingerprint: entry.fingerprint, + rowCount: entry.rowCount, + backupPath: entry.backupPath, + importedAt: now, + }, + }) + .run(); +} + +/** + * One-time import of the three Electron state files, gated by `migration_state` + * (keyed `electron:`). Snapshots `.pre-db.bak`, preserves the originals, and + * skips when the source fingerprint is unchanged. Cheap on subsequent starts. + */ +export async function runDesktopImportOnce( + db: DesktopDb, + options: DesktopImportOptions, +): Promise { + const sources: Array<{ + key: string; + path: string; + run: (db: DesktopDb, path: string) => Promise; + }> = [ + { key: "electron:openwork-workspaces.json", path: options.workspacesPath, run: importElectronWorkspaces }, + { key: "electron:openwork-server-tokens.json", path: options.serverTokensPath, run: importElectronServerTokens }, + { key: "electron:openwork-server-state.json", path: options.serverStatePath, run: importElectronServerState }, + ]; + + const report: DesktopImportReport = {}; + + for (const source of sources) { + const fingerprint = await fileFingerprint(source.path); + if (fingerprint === null) { + report[source.key] = { source: source.key, status: "missing", fingerprint: "", rowCount: 0, backupPath: null }; + continue; + } + + const prior = await getState(db, source.key); + if (prior && prior.status === "imported" && prior.fingerprint === fingerprint) { + report[source.key] = { + source: source.key, + status: "already-done", + fingerprint, + rowCount: prior.rowCount, + backupPath: prior.backupPath ?? null, + }; + continue; + } + + try { + const backupPath = await snapshotOnce(source.path); + const result = await source.run(db, source.path); + recordState(db, { source: source.key, status: "imported", fingerprint, rowCount: result.count, backupPath }); + report[source.key] = { source: source.key, status: "imported", fingerprint, rowCount: result.count, backupPath }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + recordState(db, { source: source.key, status: "error", fingerprint, rowCount: 0, backupPath: null }); + report[source.key] = { source: source.key, status: "error", fingerprint, rowCount: 0, backupPath: null, error: message }; + } + } + + return report; +} diff --git a/packages/desktop-db/src/import/fingerprint.ts b/packages/desktop-db/src/import/fingerprint.ts new file mode 100644 index 0000000000..8e0dfb2d26 --- /dev/null +++ b/packages/desktop-db/src/import/fingerprint.ts @@ -0,0 +1,47 @@ +import { copyFile, readdir, stat } from "node:fs/promises"; + +/** ":" for a file, or null if it doesn't exist. */ +export async function fileFingerprint(path: string): Promise { + try { + const s = await stat(path); + return `${Math.round(s.mtimeMs)}:${s.size}`; + } catch { + return null; + } +} + +/** + * A combined fingerprint for a directory of files (used for the audit dir): sorted + * "=:" pairs joined with "|". Returns null if the dir is absent. + */ +export async function dirFingerprint(dir: string, suffix = ""): Promise { + let names: string[]; + try { + names = await readdir(dir); + } catch { + return null; + } + const parts: string[] = []; + for (const name of names.sort()) { + if (suffix && !name.endsWith(suffix)) continue; + const fp = await fileFingerprint(`${dir}/${name}`); + if (fp) parts.push(`${name}=${fp}`); + } + return parts.join("|"); +} + +/** + * Write a one-time `.pre-db.bak` snapshot of a source file (only if the source + * exists and the backup doesn't already exist). Returns the backup path, or null if + * the source is missing. Never deletes or modifies the source. + */ +export async function snapshotOnce(path: string): Promise { + const fp = await fileFingerprint(path); + if (!fp) return null; + const backup = `${path}.pre-db.bak`; + const existing = await fileFingerprint(backup); + if (!existing) { + await copyFile(path, backup); + } + return backup; +} diff --git a/packages/desktop-db/src/import/helpers.ts b/packages/desktop-db/src/import/helpers.ts new file mode 100644 index 0000000000..02f2d257da --- /dev/null +++ b/packages/desktop-db/src/import/helpers.ts @@ -0,0 +1,30 @@ +import { readFile } from "node:fs/promises"; + +/** Read + JSON.parse a file, returning `null` if it doesn't exist or is invalid. */ +export async function readJsonFile(path: string): Promise { + try { + const raw = await readFile(path, "utf8"); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +/** Read a file's lines, returning [] if it doesn't exist. */ +export async function readLines(path: string): Promise { + try { + const raw = await readFile(path, "utf8"); + return raw.split("\n").filter((line) => line.trim().length > 0); + } catch { + return []; + } +} + +export interface ImportResult { + /** Number of rows inserted/updated for this source. */ + count: number; + /** Whether the source file existed. */ + found: boolean; +} + +export type ImportReport = Record; diff --git a/packages/desktop-db/src/import/import-once.ts b/packages/desktop-db/src/import/import-once.ts new file mode 100644 index 0000000000..c32ce3ea63 --- /dev/null +++ b/packages/desktop-db/src/import/import-once.ts @@ -0,0 +1,204 @@ +import { copyFile, mkdir, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import type { DesktopDb } from "../client"; +import { migrationStateTable } from "../schema/index"; +import { importServerJson } from "./server-json"; +import { importTokensJson } from "./tokens-json"; +import { importAuditDir } from "./audit-jsonl"; +import { dirFingerprint, fileFingerprint, snapshotOnce } from "./fingerprint"; +import { + resolveAuditDir, + resolveServerJsonPath, + resolveTokensJsonPath, + type ImportOptions, +} from "./paths"; + +export type ImportOnceStatus = "imported" | "already-done" | "missing" | "error"; + +export interface ImportOnceEntry { + source: string; + status: ImportOnceStatus; + fingerprint: string; + rowCount: number; + backupPath: string | null; + error?: string; +} + +export type ImportOnceReport = Record; + +async function getState(db: DesktopDb, source: string) { + const rows = await db + .select() + .from(migrationStateTable) + .where(eq(migrationStateTable.source, source)); + return rows[0] ?? null; +} + +function recordState( + db: DesktopDb, + entry: { source: string; status: string; fingerprint: string; rowCount: number; backupPath: string | null }, +) { + const now = Date.now(); + db.insert(migrationStateTable) + .values({ ...entry, importedAt: now }) + .onConflictDoUpdate({ + target: migrationStateTable.source, + set: { + status: entry.status, + fingerprint: entry.fingerprint, + rowCount: entry.rowCount, + backupPath: entry.backupPath, + importedAt: now, + }, + }) + .run(); +} + +/** + * Snapshot every `*.jsonl` in the audit dir into `/../audit-pre-db-bak/`. + * Returns the backup dir path, or null if the audit dir is absent. + */ +async function snapshotAuditDir(auditDir: string): Promise { + let names: string[]; + try { + names = (await readdir(auditDir)).filter((n) => n.endsWith(".jsonl")); + } catch { + return null; + } + if (names.length === 0) return null; + const backupDir = join(auditDir, "..", "audit-pre-db-bak"); + await mkdir(backupDir, { recursive: true }); + for (const name of names) { + const dest = join(backupDir, name); + const existing = await fileFingerprint(dest); + if (!existing) await copyFile(join(auditDir, name), dest); + } + return backupDir; +} + +type Source = { + key: string; + fingerprint: () => Promise; + run: () => Promise<{ count: number; found: boolean; backupPath: string | null }>; +}; + +/** + * One-time Phase 1 import gated by the `migration_state` table. + * + * For each source (server.json, tokens.json, audit dir): + * - if a `migration_state` row already exists AND the source fingerprint is unchanged, + * skip ("already-done"); + * - otherwise snapshot the source to a `.pre-db.bak` (never deleting the original), + * import it, and record the new fingerprint. + * + * Idempotent and cheap on subsequent starts (only stat()s the source files). + * Source files are preserved so the migration can be reverted. + */ +export async function runPhase1ImportOnce( + db: DesktopDb, + options: ImportOptions = {}, +): Promise { + const serverJsonPath = options.serverJsonPath ?? resolveServerJsonPath(); + const tokensJsonPath = options.tokensJsonPath ?? resolveTokensJsonPath(); + const auditDir = options.auditDir ?? resolveAuditDir(); + + const sources: Source[] = [ + { + key: "server.json", + fingerprint: () => fileFingerprint(serverJsonPath), + run: async () => { + const backupPath = await snapshotOnce(serverJsonPath); + const result = await importServerJson(db, serverJsonPath); + return { ...result, backupPath }; + }, + }, + { + key: "tokens.json", + fingerprint: () => fileFingerprint(tokensJsonPath), + run: async () => { + const backupPath = await snapshotOnce(tokensJsonPath); + const result = await importTokensJson(db, tokensJsonPath); + return { ...result, backupPath }; + }, + }, + ]; + + if (!options.skipAudit) { + sources.push({ + key: "audit", + fingerprint: () => dirFingerprint(auditDir, ".jsonl"), + run: async () => { + const backupPath = await snapshotAuditDir(auditDir); + const result = await importAuditDir(db, auditDir); + return { ...result, backupPath }; + }, + }); + } + + const report: ImportOnceReport = {}; + + for (const source of sources) { + const fingerprint = await source.fingerprint(); + + if (fingerprint === null) { + report[source.key] = { + source: source.key, + status: "missing", + fingerprint: "", + rowCount: 0, + backupPath: null, + }; + continue; + } + + const prior = await getState(db, source.key); + if (prior && prior.status === "imported" && prior.fingerprint === fingerprint) { + report[source.key] = { + source: source.key, + status: "already-done", + fingerprint, + rowCount: prior.rowCount, + backupPath: prior.backupPath ?? null, + }; + continue; + } + + try { + const { count, backupPath } = await source.run(); + recordState(db, { + source: source.key, + status: "imported", + fingerprint, + rowCount: count, + backupPath, + }); + report[source.key] = { + source: source.key, + status: "imported", + fingerprint, + rowCount: count, + backupPath, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + recordState(db, { + source: source.key, + status: "error", + fingerprint, + rowCount: 0, + backupPath: null, + }); + report[source.key] = { + source: source.key, + status: "error", + fingerprint, + rowCount: 0, + backupPath: null, + error: message, + }; + } + } + + return report; +} diff --git a/packages/desktop-db/src/import/index.ts b/packages/desktop-db/src/import/index.ts new file mode 100644 index 0000000000..2d1915b867 --- /dev/null +++ b/packages/desktop-db/src/import/index.ts @@ -0,0 +1,68 @@ +import type { DesktopDb } from "../client"; +import type { ImportReport } from "./helpers"; +import { importServerJson } from "./server-json"; +import { importTokensJson } from "./tokens-json"; +import { importAuditDir } from "./audit-jsonl"; +import { + resolveAuditDir, + resolveServerJsonPath, + resolveTokensJsonPath, + type ImportOptions, +} from "./paths"; + +export { importServerJson } from "./server-json"; +export { importTokensJson } from "./tokens-json"; +export { importAuditDir } from "./audit-jsonl"; +export type { ImportReport, ImportResult } from "./helpers"; +export { + resolveAuditDir, + resolveServerJsonPath, + resolveTokensJsonPath, + type ImportOptions, +} from "./paths"; +export { fileFingerprint, dirFingerprint, snapshotOnce } from "./fingerprint"; +export { + runPhase1ImportOnce, + type ImportOnceReport, + type ImportOnceEntry, + type ImportOnceStatus, +} from "./import-once"; +export { + importElectronWorkspaces, + importElectronServerTokens, + importElectronServerState, + runDesktopImportOnce, + DESKTOP_SELECTED_WORKSPACE_PREF, + DESKTOP_WATCHED_WORKSPACE_PREF, + DESKTOP_PREFERRED_PORT_PREF, + type DesktopImportOptions, + type DesktopImportReport, + type DesktopImportEntry, + type DesktopImportStatus, +} from "./desktop"; + +/** + * Phase 1 import WITHOUT the one-time guard (always runs). Idempotent (upserts by + * natural key / dedupes by sourceId). Useful for tests; production should prefer + * `runPhase1ImportOnce`. + */ +export async function runPhase1Import( + db: DesktopDb, + options: ImportOptions = {}, +): Promise { + const report: ImportReport = {}; + + report.serverJson = await importServerJson( + db, + options.serverJsonPath ?? resolveServerJsonPath(), + ); + report.tokensJson = await importTokensJson( + db, + options.tokensJsonPath ?? resolveTokensJsonPath(), + ); + if (!options.skipAudit) { + report.audit = await importAuditDir(db, options.auditDir ?? resolveAuditDir()); + } + + return report; +} diff --git a/packages/desktop-db/src/import/paths.ts b/packages/desktop-db/src/import/paths.ts new file mode 100644 index 0000000000..5ffa7336b2 --- /dev/null +++ b/packages/desktop-db/src/import/paths.ts @@ -0,0 +1,35 @@ +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +/** Resolve the default `server.json` path (mirrors server `config.ts`). */ +export function resolveServerJsonPath(): string { + const override = process.env.OPENWORK_SERVER_CONFIG?.trim(); + if (override) return resolve(override); + return join(homedir(), ".config", "openwork", "server.json"); +} + +/** Resolve the default `tokens.json` path (mirrors server `tokens.ts`). */ +export function resolveTokensJsonPath(): string { + const override = process.env.OPENWORK_TOKEN_STORE?.trim(); + if (override) return resolve(override); + return join(dirname(resolveServerJsonPath()), "tokens.json"); +} + +/** Resolve the default audit dir (mirrors server `audit.ts`). */ +export function resolveAuditDir(): string { + const override = process.env.OPENWORK_DATA_DIR?.trim(); + const base = override + ? override.startsWith("~/") + ? join(homedir(), override.slice(2)) + : resolve(override) + : join(homedir(), ".openwork", "openwork-server"); + return join(base, "audit"); +} + +export interface ImportOptions { + serverJsonPath?: string; + tokensJsonPath?: string; + auditDir?: string; + /** Skip the audit import (can be large). Default false. */ + skipAudit?: boolean; +} diff --git a/packages/desktop-db/src/import/server-json.ts b/packages/desktop-db/src/import/server-json.ts new file mode 100644 index 0000000000..a1332cf592 --- /dev/null +++ b/packages/desktop-db/src/import/server-json.ts @@ -0,0 +1,171 @@ +import { resolve } from "node:path"; +import type { DesktopDb } from "../client"; +import { + authorizedRootTable, + serverConfigTable, + workspaceTable, +} from "../schema/index"; +import { createDesktopTypeId } from "../typeid"; +import { type ImportResult, readJsonFile } from "./helpers"; + +/** + * Import `server.json` into the DB: + * - scalar server settings -> server_config (key/value) + * - workspaces[] -> workspace table (id preserved, order preserved) + * - authorizedRoots[] -> authorized_root table (server-global, workspaceId = NULL) + * + * Idempotent: re-running upserts the same rows. + */ + +interface ServerJsonWorkspace { + id?: string; + path: string; + name?: string; + preset?: string; + workspaceType?: string; + remoteType?: string; + baseUrl?: string; + directory?: string; + displayName?: string; + openworkHostUrl?: string; + openworkToken?: string; + openworkWorkspaceId?: string; + openworkWorkspaceName?: string; + sandboxBackend?: string; + sandboxRunId?: string; + sandboxContainerName?: string; + opencodeUsername?: string; + opencodePassword?: string; +} + +interface ServerJson { + host?: string; + port?: number; + token?: string; + hostToken?: string; + approval?: { mode?: string; timeoutMs?: number }; + workspaces?: ServerJsonWorkspace[]; + corsOrigins?: string[]; + authorizedRoots?: string[]; + readOnly?: boolean; + opencodeBaseUrl?: string; + opencodeDirectory?: string; + opencodeUsername?: string; + opencodePassword?: string; + logFormat?: string; + logRequests?: boolean; +} + +const SERVER_CONFIG_KEYS: (keyof ServerJson)[] = [ + "host", + "port", + "token", + "hostToken", + "approval", + "corsOrigins", + "readOnly", + "opencodeBaseUrl", + "opencodeDirectory", + "opencodeUsername", + "opencodePassword", + "logFormat", + "logRequests", +]; + +export async function importServerJson(db: DesktopDb, path: string): Promise { + const parsed = await readJsonFile(path); + if (!parsed) return { count: 0, found: false }; + + const now = Date.now(); + let count = 0; + + db.transaction((tx) => { + // Scalar server settings -> server_config + for (const key of SERVER_CONFIG_KEYS) { + const value = parsed[key]; + if (value === undefined) continue; + tx.insert(serverConfigTable) + .values({ key, value, createdAt: now, updatedAt: now }) + .onConflictDoUpdate({ + target: serverConfigTable.key, + set: { value, updatedAt: now }, + }) + .run(); + count += 1; + } + + // Workspaces (preserve array order via sortOrder) + const workspaces = parsed.workspaces ?? []; + workspaces.forEach((ws, index) => { + const id = ws.id ?? `ws_${resolve(ws.path)}`; + tx.insert(workspaceTable) + .values({ + id, + path: ws.path, + name: ws.name ?? ws.path, + preset: ws.preset ?? null, + workspaceType: ws.workspaceType ?? "local", + remoteType: ws.remoteType ?? null, + baseUrl: ws.baseUrl ?? null, + directory: ws.directory ?? null, + displayName: ws.displayName ?? null, + openworkHostUrl: ws.openworkHostUrl ?? null, + openworkToken: ws.openworkToken ?? null, + openworkWorkspaceId: ws.openworkWorkspaceId ?? null, + openworkWorkspaceName: ws.openworkWorkspaceName ?? null, + sandboxBackend: ws.sandboxBackend ?? null, + sandboxRunId: ws.sandboxRunId ?? null, + sandboxContainerName: ws.sandboxContainerName ?? null, + opencodeUsername: ws.opencodeUsername ?? null, + opencodePassword: ws.opencodePassword ?? null, + sortOrder: index, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: workspaceTable.id, + set: { + path: ws.path, + name: ws.name ?? ws.path, + preset: ws.preset ?? null, + workspaceType: ws.workspaceType ?? "local", + remoteType: ws.remoteType ?? null, + baseUrl: ws.baseUrl ?? null, + directory: ws.directory ?? null, + displayName: ws.displayName ?? null, + openworkHostUrl: ws.openworkHostUrl ?? null, + openworkToken: ws.openworkToken ?? null, + openworkWorkspaceId: ws.openworkWorkspaceId ?? null, + openworkWorkspaceName: ws.openworkWorkspaceName ?? null, + sandboxBackend: ws.sandboxBackend ?? null, + sandboxRunId: ws.sandboxRunId ?? null, + sandboxContainerName: ws.sandboxContainerName ?? null, + opencodeUsername: ws.opencodeUsername ?? null, + opencodePassword: ws.opencodePassword ?? null, + sortOrder: index, + updatedAt: now, + }, + }) + .run(); + count += 1; + }); + + // Authorized roots (server-global) + for (const root of parsed.authorizedRoots ?? []) { + const resolved = resolve(root); + tx.insert(authorizedRootTable) + .values({ + id: createDesktopTypeId("authorizedRoot"), + workspaceId: null, + path: resolved, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing({ target: authorizedRootTable.path }) + .run(); + count += 1; + } + }); + + return { count, found: true }; +} diff --git a/packages/desktop-db/src/import/tokens-json.ts b/packages/desktop-db/src/import/tokens-json.ts new file mode 100644 index 0000000000..58dccbd6df --- /dev/null +++ b/packages/desktop-db/src/import/tokens-json.ts @@ -0,0 +1,61 @@ +import type { DesktopDb } from "../client"; +import { tokenTable } from "../schema/index"; +import { createDesktopTypeId } from "../typeid"; +import { type ImportResult, readJsonFile } from "./helpers"; + +/** + * Import `tokens.json` into the `token` table. + * + * Schema (tokens.ts): { schemaVersion, updatedAt, tokens: [{ id, hash, scope, + * createdAt, label? }] }. Only the SHA-256 `hash` is stored (raw tokens never persist). + * The original `id` (randomUUID) is mapped to a new TypeID row id; dedupe is by `hash`. + * + * Idempotent: re-running upserts by hash. + */ + +interface TokenRecord { + id?: string; + hash: string; + scope: string; + createdAt?: number; + label?: string; +} + +interface TokensJson { + schemaVersion?: number; + updatedAt?: number; + tokens?: TokenRecord[]; +} + +export async function importTokensJson(db: DesktopDb, path: string): Promise { + const parsed = await readJsonFile(path); + if (!parsed) return { count: 0, found: false }; + + const now = Date.now(); + let count = 0; + + db.transaction((tx) => { + for (const record of parsed.tokens ?? []) { + if (!record.hash || !record.scope) continue; + tx.insert(tokenTable) + .values({ + id: createDesktopTypeId("token"), + hash: record.hash, + scope: record.scope, + label: record.label ?? null, + createdAt: record.createdAt ?? now, + }) + .onConflictDoUpdate({ + target: tokenTable.hash, + set: { + scope: record.scope, + label: record.label ?? null, + }, + }) + .run(); + count += 1; + } + }); + + return { count, found: true }; +} diff --git a/packages/desktop-db/src/index.ts b/packages/desktop-db/src/index.ts new file mode 100644 index 0000000000..534afe2d15 --- /dev/null +++ b/packages/desktop-db/src/index.ts @@ -0,0 +1,50 @@ +export * from "./typeid"; +export * from "./columns"; +export * from "./schema/index"; +export { + openDb, + closeDb, + resolveDefaultDbPath, + resolveDbPathForServerConfig, + schema, + type DesktopDb, + type OpenDbOptions, +} from "./client"; +export * as drizzle from "./drizzle"; +export { + MIRRORED_PREFERENCE_KEYS, + MIRRORED_PREFERENCE_PREFIXES, + isMirroredPreferenceKey, + getPreference, + getAllMirroredPreferences, + setPreference, + removePreference, + removePreferences, +} from "./preferences"; +export { + runPhase1Import, + runPhase1ImportOnce, + importServerJson, + importTokensJson, + importAuditDir, + resolveServerJsonPath, + resolveTokensJsonPath, + resolveAuditDir, + type ImportOptions, + type ImportReport, + type ImportResult, + type ImportOnceReport, + type ImportOnceEntry, + type ImportOnceStatus, + importElectronWorkspaces, + importElectronServerTokens, + importElectronServerState, + runDesktopImportOnce, + DESKTOP_SELECTED_WORKSPACE_PREF, + DESKTOP_WATCHED_WORKSPACE_PREF, + DESKTOP_PREFERRED_PORT_PREF, + type DesktopImportOptions, + type DesktopImportReport, + type DesktopImportEntry, + type DesktopImportStatus, +} from "./import/index"; diff --git a/packages/desktop-db/src/preferences.test.ts b/packages/desktop-db/src/preferences.test.ts new file mode 100644 index 0000000000..3844a9e7f5 --- /dev/null +++ b/packages/desktop-db/src/preferences.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { closeDb, openDb } from "./client"; +import { + getAllMirroredPreferences, + getPreference, + isMirroredPreferenceKey, + removePreference, + setPreference, +} from "./preferences"; + +let tmp: string | null = null; + +afterEach(() => { + closeDb(); + if (tmp) { + rmSync(tmp, { recursive: true, force: true }); + tmp = null; + } +}); + +async function freshDb() { + tmp = mkdtempSync(join(tmpdir(), "owpref-")); + return openDb({ path: join(tmp, "test.db") }); +} + +describe("preference mirror", () => { + test("classifies mirrored vs ephemeral keys", () => { + expect(isMirroredPreferenceKey("openwork.defaultModel")).toBe(true); + expect(isMirroredPreferenceKey("openwork.sessionModels.ws_a")).toBe(true); + expect(isMirroredPreferenceKey("openwork.extension.enabled.foo")).toBe(true); + expect(isMirroredPreferenceKey("openwork.modelVariant.ws_a")).toBe(true); + // ephemeral / not tracked + expect(isMirroredPreferenceKey("openwork:session-scroll:v1")).toBe(false); + expect(isMirroredPreferenceKey("openwork.server.token")).toBe(false); + expect(isMirroredPreferenceKey("openwork.ui")).toBe(false); + expect(isMirroredPreferenceKey("openwork.debug.profiler")).toBe(false); + }); + + test("set/get/remove round-trip and getAll filters to mirrored keys", async () => { + const db = await freshDb(); + await setPreference(db, "openwork.defaultModel", "anthropic/claude"); + await setPreference(db, "openwork.extension.enabled.foo", "1"); + // store a non-mirrored key directly via setPreference (raw store) — getAll must exclude it + await setPreference(db, "openwork:session-scroll:v1", "x"); + + expect(await getPreference(db, "openwork.defaultModel")).toBe("anthropic/claude"); + + const all = await getAllMirroredPreferences(db); + expect(all["openwork.defaultModel"]).toBe("anthropic/claude"); + expect(all["openwork.extension.enabled.foo"]).toBe("1"); + expect("openwork:session-scroll:v1" in all).toBe(false); + + await removePreference(db, "openwork.defaultModel"); + expect(await getPreference(db, "openwork.defaultModel")).toBeNull(); + }); +}); diff --git a/packages/desktop-db/src/preferences.ts b/packages/desktop-db/src/preferences.ts new file mode 100644 index 0000000000..74d3f0b352 --- /dev/null +++ b/packages/desktop-db/src/preferences.ts @@ -0,0 +1,86 @@ +import { eq, inArray } from "drizzle-orm"; +import type { DesktopDb } from "./client"; +import { preferenceTable } from "./schema/index"; + +/** + * Renderer preference keys that the desktop mirrors into the DB `preference` table + * (write-through + boot hydration). These are DEVICE-DURABLE or account/workspace + * "real state" keys — NOT purely-ephemeral UI (scroll, sidebar widths, debug toggles), + * which stay in localStorage only. + * + * Exact keys are mirrored as-is; prefixes mirror any key starting with the prefix + * (e.g. per-extension flags, per-workspace session models). + * + * The stored value is the raw localStorage string (so the renderer can hydrate + * localStorage verbatim without reinterpreting types). + */ +export const MIRRORED_PREFERENCE_KEYS: readonly string[] = [ + // DEVICE-DURABLE: connection topology/policy + UX ordering + drafts + shell config + "openwork.server.list", + "openwork.server.active", + "openwork.server.remoteAccessEnabled", + "openwork.react.workspaceOrder", + "openwork.session-drafts.v1", + "openwork.shell-config", + // SERVER-group "real state" (account/workspace scoped) — stored locally for now + "openwork.preferences", + "openwork.defaultModel", + "openwork.hiddenModels", + "openwork.skills.hubRepos.v1", + "openwork.acknowledgedProviders", + "openwork.seenProviderIds", + "openwork.orgOnboardingSeen", +] as const; + +export const MIRRORED_PREFERENCE_PREFIXES: readonly string[] = [ + // per-workspace model / variant overrides: openwork.sessionModels., openwork.modelVariant. + "openwork.sessionModels", + "openwork.modelVariant", + // per-extension flags: openwork.extension.enabled/disabled/hidden. + "openwork.extension.", +] as const; + +/** Whether a localStorage key should be mirrored into the DB preference table. */ +export function isMirroredPreferenceKey(key: string): boolean { + if (MIRRORED_PREFERENCE_KEYS.includes(key)) return true; + return MIRRORED_PREFERENCE_PREFIXES.some((prefix) => key.startsWith(prefix)); +} + +/** Read a single preference value (raw string), or null. */ +export async function getPreference(db: DesktopDb, key: string): Promise { + const rows = await db.select().from(preferenceTable).where(eq(preferenceTable.key, key)); + const value = rows[0]?.value; + return typeof value === "string" ? value : value == null ? null : JSON.stringify(value); +} + +/** Read all mirrored preferences as a `key -> rawString` map (for boot hydration). */ +export async function getAllMirroredPreferences(db: DesktopDb): Promise> { + const rows = await db.select().from(preferenceTable); + const out: Record = {}; + for (const row of rows) { + if (!isMirroredPreferenceKey(row.key)) continue; + out[row.key] = typeof row.value === "string" ? row.value : JSON.stringify(row.value); + } + return out; +} + +/** Upsert a single preference (raw string value). */ +export async function setPreference(db: DesktopDb, key: string, value: string): Promise { + const now = Date.now(); + await db + .insert(preferenceTable) + .values({ key, value, createdAt: now, updatedAt: now }) + .onConflictDoUpdate({ target: preferenceTable.key, set: { value, updatedAt: now } }) + .run(); +} + +/** Remove a preference. */ +export async function removePreference(db: DesktopDb, key: string): Promise { + await db.delete(preferenceTable).where(eq(preferenceTable.key, key)).run(); +} + +/** Remove several preferences. */ +export async function removePreferences(db: DesktopDb, keys: string[]): Promise { + if (keys.length === 0) return; + await db.delete(preferenceTable).where(inArray(preferenceTable.key, keys)).run(); +} diff --git a/packages/desktop-db/src/schema/audit.ts b/packages/desktop-db/src/schema/audit.ts new file mode 100644 index 0000000000..0390fe44d9 --- /dev/null +++ b/packages/desktop-db/src/schema/audit.ts @@ -0,0 +1,36 @@ +import { index, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { epochMs, jsonColumn, typeIdColumn } from "../columns"; + +/** + * Audit log — replaces the append-only `~/.openwork/openwork-server/audit/.jsonl`. + * + * One row per audit entry (was one JSONL line). The `actor` is stored as JSON + * (`{ type, clientId?, tokenHash?, scope? }`). No retention policy today; consider + * adding pruning now that it's a table. + */ +export const auditTable = sqliteTable( + "audit", + { + /** Original randomUUID id is preserved as `sourceId`; row uses a TypeID. */ + id: typeIdColumn("audit", "id").primaryKey(), + sourceId: text("source_id"), + /** Empty string for the legacy workspace-relative log (no workspace id). */ + workspaceId: text("workspace_id").notNull().default(""), + actor: jsonColumn<{ + type: "remote" | "host"; + clientId?: string; + tokenHash?: string; + scope?: "owner" | "collaborator" | "viewer"; + }>("actor").notNull(), + action: text("action").notNull(), + target: text("target").notNull(), + summary: text("summary").notNull(), + timestamp: epochMs("timestamp").notNull(), + }, + (table) => [ + index("audit_workspace_timestamp_idx").on(table.workspaceId, table.timestamp), + ], +); + +export type AuditRow = typeof auditTable.$inferSelect; +export type AuditInsert = typeof auditTable.$inferInsert; diff --git a/packages/desktop-db/src/schema/env.ts b/packages/desktop-db/src/schema/env.ts new file mode 100644 index 0000000000..9539c8501d --- /dev/null +++ b/packages/desktop-db/src/schema/env.ts @@ -0,0 +1,26 @@ +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { epochMs, secretText } from "../columns"; + +/** + * User-level environment variables — replaces `env.json`. + * + * Scope: user/machine (NOT per-workspace, NOT per-session). Values are service + * credentials (e.g. ANTHROPIC_API_KEY) stored in plaintext today (0o600 file). See + * plan.md open question on at-rest encryption AND the cross-shell read concern + * (the Rust/Node shells read env.json independently before the server starts). + * + * `schemaVersion` is tracked in server_config (`env.schemaVersion`). + */ +export const envVarTable = sqliteTable( + "env_var", + { + /** POSIX env name: ^[A-Za-z_][A-Za-z0-9_]*$. Reserved OPENWORK_/OPENCODE_ refused. */ + key: text("key").primaryKey(), + value: secretText("value").notNull(), + updatedAt: epochMs("updated_at").notNull(), + }, + (table) => [uniqueIndex("env_var_key_unique").on(table.key)], +); + +export type EnvVarRow = typeof envVarTable.$inferSelect; +export type EnvVarInsert = typeof envVarTable.$inferInsert; diff --git a/packages/desktop-db/src/schema/extensions.ts b/packages/desktop-db/src/schema/extensions.ts new file mode 100644 index 0000000000..8ef8a70506 --- /dev/null +++ b/packages/desktop-db/src/schema/extensions.ts @@ -0,0 +1,48 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { jsonColumn, secretText, timestamps, typeIdColumn } from "../columns"; + +/** + * Google Workspace OAuth vault — replaces `extensions/google-workspace/oauth.vault` + * (+ dev plaintext file). The decrypted record is `{ version, account, scopes, token: + * { accessToken, refreshToken, expiresAt }, connectedAt, updatedAt }`. + * + * `data` holds the encrypted envelope (AES-256-GCM) or, in dev plaintext mode, the raw + * record JSON. The `vault-key` file concept can be replaced by a server_config row or a + * DB-level encryption strategy (see plan.md). + */ +export const googleWorkspaceVaultTable = sqliteTable("google_workspace_vault", { + /** Google account `sub`, or a fixed singleton key (only one connection today). */ + id: typeIdColumn("googleWorkspaceVault", "id").primaryKey(), + accountSub: text("account_sub"), + /** Encrypted envelope JSON (or plaintext record in dev mode). */ + data: secretText("data").notNull(), + encrypted: integer("encrypted", { mode: "boolean" }).notNull().default(true), + ...timestamps, +}); + +/** + * Extension enable/disable/hidden flags — replaces the renderer localStorage keys + * `openwork.extension.{enabled,disabled,hidden}.`. + */ +export const extensionStateTable = sqliteTable("extension_state", { + extensionId: typeIdColumn("extensionState", "extension_id").primaryKey(), + enabled: integer("enabled", { mode: "boolean" }), + hidden: integer("hidden", { mode: "boolean" }), + ...timestamps, +}); + +/** + * Generic preferences — replaces the high-priority renderer `localStorage` keys + * (server URLs/tokens, model prefs, drafts, onboarding flags, shell-config, etc.). + * Key/value JSON so the frontend can migrate keys incrementally via `invokeDesktop`. + */ +export const preferenceTable = sqliteTable("preference", { + key: text("key").primaryKey(), + value: jsonColumn("value").notNull(), + ...timestamps, +}); + +export type GoogleWorkspaceVaultRow = typeof googleWorkspaceVaultTable.$inferSelect; +export type ExtensionStateRow = typeof extensionStateTable.$inferSelect; +export type PreferenceRow = typeof preferenceTable.$inferSelect; +export type PreferenceInsert = typeof preferenceTable.$inferInsert; diff --git a/packages/desktop-db/src/schema/index.ts b/packages/desktop-db/src/schema/index.ts new file mode 100644 index 0000000000..134d7ea862 --- /dev/null +++ b/packages/desktop-db/src/schema/index.ts @@ -0,0 +1,9 @@ +export * from "./workspaces"; +export * from "./tokens"; +export * from "./server-config"; +export * from "./env"; +export * from "./audit"; +export * from "./sessions"; +export * from "./opencode-config"; +export * from "./extensions"; +export * from "./migration-state"; diff --git a/packages/desktop-db/src/schema/migration-state.ts b/packages/desktop-db/src/schema/migration-state.ts new file mode 100644 index 0000000000..dac8a3e7d0 --- /dev/null +++ b/packages/desktop-db/src/schema/migration-state.ts @@ -0,0 +1,28 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { epochMs } from "../columns"; + +/** + * Tracks one-time file -> DB imports so we never re-import on every start. + * + * Keyed by `source` (e.g. "server.json", "tokens.json", "audit"). The `fingerprint` + * is ":" of the source file (or a directory digest for audit); if the + * source file changes after import, the importer can decide to re-run. + * + * Source files are NEVER deleted; on first successful import a `.pre-db.bak` + * snapshot is written (recorded in `backupPath`) so the migration can be reverted. + */ +export const migrationStateTable = sqliteTable("migration_state", { + source: text("source").primaryKey(), + /** "imported" | "skipped" | "error" */ + status: text("status").notNull(), + /** ":" of the imported source, or "" if not found. */ + fingerprint: text("fingerprint").notNull().default(""), + /** Number of rows imported. */ + rowCount: integer("row_count").notNull().default(0), + /** Path to the one-time .pre-db.bak snapshot, if any. */ + backupPath: text("backup_path"), + importedAt: epochMs("imported_at").notNull(), +}); + +export type MigrationStateRow = typeof migrationStateTable.$inferSelect; +export type MigrationStateInsert = typeof migrationStateTable.$inferInsert; diff --git a/packages/desktop-db/src/schema/opencode-config.ts b/packages/desktop-db/src/schema/opencode-config.ts new file mode 100644 index 0000000000..2a1aa8ab0c --- /dev/null +++ b/packages/desktop-db/src/schema/opencode-config.ts @@ -0,0 +1,83 @@ +import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { jsonColumn, timestamps, typeIdColumn } from "../columns"; + +/** + * OpenCode config projection — the keys OpenWork currently writes into + * `opencode.json[c]` (`default_agent`, `plugin`, `mcp`, `provider`, + * `permission.external_directory`). DB becomes the source of truth; the server projects + * these into OpenCode via `OPENCODE_CONFIG_CONTENT` (managed) and/or a generated config + * file (external/portable). See research doc 08. + * + * Generic key/value bag per (workspace, scope). `scope` = "project" | "global"; a NULL + * workspaceId is used for global scope. + */ +export const opencodeConfigTable = sqliteTable( + "opencode_config", + { + id: typeIdColumn("opencodeConfig", "id").primaryKey(), + /** NULL for global scope. */ + workspaceId: text("workspace_id"), + /** "project" | "global" */ + scope: text("scope").notNull().default("project"), + /** e.g. "default_agent", "plugin", "provider", "permission.external_directory" */ + key: text("key").notNull(), + value: jsonColumn("value").notNull(), + ...timestamps, + }, + (table) => [ + uniqueIndex("opencode_config_ws_scope_key_unique").on( + table.workspaceId, + table.scope, + table.key, + ), + ], +); + +/** + * MCP servers — replaces the `mcp` key inside `opencode.json[c]`. Modeled explicitly + * (rather than a JSON blob) since it's a first-class concept. Inline auth (headers/key) + * is kept in `config` JSON, mirroring today's verbatim storage. + */ +export const mcpServerTable = sqliteTable( + "mcp_server", + { + id: typeIdColumn("mcpServer", "id").primaryKey(), + /** NULL for global-scope MCP (read-only merge today; OpenWork only writes project). */ + workspaceId: text("workspace_id"), + scope: text("scope").notNull().default("project"), + name: text("name").notNull(), + /** "local" | "remote" */ + type: text("type").notNull(), + enabled: integer("enabled", { mode: "boolean" }), + /** Full entry config verbatim: command[] (local) / url + headers/key (remote). */ + config: jsonColumn>("config").notNull(), + ...timestamps, + }, + (table) => [uniqueIndex("mcp_server_ws_name_unique").on(table.workspaceId, table.name)], +); + +/** + * Plugin registrations — replaces the `plugin` array in `opencode.json[c]`. Ordering is + * preserved via `sortOrder`. + */ +export const pluginEntryTable = sqliteTable( + "plugin_entry", + { + id: typeIdColumn("pluginEntry", "id").primaryKey(), + workspaceId: text("workspace_id"), + scope: text("scope").notNull().default("project"), + /** The plugin spec string (npm name, file:, http(s):, git:, absolute). */ + spec: text("spec").notNull(), + sortOrder: integer("sort_order").notNull().default(0), + ...timestamps, + }, + (table) => [ + uniqueIndex("plugin_entry_ws_spec_unique").on(table.workspaceId, table.spec), + index("plugin_entry_sort_order_idx").on(table.sortOrder), + ], +); + +export type OpencodeConfigRow = typeof opencodeConfigTable.$inferSelect; +export type McpServerRow = typeof mcpServerTable.$inferSelect; +export type McpServerInsert = typeof mcpServerTable.$inferInsert; +export type PluginEntryRow = typeof pluginEntryTable.$inferSelect; diff --git a/packages/desktop-db/src/schema/server-config.ts b/packages/desktop-db/src/schema/server-config.ts new file mode 100644 index 0000000000..1f6edc7340 --- /dev/null +++ b/packages/desktop-db/src/schema/server-config.ts @@ -0,0 +1,25 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { jsonColumn, timestamps } from "../columns"; + +/** + * Server-level config — replaces the scalar keys of `server.json` (everything except + * `workspaces[]` and `authorizedRoots[]`, which have their own tables). + * + * Stored as a key/value table so we can add/remove settings without migrations and + * preserve the "unknown keys are preserved" behavior of the old merge-on-write. + * + * Known keys: host, port, token, hostToken, approval, corsOrigins, readOnly, + * opencodeBaseUrl, opencodeDirectory, opencodeUsername, opencodePassword, logFormat, + * logRequests, preferredPort. Values are JSON-encoded. + * + * NOTE: `token`/`hostToken`/`opencodePassword` are plaintext secrets here, exactly as + * they are in `server.json` today. + */ +export const serverConfigTable = sqliteTable("server_config", { + key: text("key").primaryKey(), + value: jsonColumn("value").notNull(), + ...timestamps, +}); + +export type ServerConfigRow = typeof serverConfigTable.$inferSelect; +export type ServerConfigInsert = typeof serverConfigTable.$inferInsert; diff --git a/packages/desktop-db/src/schema/sessions.ts b/packages/desktop-db/src/schema/sessions.ts new file mode 100644 index 0000000000..a6f925a68a --- /dev/null +++ b/packages/desktop-db/src/schema/sessions.ts @@ -0,0 +1,69 @@ +import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { epochMs, timestamps, typeIdColumn } from "../columns"; + +/** + * File sessions — currently IN-MEMORY only (`FileSessionStore`). Persisting them to the + * DB changes restart semantics (today they're TTL-evicted). Included so the option + * exists; the server can choose to keep them in-memory and ignore these tables. + */ +export const fileSessionTable = sqliteTable( + "file_session", + { + id: typeIdColumn("fileSession", "id").primaryKey(), + workspaceId: text("workspace_id").notNull(), + workspaceRoot: text("workspace_root").notNull(), + actorTokenHash: text("actor_token_hash").notNull(), + /** "owner" | "collaborator" | "viewer" */ + actorScope: text("actor_scope").notNull(), + canWrite: integer("can_write", { mode: "boolean" }).notNull().default(false), + createdAt: epochMs("created_at").notNull(), + expiresAt: epochMs("expires_at").notNull(), + }, + (table) => [index("file_session_workspace_idx").on(table.workspaceId)], +); + +export const fileSessionEventTable = sqliteTable( + "file_session_event", + { + id: typeIdColumn("fileSessionEvent", "id").primaryKey(), + /** Monotonic per-workspace sequence. */ + seq: integer("seq").notNull(), + workspaceId: text("workspace_id").notNull(), + /** "write" | "delete" | "rename" | "mkdir" */ + type: text("type").notNull(), + path: text("path").notNull(), + toPath: text("to_path"), + /** "mtimeMs:size" fingerprint. */ + revision: text("revision"), + timestamp: epochMs("timestamp").notNull(), + }, + (table) => [ + uniqueIndex("file_session_event_ws_seq_unique").on(table.workspaceId, table.seq), + ], +); + +/** + * Per-session preferences — GREENFIELD. None exist today. Key/value per (session, + * workspace) so we can expand per-session model/agent/UI prefs without migrations. + */ +export const sessionPrefTable = sqliteTable( + "session_pref", + { + id: typeIdColumn("sessionPref", "id").primaryKey(), + /** OpenCode session id (e.g. "ses_..."). */ + sessionId: text("session_id").notNull(), + workspaceId: text("workspace_id").notNull(), + key: text("key").notNull(), + value: text("value"), + ...timestamps, + }, + (table) => [ + uniqueIndex("session_pref_session_key_unique").on(table.sessionId, table.key), + index("session_pref_workspace_idx").on(table.workspaceId), + ], +); + +export type FileSessionRow = typeof fileSessionTable.$inferSelect; +export type FileSessionEventRow = typeof fileSessionEventTable.$inferSelect; +export type SessionPrefRow = typeof sessionPrefTable.$inferSelect; +export type SessionPrefInsert = typeof sessionPrefTable.$inferInsert; diff --git a/packages/desktop-db/src/schema/tokens.ts b/packages/desktop-db/src/schema/tokens.ts new file mode 100644 index 0000000000..793711dacd --- /dev/null +++ b/packages/desktop-db/src/schema/tokens.ts @@ -0,0 +1,53 @@ +import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { epochMs, secretText, timestamps, typeIdColumn } from "../columns"; + +/** + * Scoped API tokens — replaces `tokens.json`. + * + * Raw tokens are NEVER stored; only their SHA-256 hash (matching `tokens.ts`). The + * built-in `config.token`/`hostToken` live in server_config (plaintext, as today). + */ +export const tokenTable = sqliteTable( + "token", + { + id: typeIdColumn("token", "id").primaryKey(), + /** sha256(token) — the raw token is returned once at creation, never persisted. */ + hash: text("hash").notNull(), + /** "owner" | "collaborator" | "viewer" */ + scope: text("scope").notNull(), + label: text("label"), + createdAt: epochMs("created_at").notNull(), + }, + (table) => [uniqueIndex("token_hash_unique").on(table.hash)], +); + +/** + * Per-workspace server tokens — replaces the Electron `openwork-server-tokens.json`. + * `workspaceKey` is the lowercased resolved POSIX path used by the Electron shell. + */ +export const workspaceServerTokenTable = sqliteTable("workspace_server_token", { + workspaceKey: text("workspace_key").primaryKey(), + clientToken: secretText("client_token"), + hostToken: secretText("host_token"), + ownerToken: secretText("owner_token"), + ...timestamps, +}); + +/** + * Preferred ports per workspace — replaces the Electron `openwork-server-state.json` + * `workspacePorts` map. The global `preferredPort` lives in server_config. + */ +export const workspacePortTable = sqliteTable( + "workspace_port", + { + workspaceKey: text("workspace_key").primaryKey(), + port: integer("port").notNull(), + ...timestamps, + }, + (table) => [index("workspace_port_port_idx").on(table.port)], +); + +export type TokenRow = typeof tokenTable.$inferSelect; +export type TokenInsert = typeof tokenTable.$inferInsert; +export type WorkspaceServerTokenRow = typeof workspaceServerTokenTable.$inferSelect; +export type WorkspacePortRow = typeof workspacePortTable.$inferSelect; diff --git a/packages/desktop-db/src/schema/workspaces.ts b/packages/desktop-db/src/schema/workspaces.ts new file mode 100644 index 0000000000..30b270f2f8 --- /dev/null +++ b/packages/desktop-db/src/schema/workspaces.ts @@ -0,0 +1,136 @@ +import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { epochMs, jsonColumn, secretText, timestamps, typeIdColumn } from "../columns"; + +/** + * Workspaces — replaces `server.json` `workspaces[]` registry AND the Electron + * `openwork-workspaces.json` list (these duplicate each other today). + * + * The workspace `id` is the deterministic `ws_` identifier from the server + * (`workspaces.ts`). We keep it as the primary key but store it as plain text (NOT a + * TypeID) because it is content-derived and must stay stable across the migration. + */ +export const workspaceTable = sqliteTable( + "workspace", + { + /** Deterministic ws_ id (server `workspaceIdForKey`). Stable, content-derived. */ + id: text("id").primaryKey(), + path: text("path").notNull(), + name: text("name").notNull(), + preset: text("preset"), + /** "local" | "remote" */ + workspaceType: text("workspace_type").notNull().default("local"), + /** "opencode" | "openwork" */ + remoteType: text("remote_type"), + baseUrl: text("base_url"), + directory: text("directory"), + displayName: text("display_name"), + openworkHostUrl: text("openwork_host_url"), + openworkToken: secretText("openwork_token"), + /** Per-remote-workspace client/host tokens (desktop `openwork-workspaces.json`). */ + openworkClientToken: secretText("openwork_client_token"), + openworkHostToken: secretText("openwork_host_token"), + openworkWorkspaceId: text("openwork_workspace_id"), + openworkWorkspaceName: text("openwork_workspace_name"), + sandboxBackend: text("sandbox_backend"), + sandboxRunId: text("sandbox_run_id"), + sandboxContainerName: text("sandbox_container_name"), + opencodeUsername: text("opencode_username"), + opencodePassword: secretText("opencode_password"), + /** Active-workspace ordering. Lower = earlier; index 0 = active workspace. */ + sortOrder: integer("sort_order").notNull().default(0), + ...timestamps, + }, + (table) => [ + uniqueIndex("workspace_path_unique").on(table.path), + index("workspace_sort_order_idx").on(table.sortOrder), + ], +); + +/** + * Authorized roots — replaces `server.json` `authorizedRoots[]`. Server-global today; + * we key by workspace for forward flexibility, with a NULL workspace meaning global. + */ +export const authorizedRootTable = sqliteTable( + "authorized_root", + { + id: typeIdColumn("authorizedRoot", "id").primaryKey(), + /** NULL = server-global authorized root. */ + workspaceId: text("workspace_id").references(() => workspaceTable.id, { + onDelete: "cascade", + }), + path: text("path").notNull(), + ...timestamps, + }, + (table) => [uniqueIndex("authorized_root_path_unique").on(table.path)], +); + +/** + * Per-workspace OpenWork metadata — replaces the non-opencode sections of + * `/.opencode/openwork.json` (`version`, `workspace.{name,createdAt,preset}`). + */ +export const workspaceMetaTable = sqliteTable("workspace_meta", { + workspaceId: text("workspace_id") + .primaryKey() + .references(() => workspaceTable.id, { onDelete: "cascade" }), + version: integer("version").notNull().default(1), + workspaceName: text("workspace_name"), + preset: text("preset"), + ...timestamps, +}); + +/** + * Blueprint session materialization mapping — replaces + * `openwork.json` `blueprint.materialized.sessions`. + * + * NOTE: this is intentionally export-sanitized today (session ids are stripped on + * workspace export). Preserve that behavior when projecting back out. + */ +export const blueprintSessionTable = sqliteTable( + "blueprint_session", + { + id: typeIdColumn("blueprintSession", "id").primaryKey(), + workspaceId: text("workspace_id") + .notNull() + .references(() => workspaceTable.id, { onDelete: "cascade" }), + templateId: text("template_id").notNull(), + /** Real OpenCode session id (e.g. "ses_..."). Machine-specific. */ + sessionId: text("session_id").notNull(), + hydratedAt: epochMs("hydrated_at").notNull(), + ...timestamps, + }, + (table) => [ + uniqueIndex("blueprint_session_ws_template_unique").on(table.workspaceId, table.templateId), + ], +); + +/** + * Desktop cloud sync state — replaces the `desktopCloudSync` key inside `openwork.json`. + * The full entry (pendingChanges, snapshot, teamIds, etc.) is stored as JSON; key by + * workspace + contextKey ("::"). + */ +export const desktopCloudSyncTable = sqliteTable( + "desktop_cloud_sync", + { + id: typeIdColumn("desktopCloudSync", "id").primaryKey(), + workspaceId: text("workspace_id") + .notNull() + .references(() => workspaceTable.id, { onDelete: "cascade" }), + contextKey: text("context_key").notNull(), + organizationId: text("organization_id").notNull(), + orgMemberId: text("org_member_id").notNull(), + /** Full DesktopCloudSyncEntry JSON. */ + data: jsonColumn("data").notNull(), + fetchedAt: epochMs("fetched_at"), + ...timestamps, + }, + (table) => [ + uniqueIndex("desktop_cloud_sync_ws_context_unique").on(table.workspaceId, table.contextKey), + ], +); + +export type WorkspaceRow = typeof workspaceTable.$inferSelect; +export type WorkspaceInsert = typeof workspaceTable.$inferInsert; +export type AuthorizedRootRow = typeof authorizedRootTable.$inferSelect; +export type WorkspaceMetaRow = typeof workspaceMetaTable.$inferSelect; +export type BlueprintSessionRow = typeof blueprintSessionTable.$inferSelect; +export type DesktopCloudSyncRow = typeof desktopCloudSyncTable.$inferSelect; diff --git a/packages/desktop-db/src/typeid.ts b/packages/desktop-db/src/typeid.ts new file mode 100644 index 0000000000..1a1b391ec3 --- /dev/null +++ b/packages/desktop-db/src/typeid.ts @@ -0,0 +1,222 @@ +import { TypeID, typeid } from "typeid-js"; +import { v7 as uuidv7 } from "uuid"; +import { z } from "zod"; + +/** + * TypeID registry for OpenWork desktop-local state (SQLite DB on the user's machine). + * + * This is intentionally SEPARATE from the cloud registry in + * `ee/packages/utils/src/typeid.ts`. That one is for cloud/Den resources; this one is + * for desktop-app state (workspaces, tokens, env vars, sessions, audit, etc.) and is + * shared across the server, electron, and frontend. + * + * TypeID prefixes are persisted in DB rows: APPEND new entries, never change existing + * values. + */ + +export const TYPE_ID_SUFFIX_LENGTH = 26; + +const BASE32_REGEX = /^[0-9a-hjkmnp-tv-z]+$/; + +export const idTypesMapNameToPrefix = { + // Workspaces & per-workspace metadata + workspace: "ws", + authorizedRoot: "aroot", + workspaceMeta: "wsmeta", + blueprintSession: "bps", + desktopCloudSync: "dcs", + cloudImport: "cimp", + + // Tokens & server identity + token: "owt", + workspaceServerToken: "wst", + workspacePort: "wport", + + // Environment variables + envVar: "env", + + // Audit + audit: "aud", + + // File sessions (file-sync API) + fileSession: "fses", + fileSessionEvent: "fsev", + + // Per-session preferences (greenfield) + sessionPref: "spref", + + // OpenCode config projection (replaces opencode.json keys) + opencodeConfig: "occ", + mcpServer: "mcp", + pluginEntry: "plg", + + // Extensions + googleWorkspaceVault: "gwv", + extensionState: "ext", + + // Generic preferences (renderer localStorage migration) + preference: "pref", +} as const; + +type IdTypesMapNameToPrefix = typeof idTypesMapNameToPrefix; +type IdTypesMapPrefixToName = { + [K in keyof IdTypesMapNameToPrefix as IdTypesMapNameToPrefix[K]]: K; +}; + +const idTypesMapPrefixToName = Object.fromEntries( + Object.entries(idTypesMapNameToPrefix).map(([name, prefix]) => [prefix, name]), +) as IdTypesMapPrefixToName; + +export type IdTypePrefixNames = keyof typeof idTypesMapNameToPrefix; +export type TypeId = `${IdTypesMapNameToPrefix[T]}_${string}`; + +type TypeIdSchema = z.ZodType, string>; + +const schemaCache = new Map>(); + +const buildTypeIdSchema = (prefix: T): TypeIdSchema => { + const expectedPrefix = idTypesMapNameToPrefix[prefix]; + const expectedLength = TYPE_ID_SUFFIX_LENGTH + expectedPrefix.length + 1; + + return z + .string() + .length(expectedLength, { + message: `TypeID must be ${expectedLength} characters (${expectedPrefix}_<26 char suffix>)`, + }) + .startsWith(`${expectedPrefix}_`, { + message: `TypeID must start with '${expectedPrefix}_'`, + }) + .refine( + (input) => { + const suffix = input.slice(expectedPrefix.length + 1); + return BASE32_REGEX.test(suffix); + }, + { message: "TypeID suffix contains invalid base32 characters" }, + ) + .refine( + (input) => { + try { + TypeID.fromString(input); + return true; + } catch { + return false; + } + }, + { message: "TypeID is structurally invalid" }, + ) + .transform((input) => TypeID.fromString(input).toString() as TypeId); +}; + +const typeIdZodSchema = (prefix: T): TypeIdSchema => { + let schema = schemaCache.get(prefix); + if (!schema) { + schema = buildTypeIdSchema(prefix); + schemaCache.set(prefix, schema); + } + return schema as TypeIdSchema; +}; + +const typeIdGenerator = (prefix: T) => + typeid(idTypesMapNameToPrefix[prefix]).toString() as TypeId; + +const validateTypeId = ( + prefix: T, + data: unknown, +): data is TypeId => typeIdZodSchema(prefix).safeParse(data).success; + +const inferTypeId = ( + input: `${T}_${string}`, +): IdTypesMapPrefixToName[T] => { + const parsed = TypeID.fromString(input); + const prefix = parsed.getType() as T; + const typeName = idTypesMapPrefixToName[prefix]; + + if (typeName === undefined) { + throw new Error( + `Unknown TypeID prefix '${prefix}'. Registered prefixes: ${Object.keys(idTypesMapPrefixToName).join(", ")}`, + ); + } + + return typeName; +}; + +const typeIdFromString = ( + typeName: T, + input: string, +): TypeId => { + const parsed = TypeID.fromString(input); + const expectedPrefix = idTypesMapNameToPrefix[typeName]; + const actualPrefix = parsed.getType(); + + if (actualPrefix !== expectedPrefix) { + throw new Error( + `TypeID prefix mismatch: expected '${expectedPrefix}' but got '${actualPrefix}'`, + ); + } + + return parsed.toString() as TypeId; +}; + +const typeIdWithTimestamp = ( + typeName: T, + timestamp?: Date | number, +): TypeId => { + let msecs: number; + + if (timestamp === undefined) { + msecs = Date.now(); + } else if (timestamp instanceof Date) { + msecs = timestamp.getTime(); + } else { + msecs = timestamp; + } + + if (!Number.isFinite(msecs)) { + throw new Error(`Invalid timestamp: expected finite number, got ${msecs}`); + } + if (msecs < 0) { + throw new Error(`Invalid timestamp: expected non-negative number, got ${msecs}`); + } + + const uuid = uuidv7({ msecs }); + const prefix = idTypesMapNameToPrefix[typeName]; + return TypeID.fromUUID(prefix, uuid).toString() as TypeId; +}; + +const getColumnLength = (typeName: T) => + idTypesMapNameToPrefix[typeName].length + 1 + TYPE_ID_SUFFIX_LENGTH; + +export const typeId = { + schema: typeIdZodSchema, + generator: typeIdGenerator, + generatorWithTimestamp: typeIdWithTimestamp, + validator: validateTypeId, + infer: inferTypeId, + fromString: typeIdFromString, + suffixLength: TYPE_ID_SUFFIX_LENGTH, + prefix: idTypesMapNameToPrefix, + columnLength: getColumnLength, +}; + +export type DesktopTypeIdName = IdTypePrefixNames; +export type DesktopTypeId = TypeId; + +export function createDesktopTypeId( + name: TName, +): DesktopTypeId { + return typeId.generator(name); +} + +export function normalizeDesktopTypeId( + name: TName, + value: string, +): DesktopTypeId { + return typeId.fromString(name, value); +} + +export function isDesktopTypeId( + name: TName, + value: unknown, +): value is DesktopTypeId { + return typeId.validator(name, value); +} diff --git a/packages/desktop-db/tsconfig.json b/packages/desktop-db/tsconfig.json new file mode 100644 index 0000000000..8dafd8024e --- /dev/null +++ b/packages/desktop-db/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node", "bun-types"] + }, + "include": ["src/**/*"] +} diff --git a/packages/desktop-db/tsup.config.ts b/packages/desktop-db/tsup.config.ts new file mode 100644 index 0000000000..e1bafd82c1 --- /dev/null +++ b/packages/desktop-db/tsup.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + typeid: "src/typeid.ts", + client: "src/client.ts", + drizzle: "src/drizzle.ts", + "schema/index": "src/schema/index.ts", + "import/index": "src/import/index.ts", + preferences: "src/preferences.ts", + }, + tsconfig: "./tsconfig.json", + format: ["esm"], + dts: { + tsconfig: "./tsconfig.json", + }, + clean: true, + target: "es2022", + platform: "node", + sourcemap: false, + splitting: false, + treeshake: true, + external: ["zod", "drizzle-orm", "better-sqlite3", "bun:sqlite"], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d0e1fd082..5f4e30b859 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: '@opencode-ai/sdk': specifier: ^1.14.38 version: 1.14.38 + '@openwork/desktop-db': + specifier: workspace:* + version: link:../../packages/desktop-db better-sqlite3: specifier: ^11.10.0 version: 11.10.0 @@ -307,6 +310,9 @@ importers: '@opencode-ai/sdk': specifier: ^1.14.38 version: 1.14.38 + '@openwork/desktop-db': + specifier: workspace:* + version: link:../../packages/desktop-db better-sqlite3: specifier: ^11.10.0 version: 11.10.0 @@ -371,16 +377,16 @@ importers: dependencies: '@better-auth/api-key': specifier: 1.6.11 - version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10)) + version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9)) '@better-auth/oauth-provider': specifier: 1.6.11 - version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10))(better-call@1.3.5(zod@4.3.6)) + version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9))(better-call@1.3.5(zod@4.3.6)) '@better-auth/scim': specifier: 1.6.11 - version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10))(better-call@1.3.5(zod@4.3.6)) + version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9))(better-call@1.3.5(zod@4.3.6)) '@better-auth/sso': specifier: 1.6.11 - version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10))(better-call@1.3.5(zod@4.3.6)) + version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9))(better-call@1.3.5(zod@4.3.6)) '@daytonaio/sdk': specifier: ^0.173.0 version: 0.173.0(ws@8.19.0) @@ -422,7 +428,7 @@ importers: version: 1.1.0 better-auth: specifier: 1.6.11 - version: 1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10) + version: 1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9) better-call: specifier: 1.3.5 version: 1.3.5(zod@4.3.6) @@ -483,7 +489,7 @@ importers: version: 0.577.0(react@19.2.4) next: specifier: 16.2.1 - version: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 'catalog:' version: 19.2.4 @@ -594,7 +600,7 @@ importers: version: link:../../../packages/ui botid: specifier: ^1.5.11 - version: 1.5.11(next@14.2.35(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0) + version: 1.5.11(next@14.2.35(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0) framer-motion: specifier: ^12.35.1 version: 12.35.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -603,7 +609,7 @@ importers: version: 0.577.0(react@18.2.0) next: specifier: 14.2.35 - version: 14.2.35(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 14.2.35(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: specifier: catalog:react18 version: 18.2.0 @@ -695,6 +701,43 @@ importers: specifier: ^5.5.4 version: 5.9.3 + packages/desktop-db: + dependencies: + better-sqlite3: + specifier: ^11.10.0 + version: 11.10.0 + drizzle-orm: + specifier: ^0.45.1 + version: 0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(bun-types@1.3.6)(kysely@0.28.17)(mysql2@3.17.4) + typeid-js: + specifier: ^1.2.0 + version: 1.2.0 + uuid: + specifier: ^11.1.0 + version: 11.1.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@types/node': + specifier: ^22.10.2 + version: 22.19.7 + bun-types: + specifier: ^1.3.6 + version: 1.3.6 + drizzle-kit: + specifier: ^0.31.9 + version: 0.31.9 + tsup: + specifier: ^8.5.0 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + packages/email: dependencies: '@react-email/components': @@ -721,7 +764,7 @@ importers: devDependencies: '@react-email/ui': specifier: 6.1.1 - version: 6.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 6.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/nodemailer': specifier: ^8.0.0 version: 8.0.0 @@ -3013,11 +3056,6 @@ packages: resolution: {integrity: sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA==} engines: {node: '>=16'} - '@playwright/test@1.58.2': - resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} - engines: {node: '>=18'} - hasBin: true - '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -5623,11 +5661,6 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -7056,16 +7089,6 @@ packages: peerDependencies: stage-js: ^1.0.0-alpha.12 - playwright-core@1.58.2: - resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} - engines: {node: '>=18'} - hasBin: true - plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -7673,9 +7696,6 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - solid-js@1.9.10: - resolution: {integrity: sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==} - solid-js@1.9.9: resolution: {integrity: sha512-A0ZBPJQldAeGCTW0YRYJmt7RCeh5rbFfPZ2aOttgYnctHE7HgKeHCBB/PVc2P7eOfmNXqMFFFoYYdm3S4dcbkA==} @@ -9373,11 +9393,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@better-auth/api-key@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10))': + '@better-auth/api-key@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9))': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.4.0 - better-auth: 1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10) + better-auth: 1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9) zod: 4.3.6 '@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0)': @@ -9416,12 +9436,12 @@ snapshots: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.4.0 - '@better-auth/oauth-provider@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10))(better-call@1.3.5(zod@4.3.6))': + '@better-auth/oauth-provider@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9))(better-call@1.3.5(zod@4.3.6))': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 - better-auth: 1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10) + better-auth: 1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9) better-call: 1.3.5(zod@4.3.6) jose: 6.1.3 zod: 4.3.6 @@ -9431,20 +9451,20 @@ snapshots: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.4.0 - '@better-auth/scim@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10))(better-call@1.3.5(zod@4.3.6))': + '@better-auth/scim@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9))(better-call@1.3.5(zod@4.3.6))': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.4.0 - better-auth: 1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10) + better-auth: 1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9) better-call: 1.3.5(zod@4.3.6) zod: 4.3.6 - '@better-auth/sso@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10))(better-call@1.3.5(zod@4.3.6))': + '@better-auth/sso@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9))(better-call@1.3.5(zod@4.3.6))': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 - better-auth: 1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10) + better-auth: 1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9) better-call: 1.3.5(zod@4.3.6) fast-xml-parser: 5.8.0 jose: 6.1.3 @@ -11130,11 +11150,6 @@ snapshots: '@planetscale/database@1.19.0': {} - '@playwright/test@1.58.2': - dependencies: - playwright: 1.58.2 - optional: true - '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -11450,10 +11465,10 @@ snapshots: dependencies: react: 19.2.4 - '@react-email/ui@6.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-email/ui@6.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: esbuild: 0.28.0 - next: 16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - '@babel/core' - '@opentelemetry/api' @@ -11599,7 +11614,7 @@ snapshots: '@slack/logger@4.0.0': dependencies: - '@types/node': 25.6.0 + '@types/node': 20.12.12 '@slack/socket-mode@2.0.5': dependencies: @@ -12150,12 +12165,12 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 25.6.0 + '@types/node': 20.12.12 '@types/responselike': 1.0.3 '@types/cors@2.8.19': dependencies: - '@types/node': 25.6.0 + '@types/node': 20.12.12 '@types/debug@4.1.13': dependencies: @@ -12165,7 +12180,7 @@ snapshots: '@types/fs-extra@9.0.13': dependencies: - '@types/node': 25.6.0 + '@types/node': 20.12.12 '@types/hast@3.0.4': dependencies: @@ -12177,7 +12192,7 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 25.6.0 + '@types/node': 20.12.12 '@types/luxon@3.7.1': {} @@ -12205,11 +12220,11 @@ snapshots: '@types/nodemailer@8.0.0': dependencies: - '@types/node': 25.6.0 + '@types/node': 20.12.12 '@types/plist@3.0.5': dependencies: - '@types/node': 25.6.0 + '@types/node': 20.12.12 xmlbuilder: 15.1.1 optional: true @@ -12234,13 +12249,13 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 25.6.0 + '@types/node': 20.12.12 '@types/retry@0.12.0': {} '@types/set-cookie-parser@2.4.10': dependencies: - '@types/node': 25.6.0 + '@types/node': 20.12.12 '@types/statuses@2.0.6': {} @@ -12253,11 +12268,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.6.0 + '@types/node': 20.12.12 '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.6.0 + '@types/node': 20.12.12 optional: true '@ungap/structured-clone@1.3.1': {} @@ -12569,7 +12584,7 @@ snapshots: baseline-browser-mapping@2.9.14: {} - better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10): + better-auth@1.6.11(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(mysql2@3.17.4)(next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9): dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0) @@ -12591,10 +12606,10 @@ snapshots: optionalDependencies: drizzle-kit: 0.31.9 mysql2: 3.17.4 - next: 16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - solid-js: 1.9.10 + solid-js: 1.9.9 transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -12655,9 +12670,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - botid@1.5.11(next@14.2.35(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0): + botid@1.5.11(next@14.2.35(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0): optionalDependencies: - next: 14.2.35(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + next: 14.2.35(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 bowser@2.14.1: {} @@ -13333,7 +13348,7 @@ snapshots: engine.io@6.6.7: dependencies: '@types/cors': 2.8.19 - '@types/node': 25.6.0 + '@types/node': 20.12.12 '@types/ws': 8.18.1 accepts: 1.3.8 base64id: 2.0.0 @@ -13806,9 +13821,6 @@ snapshots: fs.realpath@1.0.0: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -14791,7 +14803,7 @@ snapshots: negotiator@1.0.0: {} - next@14.2.35(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + next@14.2.35(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@next/env': 14.2.35 '@swc/helpers': 0.5.5 @@ -14813,12 +14825,11 @@ snapshots: '@next/swc-win32-ia32-msvc': 14.2.33 '@next/swc-win32-x64-msvc': 14.2.33 '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.58.2 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.1 '@swc/helpers': 0.5.15 @@ -14838,14 +14849,13 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.2.1 '@next/swc-win32-x64-msvc': 16.2.1 '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.58.2 babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.3(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.3 '@swc/helpers': 0.5.15 @@ -14865,7 +14875,6 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.2.3 '@next/swc-win32-x64-msvc': 16.2.3 '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.58.2 babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: @@ -15200,16 +15209,6 @@ snapshots: stage-js: 1.0.0-alpha.17 optional: true - playwright-core@1.58.2: - optional: true - - playwright@1.58.2: - dependencies: - playwright-core: 1.58.2 - optionalDependencies: - fsevents: 2.3.2 - optional: true - plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.13 @@ -15344,7 +15343,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.6.0 + '@types/node': 20.12.12 long: 5.3.2 proxy-addr@2.0.7: @@ -15970,13 +15969,6 @@ snapshots: ip-address: 10.1.0 smart-buffer: 4.2.0 - solid-js@1.9.10: - dependencies: - csstype: 3.2.3 - seroval: 1.3.2 - seroval-plugins: 1.3.3(seroval@1.3.2) - optional: true - solid-js@1.9.9: dependencies: csstype: 3.2.3 From 3fb510cb9065016e188452642f26ecbcf4688a23 Mon Sep 17 00:00:00 2001 From: src-opn Date: Thu, 28 May 2026 19:11:51 -0700 Subject: [PATCH 2/3] feat: migrate env vars + desktop bootstrap config to the DB - env.json -> env_var table. EnvService is now DB-backed (same /env API, key + reserved-prefix policy preserved). The desktop shell injects user env vars from the DB into process.env before starting the in-process server and managed OpenCode (buildChildEnv), so the env.json file is no longer read. Stored plaintext for now (parity with the prior 0600 file). - desktop-bootstrap.json -> preference rows. Electron get/set and the renderer bootstrap read it from the DB; the server now surfaces denBaseUrl/denApiBaseUrl in ServerConfig from the DB. Both files are imported once (gated by migration_state) and preserved as .pre-db.bak snapshots. Bootstrap URLs store "" for unset (preference.value is NOT NULL JSON). --- apps/desktop/electron/desktop-db.mjs | 25 +- apps/desktop/electron/main.mjs | 51 ++- apps/desktop/electron/runtime.mjs | 48 ++- apps/server/src/db.ts | 8 + apps/server/src/env-file.test.ts | 143 ++------- apps/server/src/env-file.ts | 290 ++++-------------- apps/server/src/env-routes.e2e.test.ts | 23 +- apps/server/src/server.ts | 2 +- apps/server/src/types.ts | 3 + packages/desktop-db/package.json | 10 + packages/desktop-db/src/bootstrap.ts | 62 ++++ .../src/env-bootstrap-import.test.ts | 73 +++++ packages/desktop-db/src/env-store.ts | 99 ++++++ packages/desktop-db/src/import/desktop.ts | 71 +++++ packages/desktop-db/src/import/index.ts | 2 + packages/desktop-db/src/index.ts | 22 ++ packages/desktop-db/tsup.config.ts | 2 + 17 files changed, 527 insertions(+), 407 deletions(-) create mode 100644 packages/desktop-db/src/bootstrap.ts create mode 100644 packages/desktop-db/src/env-bootstrap-import.test.ts create mode 100644 packages/desktop-db/src/env-store.ts diff --git a/apps/desktop/electron/desktop-db.mjs b/apps/desktop/electron/desktop-db.mjs index 793de53c47..1a39d72351 100644 --- a/apps/desktop/electron/desktop-db.mjs +++ b/apps/desktop/electron/desktop-db.mjs @@ -23,6 +23,9 @@ import { getPreference as getPreferenceFromDb, setPreference as setPreferenceInDb, removePreference as removePreferenceFromDb, + readEnvForInjection, + getDesktopBootstrapConfig as getBootstrapFromDb, + setDesktopBootstrapConfig as setBootstrapInDb, } from "@openwork/desktop-db"; const { eq, asc } = drizzle; @@ -35,7 +38,7 @@ let importedFor = null; * files. `serverConfigPath` pins the DB next to server.json. `userDataDir` locates the * three legacy JSON files. Cached per process. */ -export async function getDesktopDb({ serverConfigPath, userDataDir }) { +export async function getDesktopDb({ serverConfigPath, userDataDir, envPath, bootstrapPath }) { const dbPath = resolveDbPathForServerConfig(serverConfigPath); if (!dbPromise) { dbPromise = openDb({ path: dbPath }); @@ -49,6 +52,8 @@ export async function getDesktopDb({ serverConfigPath, userDataDir }) { workspacesPath: path.join(userDataDir, "openwork-workspaces.json"), serverTokensPath: path.join(userDataDir, "openwork-server-tokens.json"), serverStatePath: path.join(userDataDir, "openwork-server-state.json"), + envPath: envPath ?? undefined, + bootstrapPath: bootstrapPath ?? undefined, }).catch((error) => { console.warn("[desktop-db] one-time import failed", error); }); @@ -255,4 +260,22 @@ export async function removePreference(db, key) { await removePreferenceFromDb(db, key); } +// --- User env vars (env_var table) for child-process injection --- + +/** Flat `key -> value` of user env vars (reserved keys stripped) for process.env. */ +export async function readUserEnvForInjection(db) { + return readEnvForInjection(db); +} + +// --- Desktop bootstrap (cloud / Den) config --- + +export async function getDesktopBootstrap(db) { + return getBootstrapFromDb(db); +} + +export async function setDesktopBootstrap(db, config) { + await setBootstrapInDb(db, config); + return getBootstrapFromDb(db); +} + export { authorizedRootTable }; diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 213e968c74..f7cf212e18 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -30,6 +30,8 @@ import { getAllPreferences, setPreference as setDesktopPreference, removePreference as removeDesktopPreference, + getDesktopBootstrap, + setDesktopBootstrap, } from "./desktop-db.mjs"; import { registerUpdaterIpc } from "./updater.mjs"; import { exportWorkspaceConfig, importWorkspaceConfig } from "./workspace-archive.mjs"; @@ -1192,6 +1194,10 @@ function desktopDb() { return getDesktopDb({ serverConfigPath: resolveOpenworkServerConfigPath(process.env), userDataDir: app.getPath("userData"), + envPath: process.env.OPENWORK_ENV_STORE?.trim() + ? path.resolve(process.env.OPENWORK_ENV_STORE.trim()) + : path.join(os.homedir(), ".config", "openwork", "env.json"), + bootstrapPath: desktopBootstrapPath(), }); } @@ -1347,13 +1353,25 @@ function normalizeDesktopBootstrapConfig(input) { } async function getDesktopBootstrapConfig() { - const configPath = desktopBootstrapPath(); try { - const raw = await readFile(configPath, "utf8"); - return normalizeDesktopBootstrapConfig(JSON.parse(raw)); + const db = await desktopDb(); + const stored = await getDesktopBootstrap(db); + // A stored baseUrl means the user/import configured it; otherwise fall back to + // build defaults. requireSignin is forced on for locked builds. + if (stored.baseUrl) { + return { + baseUrl: stored.baseUrl, + apiBaseUrl: stored.apiBaseUrl ?? null, + requireSignin: FORCE_DESKTOP_REQUIRE_SIGNIN || stored.requireSignin === true, + }; + } + return { + baseUrl: DEFAULT_DEN_BASE_URL, + apiBaseUrl: null, + requireSignin: FORCE_DESKTOP_REQUIRE_SIGNIN || stored.requireSignin === true || DEFAULT_DESKTOP_REQUIRE_SIGNIN, + }; } catch (error) { console.warn("[desktop-bootstrap] falling back to defaults", { - path: configPath, error: error instanceof Error ? error.message : String(error), }); return { @@ -1365,23 +1383,23 @@ async function getDesktopBootstrapConfig() { } async function debugDesktopBootstrapConfig() { - const configPath = desktopBootstrapPath(); + const legacyPath = desktopBootstrapPath(); const result = { - path: configPath, + source: "db", + legacyFilePath: legacyPath, + legacyFileExists: existsSync(legacyPath), home: os.homedir(), envHome: process.env.HOME ?? null, envOverride: process.env.OPENWORK_DESKTOP_BOOTSTRAP_PATH ?? null, - exists: existsSync(configPath), - raw: null, - parsed: null, + stored: null, normalized: null, error: null, }; try { - result.raw = await readFile(configPath, "utf8"); - result.parsed = JSON.parse(result.raw); - result.normalized = normalizeDesktopBootstrapConfig(result.parsed); + const db = await desktopDb(); + result.stored = await getDesktopBootstrap(db); + result.normalized = await getDesktopBootstrapConfig(); } catch (error) { result.error = error instanceof Error ? error.message : String(error); } @@ -1391,9 +1409,12 @@ async function debugDesktopBootstrapConfig() { async function setDesktopBootstrapConfig(config) { const normalized = normalizeDesktopBootstrapConfig(config); - const outputPath = desktopBootstrapPath(); - await mkdir(path.dirname(outputPath), { recursive: true }); - await writeFile(outputPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8"); + const db = await desktopDb(); + await setDesktopBootstrap(db, { + baseUrl: normalized.baseUrl, + apiBaseUrl: normalized.apiBaseUrl, + requireSignin: normalized.requireSignin, + }); return normalized; } diff --git a/apps/desktop/electron/runtime.mjs b/apps/desktop/electron/runtime.mjs index 0f05723d0c..7be5dbb48a 100644 --- a/apps/desktop/electron/runtime.mjs +++ b/apps/desktop/electron/runtime.mjs @@ -14,6 +14,7 @@ import { setWorkspaceOwnerTokenInDb, readPreferredPortFromDb, persistPreferredPortInDb, + readUserEnvForInjection, } from "./desktop-db.mjs"; const __runtimeDir = path.dirname(fileURLToPath(import.meta.url)); @@ -426,29 +427,12 @@ function resolveUserEnvFilePath() { return path.join(os.homedir(), ".config", "openwork", "env.json"); } -const USER_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; -const USER_ENV_RESERVED_PREFIXES = ["OPENWORK_", "OPENCODE_"]; - -// Synchronous, best-effort; absent or malformed returns {}. Reserved prefixes -// are stripped so a tampered file can never shadow OPENWORK_* / OPENCODE_*. -function loadUserEnvFile() { - try { - const raw = readFileSync(resolveUserEnvFilePath(), "utf8"); - const parsed = JSON.parse(raw); - if (!parsed || !Array.isArray(parsed.variables)) return {}; - const out = {}; - for (const entry of parsed.variables) { - if (!entry || typeof entry !== "object") continue; - const { key, value } = entry; - if (typeof key !== "string" || typeof value !== "string") continue; - if (!USER_ENV_KEY_PATTERN.test(key)) continue; - if (USER_ENV_RESERVED_PREFIXES.some((p) => key.startsWith(p))) continue; - out[key] = value; - } - return out; - } catch { - return {}; - } +// Mirrors main.mjs desktopBootstrapPath(): ~/.config/openwork/desktop-bootstrap.json +// (or OPENWORK_DESKTOP_BOOTSTRAP_PATH override). +function resolveDesktopBootstrapPath() { + const override = String(process.env.OPENWORK_DESKTOP_BOOTSTRAP_PATH ?? "").trim(); + if (override) return path.resolve(override); + return path.join(os.homedir(), ".config", "openwork", "desktop-bootstrap.json"); } export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths }) { @@ -489,6 +473,8 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths return getDesktopDb({ serverConfigPath: resolveOpenworkServerConfigPath(process.env), userDataDir, + envPath: resolveUserEnvFilePath(), + bootstrapPath: resolveDesktopBootstrapPath(), }); } @@ -610,11 +596,19 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths async function buildChildEnv(extra = {}) { /** @type {NodeJS.ProcessEnv} */ - // User env is layered first so process.env + any caller overrides always - // win. See apps/server/src/env-file.ts and src-tauri/src/env_file.rs — - // all three loaders must agree on path + reserved-keys policy. + // User env vars now live in the shared desktop DB (env_var table), no longer in + // env.json. They're layered first so process.env + caller overrides win; reserved + // OPENWORK_/OPENCODE_ keys are stripped by readUserEnvForInjection. + /** @type {Record} */ + let userEnv = {}; + try { + userEnv = await readUserEnvForInjection(await runtimeDb()); + } catch (error) { + console.warn("[runtime] failed to read user env vars from DB", error); + } + /** @type {Record} */ const env = { - ...loadUserEnvFile(), + ...userEnv, ...process.env, BUN_CONFIG_DNS_RESULT_ORDER: "verbatim", ...extra, diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index 38712b2348..7217f427b7 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -15,6 +15,7 @@ import { workspaceTable, authorizedRootTable, drizzle, + getDesktopBootstrapConfig, type DesktopDb, type ImportOnceReport, } from "@openwork/desktop-db"; @@ -130,6 +131,13 @@ export async function loadWorkspaceRegistryFromDb( */ export async function reconcileConfigWithDb(config: ServerConfig): Promise { await ensureImported(config); + const db = await getDb(config); + + // Surface the desktop cloud (Den) URLs from the DB bootstrap config. + const bootstrap = await getDesktopBootstrapConfig(db); + if (bootstrap.baseUrl) config.denBaseUrl = bootstrap.baseUrl; + if (bootstrap.apiBaseUrl) config.denApiBaseUrl = bootstrap.apiBaseUrl; + const registry = await loadWorkspaceRegistryFromDb(config); if (!registry) return; diff --git a/apps/server/src/env-file.test.ts b/apps/server/src/env-file.test.ts index 2a9142ce59..a7c5aa2b11 100644 --- a/apps/server/src/env-file.test.ts +++ b/apps/server/src/env-file.test.ts @@ -1,26 +1,22 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - EnvService, - EnvStoreReadError, - InvalidEnvKeyError, - isReservedEnvKey, - isValidEnvKey, -} from "./env-file.js"; +import { EnvService, InvalidEnvKeyError, isReservedEnvKey, isValidEnvKey } from "./env-file.js"; +import { closeDb, readEnvForInjection, openDb } from "@openwork/desktop-db"; -describe("env-file", () => { +describe("env-file (DB-backed)", () => { let dir: string; - let path: string; + let dbPath: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "openwork-env-")); - path = join(dir, "env.json"); + dbPath = join(dir, "env.db"); }); afterEach(() => { + closeDb(); rmSync(dir, { recursive: true, force: true }); }); @@ -42,7 +38,7 @@ describe("env-file", () => { }); test("upsertMany + list round-trips with sorted keys", async () => { - const svc = new EnvService({ path }); + const svc = new EnvService({ path: dbPath }); await svc.upsertMany([ { key: "ZED", value: "z" }, { key: "ANTHROPIC_API_KEY", value: "sk-ant-abc123" }, @@ -53,120 +49,39 @@ describe("env-file", () => { }); test("upsertMany updates existing keys in place", async () => { - const svc = new EnvService({ path }); + const svc = new EnvService({ path: dbPath }); await svc.upsertMany([{ key: "FOO", value: "1" }]); await svc.upsertMany([{ key: "FOO", value: "2" }]); const items = await svc.list(); expect(items).toHaveLength(1); - expect(items[0].value).toBe("2"); + expect(items[0]!.value).toBe("2"); }); - test("concurrent upserts do not overwrite each other", async () => { - const svc = new EnvService({ path }); - await Promise.all( - Array.from({ length: 12 }, (_, index) => - svc.upsertMany([{ key: `KEY_${index}`, value: String(index) }]) - ), + test("upsertMany rejects invalid and reserved keys", async () => { + const svc = new EnvService({ path: dbPath }); + await expect(svc.upsertMany([{ key: "1BAD", value: "x" }])).rejects.toBeInstanceOf( + InvalidEnvKeyError, ); - - const items = await svc.list(); - expect(items.map((item) => item.key)).toEqual( - Array.from({ length: 12 }, (_, index) => `KEY_${index}`).sort(), - ); - }); - - test("write failures do not mutate loaded values", async () => { - const svc = new EnvService({ path }); - await svc.upsertMany([{ key: "KEEP_ME", value: "old" }]); - - rmSync(path, { force: true }); - mkdirSync(path); - - await expect(svc.upsertMany([{ key: "NEW_KEY", value: "new" }])).rejects.toThrow(); - expect(await svc.list()).toEqual([ - expect.objectContaining({ key: "KEEP_ME", value: "old" }), - ]); - }); - - test("upsertMany rejects invalid keys with InvalidEnvKeyError", async () => { - const svc = new EnvService({ path }); - const promise = svc.upsertMany([{ key: "bad-key", value: "x" }]); - await expect(promise).rejects.toBeInstanceOf(InvalidEnvKeyError); - await expect(promise).rejects.toMatchObject({ code: "invalid_env_key" }); + await expect( + svc.upsertMany([{ key: "OPENWORK_TOKEN", value: "x" }]), + ).rejects.toBeInstanceOf(InvalidEnvKeyError); + expect(await svc.list()).toHaveLength(0); }); - test("upsertMany rejects reserved keys", async () => { - const svc = new EnvService({ path }); - const promise = svc.upsertMany([{ key: "OPENWORK_TOKEN", value: "x" }]); - await expect(promise).rejects.toBeInstanceOf(InvalidEnvKeyError); - await expect(promise).rejects.toMatchObject({ code: "reserved_env_key" }); - }); - - test("delete returns false when the key is missing", async () => { - const svc = new EnvService({ path }); - await svc.upsertMany([{ key: "FOO", value: "x" }]); + test("delete removes a key and reports whether it existed", async () => { + const svc = new EnvService({ path: dbPath }); + await svc.upsertMany([{ key: "FOO", value: "1" }]); expect(await svc.delete("FOO")).toBe(true); expect(await svc.delete("FOO")).toBe(false); + expect(await svc.list()).toHaveLength(0); }); - test("persisted file has 0600 perms on POSIX", async () => { - if (process.platform === "win32") return; - const svc = new EnvService({ path }); - await svc.upsertMany([{ key: "FOO", value: "bar" }]); - const mode = statSync(path).mode & 0o777; - expect(mode).toBe(0o600); - }); - - test("readForInjection returns a plain key/value map", async () => { - const svc = new EnvService({ path }); - await svc.upsertMany([ - { key: "A", value: "1" }, - { key: "B", value: "2" }, - ]); - const injected = await EnvService.readForInjection(path); - expect(injected).toEqual({ A: "1", B: "2" }); - }); - - test("readForInjection strips reserved keys even if present on disk", async () => { - // Simulate a hand-edited env.json that contains a reserved key. The - // service refuses to write these, but the injection path must still - // defend against a file someone tampered with. - writeFileSync( - path, - JSON.stringify({ - schemaVersion: 1, - updatedAt: Date.now(), - variables: [ - { key: "OPENWORK_TOKEN", value: "stolen" }, - { key: "ANTHROPIC_API_KEY", value: "sk-ant" }, - ], - }), - ); - const injected = await EnvService.readForInjection(path); - expect(injected).toEqual({ ANTHROPIC_API_KEY: "sk-ant" }); - }); - - test("readForInjection returns {} when the file is missing", async () => { - const injected = await EnvService.readForInjection(join(dir, "nope.json")); - expect(injected).toEqual({}); - }); - - test("readForInjection returns {} on corrupted JSON", async () => { - writeFileSync(path, "{ this is not json"); - const injected = await EnvService.readForInjection(path); - expect(injected).toEqual({}); - }); - - test("list rejects corrupted JSON instead of treating it as empty", async () => { - writeFileSync(path, "{ this is not json"); - const svc = new EnvService({ path }); - await expect(svc.list()).rejects.toBeInstanceOf(EnvStoreReadError); - }); - - test("upsertMany does not overwrite an invalid store", async () => { - writeFileSync(path, "{ this is not json"); - const svc = new EnvService({ path }); - await expect(svc.upsertMany([{ key: "SAFE", value: "new" }])).rejects.toBeInstanceOf(EnvStoreReadError); - expect(readFileSync(path, "utf8")).toBe("{ this is not json"); + test("readEnvForInjection strips reserved keys", async () => { + const db = await openDb({ path: dbPath }); + const svc = new EnvService({ path: dbPath }); + await svc.upsertMany([{ key: "ANTHROPIC_API_KEY", value: "sk" }]); + const injected = await readEnvForInjection(db); + expect(injected.ANTHROPIC_API_KEY).toBe("sk"); + expect(injected.OPENWORK_TOKEN).toBeUndefined(); }); }); diff --git a/apps/server/src/env-file.ts b/apps/server/src/env-file.ts index 655d08e2fa..614d44b72c 100644 --- a/apps/server/src/env-file.ts +++ b/apps/server/src/env-file.ts @@ -1,257 +1,79 @@ -import { homedir, platform } from "node:os"; -import { chmod, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; - -import { ensureDir, exists } from "./utils.js"; - -// User-level environment variables, persisted so the desktop shell can inject -// them into every spawned child (OpenCode, OpenWork server, opencode-router). -// Motivation: Linux GUI launches don't inherit shell env, so users set -// ANTHROPIC_API_KEY / GCLOUD_* / GCP_* in .bashrc and hit silent auth failures. -// Scope: user/machine, not workspace. Not synced to the cloud. - -const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; - -// Keys reserved for internal wiring by the shell/orchestrator/server. This UI -// is for service credentials, not OpenWork/OpenCode runtime knobs; users who -// need OPENCODE_* process settings should set them from the launching shell. -// We refuse writes to these and strip them when reading for injection, so a -// tampered file cannot shadow auth credentials, token paths, or process -// identity. -const RESERVED_PREFIXES = ["OPENWORK_", "OPENCODE_"] as const; - -export type EnvRecord = { - key: string; - value: string; - updatedAt: number; -}; - -type EnvStoreFile = { - schemaVersion: number; - updatedAt: number; - variables: EnvRecord[]; -}; - -export function isValidEnvKey(key: string): boolean { - return ENV_KEY_PATTERN.test(key); -} - -export function isReservedEnvKey(key: string): boolean { - return RESERVED_PREFIXES.some((prefix) => key.startsWith(prefix)); -} - -// Deterministic, matches what the Rust/Node shells compute independently. -// Do NOT key this off ServerConfig.configPath — the shell resolves the path -// before the server config exists, and must agree with us byte-for-byte. -export function resolveDefaultEnvStorePath(): string { - const override = (process.env.OPENWORK_ENV_STORE ?? "").trim(); - if (override) return resolve(override); - - if (platform() === "win32") { - const appData = (process.env.APPDATA ?? "").trim(); - const root = appData || join(homedir(), "AppData", "Roaming"); - return join(root, "openwork", "env.json"); - } - return join(homedir(), ".config", "openwork", "env.json"); -} - -function parseRecord(raw: unknown): EnvRecord | null { - if (!raw || typeof raw !== "object") return null; - const record = raw as Partial; - const key = typeof record.key === "string" ? record.key : ""; - const value = typeof record.value === "string" ? record.value : ""; - if (!isValidEnvKey(key)) return null; - return { - key, - value, - updatedAt: typeof record.updatedAt === "number" ? record.updatedAt : Date.now(), - }; -} - -function emptyStore(): EnvStoreFile { - return { schemaVersion: 1, updatedAt: Date.now(), variables: [] }; -} - -async function readStore( - path: string, - options: { tolerateInvalid?: boolean } = {}, -): Promise { - if (!(await exists(path))) { - return emptyStore(); - } - let raw = ""; - try { - raw = await readFile(path, "utf8"); - } catch (error) { - if ((error as { code?: string }).code === "ENOENT") return emptyStore(); - if (options.tolerateInvalid) return emptyStore(); - throw new EnvStoreReadError("Environment variable store could not be read"); - } - - let parsed: Partial; - try { - parsed = JSON.parse(raw) as Partial; - } catch { - if (options.tolerateInvalid) return emptyStore(); - throw new EnvStoreReadError("Environment variable store is invalid JSON"); - } - - if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.variables)) { - if (options.tolerateInvalid) return emptyStore(); - throw new EnvStoreReadError("Environment variable store has an invalid format"); - } - - const variables = parsed.variables - .map(parseRecord) - .filter((entry): entry is EnvRecord => Boolean(entry)); - return { - schemaVersion: typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1, - updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(), - variables, - }; -} - -async function writeStore(path: string, variables: EnvRecord[]): Promise { - const dir = dirname(path); - await ensureDir(dir); - const payload: EnvStoreFile = { - schemaVersion: 1, - updatedAt: Date.now(), - variables, - }; - const tempPath = join( - dir, - `.env.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`, - ); - await writeFile(tempPath, JSON.stringify(payload, null, 2) + "\n", { - encoding: "utf8", - flag: "wx", - mode: 0o600, - }); - try { - await chmod(tempPath, 0o600); - } catch (error) { - // chmod is a no-op on Windows; values may still contain secrets. - if (platform() !== "win32") { - await rm(tempPath, { force: true }).catch(() => {}); - throw error; - } - } - try { - await rename(tempPath, path); - } catch (error) { - await rm(tempPath, { force: true }).catch(() => {}); - throw error; - } - try { - await chmod(path, 0o600); - } catch (error) { - // chmod is a no-op on Windows; values may still contain secrets. - if (platform() !== "win32") throw error; - } -} - -export type EnvEntry = { key: string; value: string }; - +import { getDb } from "./db.js"; +import type { ServerConfig } from "./types.js"; +import { + deleteEnvVar, + getEnvVar, + InvalidEnvKeyError, + isReservedEnvKey, + isValidEnvKey, + listEnvVars, + readEnvForInjection, + upsertEnvVars, + type EnvEntry, + type EnvRecord, +} from "@openwork/desktop-db"; +import { openDb } from "@openwork/desktop-db"; +import type { DesktopDb } from "@openwork/desktop-db"; + +export { isValidEnvKey, isReservedEnvKey, InvalidEnvKeyError }; +export type { EnvRecord, EnvEntry }; + +/** + * DB-backed user environment variables (replaces the env.json file). + * + * Same public surface as before so server.ts/routes are unchanged. Scope is + * user/machine; reserved OPENWORK_/OPENCODE_ keys are refused on write and stripped on + * read-for-injection. Stored plaintext for now (parity with the prior 0600 env.json). + */ export class EnvService { - private readonly path: string; - private loaded = false; - private loadPromise: Promise | null = null; - private mutationQueue: Promise = Promise.resolve(); - private variables: EnvRecord[] = []; + private readonly dbPathOverride: string | undefined; + private readonly config: ServerConfig | undefined; - constructor(options?: { path?: string }) { - this.path = options?.path ? resolve(options.path) : resolveDefaultEnvStorePath(); + constructor(options?: { path?: string; config?: ServerConfig }) { + // `path` (when provided, e.g. in tests) points at a dedicated SQLite DB file. + // `config` ties the service to the same DB the server uses (/openwork.db). + this.dbPathOverride = options?.path; + this.config = options?.config; } - private async ensureLoaded(): Promise { - if (this.loaded) return; - if (!this.loadPromise) { - this.loadPromise = readStore(this.path) - .then((store) => { - this.variables = store.variables; - this.loaded = true; - }) - .finally(() => { - this.loadPromise = null; - }); + private async db(): Promise { + if (this.dbPathOverride) { + return openDb({ path: this.dbPathOverride }); } - await this.loadPromise; + return getDb(this.config); } - private enqueueMutation(operation: () => Promise): Promise { - const run = this.mutationQueue.catch(() => {}).then(operation); - this.mutationQueue = run.then( - () => {}, - () => {}, - ); - return run; + async list(): Promise { + return listEnvVars(await this.db()); } - async list(): Promise { - await this.ensureLoaded(); - return this.variables.slice(); + async get(key: string): Promise { + return getEnvVar(await this.db(), key); } async upsertMany(entries: EnvEntry[]): Promise { - return this.enqueueMutation(async () => { - await this.ensureLoaded(); - const now = Date.now(); - const next = new Map(this.variables.map((entry) => [entry.key, entry] as const)); - for (const entry of entries) { - if (!isValidEnvKey(entry.key)) { - throw new InvalidEnvKeyError(entry.key, "invalid_env_key"); - } - if (isReservedEnvKey(entry.key)) { - throw new InvalidEnvKeyError(entry.key, "reserved_env_key"); - } - next.set(entry.key, { key: entry.key, value: entry.value, updatedAt: now }); - } - const nextVariables = Array.from(next.values()).sort((a, b) => a.key.localeCompare(b.key)); - await writeStore(this.path, nextVariables); - this.variables = nextVariables; - }); + await upsertEnvVars(await this.db(), entries); } async delete(key: string): Promise { - return this.enqueueMutation(async () => { - await this.ensureLoaded(); - const before = this.variables.length; - const nextVariables = this.variables.filter((entry) => entry.key !== key); - if (nextVariables.length === before) return false; - await writeStore(this.path, nextVariables); - this.variables = nextVariables; - return true; - }); + return deleteEnvVar(await this.db(), key); } - // Used by the Electron + orchestrator shells at spawn time. The Tauri Rust - // shell has its own equivalent in src-tauri/src/env_file.rs — keep the two - // readers byte-for-byte in sync on path resolution and reserved-keys policy. - static async readForInjection(overridePath?: string): Promise> { - const path = overridePath?.trim() ? resolve(overridePath.trim()) : resolveDefaultEnvStorePath(); - const store = await readStore(path, { tolerateInvalid: true }); - const out: Record = {}; - for (const entry of store.variables) { - if (isReservedEnvKey(entry.key)) continue; - out[entry.key] = entry.value; - } - return out; + /** + * Flat key->value map for injecting into spawned children. Reserved keys stripped. + * Note: the desktop shells inject DB env vars into process.env before the server + * starts; this remains for server-side callers that want the same view. + */ + static async readForInjection(): Promise> { + return readEnvForInjection(await getDb()); } } +/** + * Retained for API compatibility. The DB-backed store no longer throws a distinct + * "invalid store file" error (there is no file to be malformed), but server.ts still + * imports this type for its 409 mapping. It is effectively unused now. + */ export class EnvStoreReadError extends Error { readonly code = "invalid_env_store"; } - -export class InvalidEnvKeyError extends Error { - readonly code: "invalid_env_key" | "reserved_env_key"; - constructor(key: string, code: "invalid_env_key" | "reserved_env_key") { - super( - code === "reserved_env_key" - ? `Environment variable name is reserved for OpenWork internals: ${key}` - : `Invalid environment variable name: ${key}`, - ); - this.code = code; - } -} diff --git a/apps/server/src/env-routes.e2e.test.ts b/apps/server/src/env-routes.e2e.test.ts index 86b838b628..c7850edee3 100644 --- a/apps/server/src/env-routes.e2e.test.ts +++ b/apps/server/src/env-routes.e2e.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -19,12 +19,16 @@ const priorTokenStore = process.env.OPENWORK_TOKEN_STORE; const priorOpenAiApiKey = process.env.OPENAI_API_KEY; const nativeFetch = globalThis.fetch; +let currentDir = ""; + function baseConfig(): ServerConfig { return { host: "127.0.0.1", port: 0, token: "owt_env_client_token", hostToken: HOST_TOKEN, + // Per-test config dir => per-test DB (/openwork.db) so env vars/tokens are isolated. + configPath: join(currentDir, "server.json"), approval: { mode: "auto", timeoutMs: 1000 }, corsOrigins: ["*"], workspaces: [], @@ -54,6 +58,7 @@ function hostAuth() { beforeEach(() => { const dir = mkdtempSync(join(tmpdir(), "openwork-env-routes-")); dirs.push(dir); + currentDir = dir; // Redirect the shared env.json path into a throwaway dir so the test never // touches the developer's real ~/.config/openwork/env.json. process.env.OPENWORK_ENV_STORE = join(dir, "env.json"); @@ -222,20 +227,8 @@ describe("env routes", () => { expect(await list.json()).toEqual({ keys: ["ANTHROPIC_API_KEY", "NBA_LIVE_KEY"] }); }); - test("invalid env store returns 409 instead of overwriting on PUT", async () => { - writeFileSync(process.env.OPENWORK_ENV_STORE!, "{ this is not json"); - const { base } = await boot(); - - const put = await fetch(`${base}/env`, { - method: "PUT", - headers: hostAuth(), - body: JSON.stringify({ key: "SAFE", value: "new" }), - }); - - expect(put.status).toBe(409); - const body = (await put.json()) as { code: string; message: string }; - expect(body.code).toBe("invalid_env_store"); - }); + // The DB-backed env store cannot be "invalid JSON" like the old env.json file, so the + // 409 invalid_env_store path is no longer reachable; that test was removed. test("PUT accepts a batch via entries[]", async () => { const { base } = await boot(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 14d1dc2812..ad5e46d4f8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -552,7 +552,7 @@ export async function startServer(config: ServerConfig): Promise { const approvals = new ApprovalService(config.approval); const reloadEvents = new ReloadEventStore(); const tokens = new TokenService(config); - const env = new EnvService(); + const env = new EnvService({ config }); const logger = createServerLogger(config); let watcherHandle = startReloadWatchers({ config, reloadEvents, logger }); const refreshWorkspaceReloadBaseline = (workspaceId: string, reasons?: ReloadReason[]) => diff --git a/apps/server/src/types.ts b/apps/server/src/types.ts index 00503ada1d..ee65ffc47f 100644 --- a/apps/server/src/types.ts +++ b/apps/server/src/types.ts @@ -91,6 +91,9 @@ export interface ServerConfig { hostTokenSource: "cli" | "env" | "file" | "generated"; logFormat: LogFormat; logRequests: boolean; + /** Desktop cloud (Den) connection URLs, sourced from the DB bootstrap config. */ + denBaseUrl?: string; + denApiBaseUrl?: string; } export interface Capabilities { diff --git a/packages/desktop-db/package.json b/packages/desktop-db/package.json index e61c82891c..3548302030 100644 --- a/packages/desktop-db/package.json +++ b/packages/desktop-db/package.json @@ -40,6 +40,16 @@ "development": "./src/preferences.ts", "types": "./dist/preferences.d.ts", "default": "./dist/preferences.js" + }, + "./env-store": { + "development": "./src/env-store.ts", + "types": "./dist/env-store.d.ts", + "default": "./dist/env-store.js" + }, + "./bootstrap": { + "development": "./src/bootstrap.ts", + "types": "./dist/bootstrap.d.ts", + "default": "./dist/bootstrap.js" } }, "files": [ diff --git a/packages/desktop-db/src/bootstrap.ts b/packages/desktop-db/src/bootstrap.ts new file mode 100644 index 0000000000..a5869e8678 --- /dev/null +++ b/packages/desktop-db/src/bootstrap.ts @@ -0,0 +1,62 @@ +import { eq } from "drizzle-orm"; +import type { DesktopDb } from "./client"; +import { preferenceTable } from "./schema/index"; + +/** + * Desktop "bootstrap" config (cloud / Den connection) — replaces desktop-bootstrap.json. + * Stored as preference rows so it's shared with the server (which exposes baseUrl / + * apiBaseUrl in its config) and read by Electron + the renderer. + */ + +export const BOOTSTRAP_BASE_URL_PREF = "desktop.bootstrap.baseUrl"; +export const BOOTSTRAP_API_BASE_URL_PREF = "desktop.bootstrap.apiBaseUrl"; +export const BOOTSTRAP_REQUIRE_SIGNIN_PREF = "desktop.bootstrap.requireSignin"; + +export type DesktopBootstrapConfig = { + baseUrl: string | null; + apiBaseUrl: string | null; + requireSignin: boolean; +}; + +async function readPrefString(db: DesktopDb, key: string): Promise { + const rows = await db.select().from(preferenceTable).where(eq(preferenceTable.key, key)); + const value = rows[0]?.value; + // Empty string is stored to represent "unset" (the JSON column is NOT NULL). + return typeof value === "string" && value.length > 0 ? value : null; +} + +async function readPrefBool(db: DesktopDb, key: string): Promise { + const rows = await db.select().from(preferenceTable).where(eq(preferenceTable.key, key)); + const value = rows[0]?.value; + return typeof value === "boolean" ? value : null; +} + +function writePref(db: DesktopDb, key: string, value: unknown, now: number) { + db.insert(preferenceTable) + .values({ key, value, createdAt: now, updatedAt: now }) + .onConflictDoUpdate({ target: preferenceTable.key, set: { value, updatedAt: now } }) + .run(); +} + +/** Read the bootstrap config from the DB. Returns nulls when unset. */ +export async function getDesktopBootstrapConfig(db: DesktopDb): Promise { + return { + baseUrl: await readPrefString(db, BOOTSTRAP_BASE_URL_PREF), + apiBaseUrl: await readPrefString(db, BOOTSTRAP_API_BASE_URL_PREF), + requireSignin: (await readPrefBool(db, BOOTSTRAP_REQUIRE_SIGNIN_PREF)) ?? false, + }; +} + +/** Persist the bootstrap config to the DB. */ +export async function setDesktopBootstrapConfig( + db: DesktopDb, + config: DesktopBootstrapConfig, +): Promise { + const now = Date.now(); + // The preference.value column is NOT NULL JSON; store "" for unset URLs. + db.transaction((tx) => { + writePref(tx as unknown as DesktopDb, BOOTSTRAP_BASE_URL_PREF, config.baseUrl ?? "", now); + writePref(tx as unknown as DesktopDb, BOOTSTRAP_API_BASE_URL_PREF, config.apiBaseUrl ?? "", now); + writePref(tx as unknown as DesktopDb, BOOTSTRAP_REQUIRE_SIGNIN_PREF, config.requireSignin === true, now); + }); +} diff --git a/packages/desktop-db/src/env-bootstrap-import.test.ts b/packages/desktop-db/src/env-bootstrap-import.test.ts new file mode 100644 index 0000000000..33a3edc650 --- /dev/null +++ b/packages/desktop-db/src/env-bootstrap-import.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { closeDb, openDb } from "./client"; +import { listEnvVars } from "./env-store"; +import { getDesktopBootstrapConfig } from "./bootstrap"; +import { runDesktopImportOnce } from "./import/index"; + +let tmp: string | null = null; + +afterEach(() => { + closeDb(); + if (tmp) { + rmSync(tmp, { recursive: true, force: true }); + tmp = null; + } +}); + +describe("env + bootstrap import", () => { + test("imports env.json (skipping reserved/invalid) and desktop-bootstrap.json", async () => { + tmp = mkdtempSync(join(tmpdir(), "owenv-")); + const envPath = join(tmp, "env.json"); + const bootstrapPath = join(tmp, "desktop-bootstrap.json"); + writeFileSync( + envPath, + JSON.stringify({ + schemaVersion: 1, + variables: [ + { key: "ANTHROPIC_API_KEY", value: "sk", updatedAt: 1 }, + { key: "OPENWORK_TOKEN", value: "nope", updatedAt: 1 }, // reserved -> skipped + { key: "1BAD", value: "x", updatedAt: 1 }, // invalid -> skipped + ], + }), + ); + writeFileSync( + bootstrapPath, + JSON.stringify({ baseUrl: "https://app.example.com", apiBaseUrl: "https://api.example.com", requireSignin: true }), + ); + + const db = await openDb({ path: join(tmp, "test.db") }); + const report = await runDesktopImportOnce(db, { + workspacesPath: join(tmp, "missing-ws.json"), + serverTokensPath: join(tmp, "missing-tok.json"), + serverStatePath: join(tmp, "missing-state.json"), + envPath, + bootstrapPath, + }); + + expect(report["env.json"]!.status).toBe("imported"); + expect(report["desktop-bootstrap.json"]!.status).toBe("imported"); + expect(existsSync(`${envPath}.pre-db.bak`)).toBe(true); + + const envVars = await listEnvVars(db); + expect(envVars.map((v) => v.key)).toEqual(["ANTHROPIC_API_KEY"]); + + const bootstrap = await getDesktopBootstrapConfig(db); + expect(bootstrap.baseUrl).toBe("https://app.example.com"); + expect(bootstrap.apiBaseUrl).toBe("https://api.example.com"); + expect(bootstrap.requireSignin).toBe(true); + + // idempotent + const second = await runDesktopImportOnce(db, { + workspacesPath: join(tmp, "missing-ws.json"), + serverTokensPath: join(tmp, "missing-tok.json"), + serverStatePath: join(tmp, "missing-state.json"), + envPath, + bootstrapPath, + }); + expect(second["env.json"]!.status).toBe("already-done"); + expect((await listEnvVars(db)).length).toBe(1); + }); +}); diff --git a/packages/desktop-db/src/env-store.ts b/packages/desktop-db/src/env-store.ts new file mode 100644 index 0000000000..ad1682269a --- /dev/null +++ b/packages/desktop-db/src/env-store.ts @@ -0,0 +1,99 @@ +import { eq } from "drizzle-orm"; +import type { DesktopDb } from "./client"; +import { envVarTable } from "./schema/index"; + +/** + * DB-backed user environment variables (replaces env.json). + * + * Scope: user/machine, not workspace. Values are service credentials (API keys). + * Reserved prefixes are refused on write and stripped on read-for-injection so a + * tampered store can never shadow OpenWork/OpenCode runtime wiring. + * + * Stored plaintext for now (parity with the prior 0600 env.json). At-rest encryption + * is a separate follow-up. + */ + +const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const RESERVED_PREFIXES = ["OPENWORK_", "OPENCODE_"] as const; + +export type EnvRecord = { key: string; value: string; updatedAt: number }; +export type EnvEntry = { key: string; value: string }; + +export function isValidEnvKey(key: string): boolean { + return ENV_KEY_PATTERN.test(key); +} + +export function isReservedEnvKey(key: string): boolean { + return RESERVED_PREFIXES.some((prefix) => key.startsWith(prefix)); +} + +export async function listEnvVars(db: DesktopDb): Promise { + const rows = await db.select().from(envVarTable); + return rows + .filter((row) => isValidEnvKey(row.key)) + .map((row) => ({ key: row.key, value: row.value, updatedAt: row.updatedAt })) + .sort((a, b) => a.key.localeCompare(b.key)); +} + +export async function getEnvVar(db: DesktopDb, key: string): Promise { + const rows = await db.select().from(envVarTable).where(eq(envVarTable.key, key)); + const row = rows[0]; + return row ? { key: row.key, value: row.value, updatedAt: row.updatedAt } : null; +} + +/** Upsert several env vars. Throws on invalid/reserved keys (caller maps to HTTP codes). */ +export async function upsertEnvVars(db: DesktopDb, entries: EnvEntry[]): Promise { + const now = Date.now(); + for (const entry of entries) { + if (!isValidEnvKey(entry.key)) throw new InvalidEnvKeyError(entry.key, "invalid_env_key"); + if (isReservedEnvKey(entry.key)) throw new InvalidEnvKeyError(entry.key, "reserved_env_key"); + } + db.transaction((tx) => { + for (const entry of entries) { + tx.insert(envVarTable) + .values({ key: entry.key, value: entry.value, updatedAt: now }) + .onConflictDoUpdate({ + target: envVarTable.key, + set: { value: entry.value, updatedAt: now }, + }) + .run(); + } + }); +} + +export async function deleteEnvVar(db: DesktopDb, key: string): Promise { + const existing = await getEnvVar(db, key); + if (!existing) return false; + await db.delete(envVarTable).where(eq(envVarTable.key, key)).run(); + return true; +} + +/** + * Flat `key -> value` map for injecting into spawned children / process.env. Reserved + * keys are stripped. Never throws. + */ +export async function readEnvForInjection(db: DesktopDb): Promise> { + const out: Record = {}; + try { + const rows = await db.select().from(envVarTable); + for (const row of rows) { + if (!isValidEnvKey(row.key) || isReservedEnvKey(row.key)) continue; + out[row.key] = row.value; + } + } catch { + // tolerate a missing/locked DB at spawn time. + } + return out; +} + +export class InvalidEnvKeyError extends Error { + readonly code: "invalid_env_key" | "reserved_env_key"; + constructor(key: string, code: "invalid_env_key" | "reserved_env_key") { + super( + code === "reserved_env_key" + ? `Environment variable name is reserved for OpenWork internals: ${key}` + : `Invalid environment variable name: ${key}`, + ); + this.code = code; + } +} diff --git a/packages/desktop-db/src/import/desktop.ts b/packages/desktop-db/src/import/desktop.ts index f9c70ebf1a..9601ebcfc9 100644 --- a/packages/desktop-db/src/import/desktop.ts +++ b/packages/desktop-db/src/import/desktop.ts @@ -1,12 +1,19 @@ import { eq } from "drizzle-orm"; import type { DesktopDb } from "../client"; import { + envVarTable, migrationStateTable, preferenceTable, workspacePortTable, workspaceServerTokenTable, workspaceTable, } from "../schema/index"; +import { isReservedEnvKey, isValidEnvKey } from "../env-store"; +import { + BOOTSTRAP_API_BASE_URL_PREF, + BOOTSTRAP_BASE_URL_PREF, + BOOTSTRAP_REQUIRE_SIGNIN_PREF, +} from "../bootstrap"; import { type ImportResult, readJsonFile } from "./helpers"; import { fileFingerprint, snapshotOnce } from "./fingerprint"; @@ -192,10 +199,67 @@ export async function importElectronServerState(db: DesktopDb, path: string): Pr return { count, found: true }; } +interface EnvJsonFile { + schemaVersion?: number; + variables?: Array<{ key?: unknown; value?: unknown; updatedAt?: unknown }>; +} + +/** Import env.json -> env_var table (skips invalid + reserved keys). */ +export async function importEnvJson(db: DesktopDb, path: string): Promise { + const parsed = await readJsonFile(path); + if (!parsed) return { count: 0, found: false }; + const now = Date.now(); + let count = 0; + const variables = Array.isArray(parsed.variables) ? parsed.variables : []; + + db.transaction((tx) => { + for (const entry of variables) { + const key = typeof entry?.key === "string" ? entry.key : ""; + const value = typeof entry?.value === "string" ? entry.value : ""; + if (!isValidEnvKey(key) || isReservedEnvKey(key)) continue; + const updatedAt = typeof entry?.updatedAt === "number" ? entry.updatedAt : now; + tx.insert(envVarTable) + .values({ key, value, updatedAt }) + .onConflictDoUpdate({ target: envVarTable.key, set: { value, updatedAt } }) + .run(); + count += 1; + } + }); + + return { count, found: true }; +} + +interface BootstrapJsonFile { + baseUrl?: unknown; + apiBaseUrl?: unknown; + requireSignin?: unknown; +} + +/** Import desktop-bootstrap.json -> bootstrap preference rows. */ +export async function importDesktopBootstrap(db: DesktopDb, path: string): Promise { + const parsed = await readJsonFile(path); + if (!parsed) return { count: 0, found: false }; + const now = Date.now(); + const baseUrl = typeof parsed.baseUrl === "string" ? parsed.baseUrl.trim() : ""; + const apiBaseUrl = typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl.trim() : ""; + const requireSignin = parsed.requireSignin === true; + + // preference.value is NOT NULL JSON; store "" for unset URLs. + db.transaction((tx) => { + setPreference(tx as unknown as DesktopDb, BOOTSTRAP_BASE_URL_PREF, baseUrl, now); + setPreference(tx as unknown as DesktopDb, BOOTSTRAP_API_BASE_URL_PREF, apiBaseUrl, now); + setPreference(tx as unknown as DesktopDb, BOOTSTRAP_REQUIRE_SIGNIN_PREF, requireSignin, now); + }); + + return { count: 1, found: true }; +} + export interface DesktopImportOptions { workspacesPath: string; serverTokensPath: string; serverStatePath: string; + envPath?: string; + bootstrapPath?: string; } export type DesktopImportStatus = "imported" | "already-done" | "missing" | "error"; @@ -258,6 +322,13 @@ export async function runDesktopImportOnce( { key: "electron:openwork-server-state.json", path: options.serverStatePath, run: importElectronServerState }, ]; + if (options.envPath) { + sources.push({ key: "env.json", path: options.envPath, run: importEnvJson }); + } + if (options.bootstrapPath) { + sources.push({ key: "desktop-bootstrap.json", path: options.bootstrapPath, run: importDesktopBootstrap }); + } + const report: DesktopImportReport = {}; for (const source of sources) { diff --git a/packages/desktop-db/src/import/index.ts b/packages/desktop-db/src/import/index.ts index 2d1915b867..fd95097dcb 100644 --- a/packages/desktop-db/src/import/index.ts +++ b/packages/desktop-db/src/import/index.ts @@ -31,6 +31,8 @@ export { importElectronWorkspaces, importElectronServerTokens, importElectronServerState, + importEnvJson, + importDesktopBootstrap, runDesktopImportOnce, DESKTOP_SELECTED_WORKSPACE_PREF, DESKTOP_WATCHED_WORKSPACE_PREF, diff --git a/packages/desktop-db/src/index.ts b/packages/desktop-db/src/index.ts index 534afe2d15..11b3f9aea5 100644 --- a/packages/desktop-db/src/index.ts +++ b/packages/desktop-db/src/index.ts @@ -21,6 +21,26 @@ export { removePreference, removePreferences, } from "./preferences"; +export { + isValidEnvKey, + isReservedEnvKey, + listEnvVars, + getEnvVar, + upsertEnvVars, + deleteEnvVar, + readEnvForInjection, + InvalidEnvKeyError, + type EnvRecord, + type EnvEntry, +} from "./env-store"; +export { + BOOTSTRAP_BASE_URL_PREF, + BOOTSTRAP_API_BASE_URL_PREF, + BOOTSTRAP_REQUIRE_SIGNIN_PREF, + getDesktopBootstrapConfig, + setDesktopBootstrapConfig, + type DesktopBootstrapConfig, +} from "./bootstrap"; export { runPhase1Import, runPhase1ImportOnce, @@ -39,6 +59,8 @@ export { importElectronWorkspaces, importElectronServerTokens, importElectronServerState, + importEnvJson, + importDesktopBootstrap, runDesktopImportOnce, DESKTOP_SELECTED_WORKSPACE_PREF, DESKTOP_WATCHED_WORKSPACE_PREF, diff --git a/packages/desktop-db/tsup.config.ts b/packages/desktop-db/tsup.config.ts index e1bafd82c1..b7876fa996 100644 --- a/packages/desktop-db/tsup.config.ts +++ b/packages/desktop-db/tsup.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ "schema/index": "src/schema/index.ts", "import/index": "src/import/index.ts", preferences: "src/preferences.ts", + "env-store": "src/env-store.ts", + bootstrap: "src/bootstrap.ts", }, tsconfig: "./tsconfig.json", format: ["esm"], From 01627ebc4abf144061f2226fa839bfc89ceaea73 Mon Sep 17 00:00:00 2001 From: src-opn Date: Thu, 28 May 2026 19:19:52 -0700 Subject: [PATCH 3/3] refactor(desktop-db): import files once-ever without touching them Drop the .pre-db.bak snapshots. They copied/renamed source files, which broke rollback to a pre-DB app version that still reads the originals in place. New model: - Source files are NEVER modified, copied, renamed, or deleted. - migration_state records source path + a non-cryptographic content hash. - A source is imported AT MOST ONCE: once its row is "imported", it's skipped forever, regardless of later content changes (so a rollback->edit->upgrade cycle won't re-import). Shared once-ever gate in import/gate.ts used by both the server (Phase 1) and desktop importers. migration_state schema: replace backup_path with path + hash; migrations squashed to a single unreleased baseline. --- apps/desktop/electron/desktop-db.mjs | 3 +- apps/server/src/db.ts | 2 +- apps/server/src/embedded.ts | 3 +- ...hatterstar.sql => 0000_simple_chimera.sql} | 11 + .../drizzle/0001_shocking_mastermind.sql | 8 - .../drizzle/0002_flimsy_chamber.sql | 2 - .../drizzle/meta/0000_snapshot.json | 71 +- .../drizzle/meta/0001_snapshot.json | 1453 ---------------- .../drizzle/meta/0002_snapshot.json | 1467 ----------------- .../desktop-db/drizzle/meta/_journal.json | 18 +- .../desktop-db/src/desktop-import.test.ts | 4 +- .../src/env-bootstrap-import.test.ts | 4 +- packages/desktop-db/src/import-once.test.ts | 47 +- packages/desktop-db/src/import/desktop.ts | 106 +- packages/desktop-db/src/import/fingerprint.ts | 58 +- packages/desktop-db/src/import/gate.ts | 113 ++ packages/desktop-db/src/import/import-once.ts | 191 +-- packages/desktop-db/src/import/index.ts | 2 +- .../desktop-db/src/schema/migration-state.ts | 19 +- 19 files changed, 323 insertions(+), 3259 deletions(-) rename packages/desktop-db/drizzle/{0000_giant_shatterstar.sql => 0000_simple_chimera.sql} (95%) delete mode 100644 packages/desktop-db/drizzle/0001_shocking_mastermind.sql delete mode 100644 packages/desktop-db/drizzle/0002_flimsy_chamber.sql delete mode 100644 packages/desktop-db/drizzle/meta/0001_snapshot.json delete mode 100644 packages/desktop-db/drizzle/meta/0002_snapshot.json create mode 100644 packages/desktop-db/src/import/gate.ts diff --git a/apps/desktop/electron/desktop-db.mjs b/apps/desktop/electron/desktop-db.mjs index 1a39d72351..95f1495208 100644 --- a/apps/desktop/electron/desktop-db.mjs +++ b/apps/desktop/electron/desktop-db.mjs @@ -4,7 +4,8 @@ // desktop workspace list, per-workspace server tokens, and preferred ports are a single // source of truth shared with the server. The original Electron JSON files // (openwork-workspaces.json, openwork-server-tokens.json, openwork-server-state.json) -// are imported once and preserved as `.pre-db.bak` snapshots for revert. +// are imported once and left untouched in place (no copy/rename/delete) so an older +// (pre-DB) app version still works after a rollback. import { openDb, diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index 7217f427b7..650fc387f3 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -3,7 +3,7 @@ * * The DB is the runtime source of truth for OpenWork-owned state (server config, * workspace registry, tokens, audit). The original JSON/JSONL files are preserved - * (and snapshotted to `.pre-db.bak`) for revert; they are no longer written at runtime. + * for revert (imported once, left untouched in place); they are no longer written at runtime. */ import { homedir } from "node:os"; import { dirname, join } from "node:path"; diff --git a/apps/server/src/embedded.ts b/apps/server/src/embedded.ts index ef3656ceef..51628d6965 100644 --- a/apps/server/src/embedded.ts +++ b/apps/server/src/embedded.ts @@ -38,7 +38,8 @@ export type EmbeddedServerHandle = { export async function startEmbeddedServer(options: EmbeddedServerOptions): Promise { const config = await resolveServerConfig(options); // Import OpenWork-owned state into the DB once, then make the DB the source of truth - // for the workspace registry. Source files are preserved (snapshotted to .pre-db.bak). + // for the workspace registry. Source files are imported once and left untouched in + // place (no copy/rename/delete) so an older app version still works after a rollback. await reconcileConfigWithDb(config); const serverUrl = `http://${config.host === "0.0.0.0" ? "127.0.0.1" : config.host}:${config.port}`; const opencodeModelsUrl = process.env.OPENWORK_DEV_MODE === "1" diff --git a/packages/desktop-db/drizzle/0000_giant_shatterstar.sql b/packages/desktop-db/drizzle/0000_simple_chimera.sql similarity index 95% rename from packages/desktop-db/drizzle/0000_giant_shatterstar.sql rename to packages/desktop-db/drizzle/0000_simple_chimera.sql index ce73a80fd0..ac70457708 100644 --- a/packages/desktop-db/drizzle/0000_giant_shatterstar.sql +++ b/packages/desktop-db/drizzle/0000_simple_chimera.sql @@ -56,6 +56,8 @@ CREATE TABLE `workspace` ( `display_name` text, `openwork_host_url` text, `openwork_token` text, + `openwork_client_token` text, + `openwork_host_token` text, `openwork_workspace_id` text, `openwork_workspace_name` text, `sandbox_backend` text, @@ -217,3 +219,12 @@ CREATE TABLE `preference` ( `created_at` integer NOT NULL, `updated_at` integer NOT NULL ); +--> statement-breakpoint +CREATE TABLE `migration_state` ( + `source` text PRIMARY KEY NOT NULL, + `status` text NOT NULL, + `path` text DEFAULT '' NOT NULL, + `hash` text DEFAULT '' NOT NULL, + `row_count` integer DEFAULT 0 NOT NULL, + `imported_at` integer NOT NULL +); diff --git a/packages/desktop-db/drizzle/0001_shocking_mastermind.sql b/packages/desktop-db/drizzle/0001_shocking_mastermind.sql deleted file mode 100644 index 38f505aec1..0000000000 --- a/packages/desktop-db/drizzle/0001_shocking_mastermind.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE `migration_state` ( - `source` text PRIMARY KEY NOT NULL, - `status` text NOT NULL, - `fingerprint` text DEFAULT '' NOT NULL, - `row_count` integer DEFAULT 0 NOT NULL, - `backup_path` text, - `imported_at` integer NOT NULL -); diff --git a/packages/desktop-db/drizzle/0002_flimsy_chamber.sql b/packages/desktop-db/drizzle/0002_flimsy_chamber.sql deleted file mode 100644 index f20636ca1d..0000000000 --- a/packages/desktop-db/drizzle/0002_flimsy_chamber.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE `workspace` ADD `openwork_client_token` text;--> statement-breakpoint -ALTER TABLE `workspace` ADD `openwork_host_token` text; \ No newline at end of file diff --git a/packages/desktop-db/drizzle/meta/0000_snapshot.json b/packages/desktop-db/drizzle/meta/0000_snapshot.json index f7142dcbef..f7559d57fc 100644 --- a/packages/desktop-db/drizzle/meta/0000_snapshot.json +++ b/packages/desktop-db/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "1e401508-588e-453e-b8e6-358576b3733e", + "id": "2c05c56f-6a99-486a-a72f-528d8a88321d", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "authorized_root": { @@ -397,6 +397,20 @@ "notNull": false, "autoincrement": false }, + "openwork_client_token": { + "name": "openwork_client_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openwork_host_token": { + "name": "openwork_host_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, "openwork_workspace_id": { "name": "openwork_workspace_id", "type": "text", @@ -1384,6 +1398,61 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} + }, + "migration_state": { + "name": "migration_state", + "columns": { + "source": { + "name": "source", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} } }, "views": {}, diff --git a/packages/desktop-db/drizzle/meta/0001_snapshot.json b/packages/desktop-db/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 0438fbd9f7..0000000000 --- a/packages/desktop-db/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,1453 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "b370e05c-6a91-437b-8f03-0fdb17bac893", - "prevId": "1e401508-588e-453e-b8e6-358576b3733e", - "tables": { - "authorized_root": { - "name": "authorized_root", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "authorized_root_path_unique": { - "name": "authorized_root_path_unique", - "columns": [ - "path" - ], - "isUnique": true - } - }, - "foreignKeys": { - "authorized_root_workspace_id_workspace_id_fk": { - "name": "authorized_root_workspace_id_workspace_id_fk", - "tableFrom": "authorized_root", - "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "blueprint_session": { - "name": "blueprint_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "template_id": { - "name": "template_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session_id": { - "name": "session_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "hydrated_at": { - "name": "hydrated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "blueprint_session_ws_template_unique": { - "name": "blueprint_session_ws_template_unique", - "columns": [ - "workspace_id", - "template_id" - ], - "isUnique": true - } - }, - "foreignKeys": { - "blueprint_session_workspace_id_workspace_id_fk": { - "name": "blueprint_session_workspace_id_workspace_id_fk", - "tableFrom": "blueprint_session", - "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "desktop_cloud_sync": { - "name": "desktop_cloud_sync", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "context_key": { - "name": "context_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "org_member_id": { - "name": "org_member_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "data": { - "name": "data", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "fetched_at": { - "name": "fetched_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "desktop_cloud_sync_ws_context_unique": { - "name": "desktop_cloud_sync_ws_context_unique", - "columns": [ - "workspace_id", - "context_key" - ], - "isUnique": true - } - }, - "foreignKeys": { - "desktop_cloud_sync_workspace_id_workspace_id_fk": { - "name": "desktop_cloud_sync_workspace_id_workspace_id_fk", - "tableFrom": "desktop_cloud_sync", - "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "workspace_meta": { - "name": "workspace_meta", - "columns": { - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 1 - }, - "workspace_name": { - "name": "workspace_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "preset": { - "name": "preset", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "workspace_meta_workspace_id_workspace_id_fk": { - "name": "workspace_meta_workspace_id_workspace_id_fk", - "tableFrom": "workspace_meta", - "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "workspace": { - "name": "workspace", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "preset": { - "name": "preset", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "workspace_type": { - "name": "workspace_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'local'" - }, - "remote_type": { - "name": "remote_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "directory": { - "name": "directory", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "openwork_host_url": { - "name": "openwork_host_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "openwork_token": { - "name": "openwork_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "openwork_workspace_id": { - "name": "openwork_workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "openwork_workspace_name": { - "name": "openwork_workspace_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sandbox_backend": { - "name": "sandbox_backend", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sandbox_run_id": { - "name": "sandbox_run_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sandbox_container_name": { - "name": "sandbox_container_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "opencode_username": { - "name": "opencode_username", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "opencode_password": { - "name": "opencode_password", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "workspace_path_unique": { - "name": "workspace_path_unique", - "columns": [ - "path" - ], - "isUnique": true - }, - "workspace_sort_order_idx": { - "name": "workspace_sort_order_idx", - "columns": [ - "sort_order" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "token": { - "name": "token", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "hash": { - "name": "hash", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "token_hash_unique": { - "name": "token_hash_unique", - "columns": [ - "hash" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "workspace_port": { - "name": "workspace_port", - "columns": { - "workspace_key": { - "name": "workspace_key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "port": { - "name": "port", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "workspace_port_port_idx": { - "name": "workspace_port_port_idx", - "columns": [ - "port" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "workspace_server_token": { - "name": "workspace_server_token", - "columns": { - "workspace_key": { - "name": "workspace_key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "client_token": { - "name": "client_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "host_token": { - "name": "host_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "owner_token": { - "name": "owner_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "server_config": { - "name": "server_config", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "env_var": { - "name": "env_var", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "env_var_key_unique": { - "name": "env_var_key_unique", - "columns": [ - "key" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "audit": { - "name": "audit", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "''" - }, - "actor": { - "name": "actor", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target": { - "name": "target", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "summary": { - "name": "summary", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "timestamp": { - "name": "timestamp", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "audit_workspace_timestamp_idx": { - "name": "audit_workspace_timestamp_idx", - "columns": [ - "workspace_id", - "timestamp" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "file_session_event": { - "name": "file_session_event", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "seq": { - "name": "seq", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "to_path": { - "name": "to_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "revision": { - "name": "revision", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "timestamp": { - "name": "timestamp", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "file_session_event_ws_seq_unique": { - "name": "file_session_event_ws_seq_unique", - "columns": [ - "workspace_id", - "seq" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "file_session": { - "name": "file_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "workspace_root": { - "name": "workspace_root", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "actor_token_hash": { - "name": "actor_token_hash", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "actor_scope": { - "name": "actor_scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "can_write": { - "name": "can_write", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "file_session_workspace_idx": { - "name": "file_session_workspace_idx", - "columns": [ - "workspace_id" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "session_pref": { - "name": "session_pref", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "session_id": { - "name": "session_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "session_pref_session_key_unique": { - "name": "session_pref_session_key_unique", - "columns": [ - "session_id", - "key" - ], - "isUnique": true - }, - "session_pref_workspace_idx": { - "name": "session_pref_workspace_idx", - "columns": [ - "workspace_id" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_server": { - "name": "mcp_server", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'project'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "enabled": { - "name": "enabled", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_server_ws_name_unique": { - "name": "mcp_server_ws_name_unique", - "columns": [ - "workspace_id", - "name" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "opencode_config": { - "name": "opencode_config", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'project'" - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "opencode_config_ws_scope_key_unique": { - "name": "opencode_config_ws_scope_key_unique", - "columns": [ - "workspace_id", - "scope", - "key" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "plugin_entry": { - "name": "plugin_entry", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'project'" - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "plugin_entry_ws_spec_unique": { - "name": "plugin_entry_ws_spec_unique", - "columns": [ - "workspace_id", - "spec" - ], - "isUnique": true - }, - "plugin_entry_sort_order_idx": { - "name": "plugin_entry_sort_order_idx", - "columns": [ - "sort_order" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "extension_state": { - "name": "extension_state", - "columns": { - "extension_id": { - "name": "extension_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "enabled": { - "name": "enabled", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "hidden": { - "name": "hidden", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_workspace_vault": { - "name": "google_workspace_vault", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "account_sub": { - "name": "account_sub", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "data": { - "name": "data", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "encrypted": { - "name": "encrypted", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "preference": { - "name": "preference", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "migration_state": { - "name": "migration_state", - "columns": { - "source": { - "name": "source", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "fingerprint": { - "name": "fingerprint", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "''" - }, - "row_count": { - "name": "row_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "backup_path": { - "name": "backup_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "imported_at": { - "name": "imported_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/packages/desktop-db/drizzle/meta/0002_snapshot.json b/packages/desktop-db/drizzle/meta/0002_snapshot.json deleted file mode 100644 index aae4037874..0000000000 --- a/packages/desktop-db/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,1467 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "6bb7df69-b547-44c8-afc0-85e27846fe3f", - "prevId": "b370e05c-6a91-437b-8f03-0fdb17bac893", - "tables": { - "authorized_root": { - "name": "authorized_root", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "authorized_root_path_unique": { - "name": "authorized_root_path_unique", - "columns": [ - "path" - ], - "isUnique": true - } - }, - "foreignKeys": { - "authorized_root_workspace_id_workspace_id_fk": { - "name": "authorized_root_workspace_id_workspace_id_fk", - "tableFrom": "authorized_root", - "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "blueprint_session": { - "name": "blueprint_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "template_id": { - "name": "template_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session_id": { - "name": "session_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "hydrated_at": { - "name": "hydrated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "blueprint_session_ws_template_unique": { - "name": "blueprint_session_ws_template_unique", - "columns": [ - "workspace_id", - "template_id" - ], - "isUnique": true - } - }, - "foreignKeys": { - "blueprint_session_workspace_id_workspace_id_fk": { - "name": "blueprint_session_workspace_id_workspace_id_fk", - "tableFrom": "blueprint_session", - "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "desktop_cloud_sync": { - "name": "desktop_cloud_sync", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "context_key": { - "name": "context_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "org_member_id": { - "name": "org_member_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "data": { - "name": "data", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "fetched_at": { - "name": "fetched_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "desktop_cloud_sync_ws_context_unique": { - "name": "desktop_cloud_sync_ws_context_unique", - "columns": [ - "workspace_id", - "context_key" - ], - "isUnique": true - } - }, - "foreignKeys": { - "desktop_cloud_sync_workspace_id_workspace_id_fk": { - "name": "desktop_cloud_sync_workspace_id_workspace_id_fk", - "tableFrom": "desktop_cloud_sync", - "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "workspace_meta": { - "name": "workspace_meta", - "columns": { - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 1 - }, - "workspace_name": { - "name": "workspace_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "preset": { - "name": "preset", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "workspace_meta_workspace_id_workspace_id_fk": { - "name": "workspace_meta_workspace_id_workspace_id_fk", - "tableFrom": "workspace_meta", - "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "workspace": { - "name": "workspace", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "preset": { - "name": "preset", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "workspace_type": { - "name": "workspace_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'local'" - }, - "remote_type": { - "name": "remote_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "directory": { - "name": "directory", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "openwork_host_url": { - "name": "openwork_host_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "openwork_token": { - "name": "openwork_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "openwork_client_token": { - "name": "openwork_client_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "openwork_host_token": { - "name": "openwork_host_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "openwork_workspace_id": { - "name": "openwork_workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "openwork_workspace_name": { - "name": "openwork_workspace_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sandbox_backend": { - "name": "sandbox_backend", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sandbox_run_id": { - "name": "sandbox_run_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sandbox_container_name": { - "name": "sandbox_container_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "opencode_username": { - "name": "opencode_username", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "opencode_password": { - "name": "opencode_password", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "workspace_path_unique": { - "name": "workspace_path_unique", - "columns": [ - "path" - ], - "isUnique": true - }, - "workspace_sort_order_idx": { - "name": "workspace_sort_order_idx", - "columns": [ - "sort_order" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "token": { - "name": "token", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "hash": { - "name": "hash", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "token_hash_unique": { - "name": "token_hash_unique", - "columns": [ - "hash" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "workspace_port": { - "name": "workspace_port", - "columns": { - "workspace_key": { - "name": "workspace_key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "port": { - "name": "port", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "workspace_port_port_idx": { - "name": "workspace_port_port_idx", - "columns": [ - "port" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "workspace_server_token": { - "name": "workspace_server_token", - "columns": { - "workspace_key": { - "name": "workspace_key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "client_token": { - "name": "client_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "host_token": { - "name": "host_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "owner_token": { - "name": "owner_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "server_config": { - "name": "server_config", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "env_var": { - "name": "env_var", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "env_var_key_unique": { - "name": "env_var_key_unique", - "columns": [ - "key" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "audit": { - "name": "audit", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "''" - }, - "actor": { - "name": "actor", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target": { - "name": "target", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "summary": { - "name": "summary", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "timestamp": { - "name": "timestamp", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "audit_workspace_timestamp_idx": { - "name": "audit_workspace_timestamp_idx", - "columns": [ - "workspace_id", - "timestamp" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "file_session_event": { - "name": "file_session_event", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "seq": { - "name": "seq", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "to_path": { - "name": "to_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "revision": { - "name": "revision", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "timestamp": { - "name": "timestamp", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "file_session_event_ws_seq_unique": { - "name": "file_session_event_ws_seq_unique", - "columns": [ - "workspace_id", - "seq" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "file_session": { - "name": "file_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "workspace_root": { - "name": "workspace_root", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "actor_token_hash": { - "name": "actor_token_hash", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "actor_scope": { - "name": "actor_scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "can_write": { - "name": "can_write", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "file_session_workspace_idx": { - "name": "file_session_workspace_idx", - "columns": [ - "workspace_id" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "session_pref": { - "name": "session_pref", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "session_id": { - "name": "session_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "session_pref_session_key_unique": { - "name": "session_pref_session_key_unique", - "columns": [ - "session_id", - "key" - ], - "isUnique": true - }, - "session_pref_workspace_idx": { - "name": "session_pref_workspace_idx", - "columns": [ - "workspace_id" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_server": { - "name": "mcp_server", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'project'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "enabled": { - "name": "enabled", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_server_ws_name_unique": { - "name": "mcp_server_ws_name_unique", - "columns": [ - "workspace_id", - "name" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "opencode_config": { - "name": "opencode_config", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'project'" - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "opencode_config_ws_scope_key_unique": { - "name": "opencode_config_ws_scope_key_unique", - "columns": [ - "workspace_id", - "scope", - "key" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "plugin_entry": { - "name": "plugin_entry", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'project'" - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "plugin_entry_ws_spec_unique": { - "name": "plugin_entry_ws_spec_unique", - "columns": [ - "workspace_id", - "spec" - ], - "isUnique": true - }, - "plugin_entry_sort_order_idx": { - "name": "plugin_entry_sort_order_idx", - "columns": [ - "sort_order" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "extension_state": { - "name": "extension_state", - "columns": { - "extension_id": { - "name": "extension_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "enabled": { - "name": "enabled", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "hidden": { - "name": "hidden", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_workspace_vault": { - "name": "google_workspace_vault", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "account_sub": { - "name": "account_sub", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "data": { - "name": "data", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "encrypted": { - "name": "encrypted", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "preference": { - "name": "preference", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "migration_state": { - "name": "migration_state", - "columns": { - "source": { - "name": "source", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "fingerprint": { - "name": "fingerprint", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "''" - }, - "row_count": { - "name": "row_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "backup_path": { - "name": "backup_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "imported_at": { - "name": "imported_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/packages/desktop-db/drizzle/meta/_journal.json b/packages/desktop-db/drizzle/meta/_journal.json index f8c9314ef3..5ad3e03b6d 100644 --- a/packages/desktop-db/drizzle/meta/_journal.json +++ b/packages/desktop-db/drizzle/meta/_journal.json @@ -5,22 +5,8 @@ { "idx": 0, "version": "6", - "when": 1780012089645, - "tag": "0000_giant_shatterstar", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1780013417000, - "tag": "0001_shocking_mastermind", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1780014910749, - "tag": "0002_flimsy_chamber", + "when": 1780021044396, + "tag": "0000_simple_chimera", "breakpoints": true } ] diff --git a/packages/desktop-db/src/desktop-import.test.ts b/packages/desktop-db/src/desktop-import.test.ts index 806a4b916e..4b41099219 100644 --- a/packages/desktop-db/src/desktop-import.test.ts +++ b/packages/desktop-db/src/desktop-import.test.ts @@ -84,7 +84,9 @@ describe("runDesktopImportOnce", () => { expect(report["electron:openwork-server-tokens.json"]!.status).toBe("imported"); expect(report["electron:openwork-server-state.json"]!.status).toBe("imported"); - expect(existsSync(`${workspacesPath}.pre-db.bak`)).toBe(true); + // Source files are left in place, untouched (no .bak snapshot). + expect(existsSync(workspacesPath)).toBe(true); + expect(existsSync(`${workspacesPath}.pre-db.bak`)).toBe(false); const workspaces = db.select().from(workspaceTable).all(); expect(workspaces.length).toBe(2); diff --git a/packages/desktop-db/src/env-bootstrap-import.test.ts b/packages/desktop-db/src/env-bootstrap-import.test.ts index 33a3edc650..ce11e8715a 100644 --- a/packages/desktop-db/src/env-bootstrap-import.test.ts +++ b/packages/desktop-db/src/env-bootstrap-import.test.ts @@ -49,7 +49,9 @@ describe("env + bootstrap import", () => { expect(report["env.json"]!.status).toBe("imported"); expect(report["desktop-bootstrap.json"]!.status).toBe("imported"); - expect(existsSync(`${envPath}.pre-db.bak`)).toBe(true); + // Source file left untouched (no .bak snapshot). + expect(existsSync(envPath)).toBe(true); + expect(existsSync(`${envPath}.pre-db.bak`)).toBe(false); const envVars = await listEnvVars(db); expect(envVars.map((v) => v.key)).toEqual(["ANTHROPIC_API_KEY"]); diff --git a/packages/desktop-db/src/import-once.test.ts b/packages/desktop-db/src/import-once.test.ts index 6e1fb39f58..1f9326e456 100644 --- a/packages/desktop-db/src/import-once.test.ts +++ b/packages/desktop-db/src/import-once.test.ts @@ -42,8 +42,9 @@ function setup() { } describe("runPhase1ImportOnce", () => { - test("imports once, snapshots .pre-db.bak, and skips on re-run", async () => { + test("imports once, never touches the source files, and skips on re-run", async () => { const { serverJsonPath, tokensJsonPath, auditDir } = setup(); + const serverJsonBefore = readFileSync(serverJsonPath, "utf8"); const db = await openDb({ path: join(tmp!, "test.db") }); const first = await runPhase1ImportOnce(db, { serverJsonPath, tokensJsonPath, auditDir }); @@ -51,20 +52,22 @@ describe("runPhase1ImportOnce", () => { expect(first["tokens.json"]!.status).toBe("imported"); expect(first.audit!.status).toBe("imported"); - // Source files are preserved + a .pre-db.bak snapshot exists. + // Source files are left EXACTLY in place: unchanged content, no .bak/.tmp siblings. expect(existsSync(serverJsonPath)).toBe(true); - expect(existsSync(`${serverJsonPath}.pre-db.bak`)).toBe(true); - expect(existsSync(`${tokensJsonPath}.pre-db.bak`)).toBe(true); - expect(readFileSync(`${serverJsonPath}.pre-db.bak`, "utf8")).toBe( - readFileSync(serverJsonPath, "utf8"), - ); + expect(readFileSync(serverJsonPath, "utf8")).toBe(serverJsonBefore); + expect(existsSync(`${serverJsonPath}.pre-db.bak`)).toBe(false); + expect(existsSync(`${tokensJsonPath}.pre-db.bak`)).toBe(false); + expect(existsSync(join(auditDir, "..", "audit-pre-db-bak"))).toBe(false); expect(db.select().from(workspaceTable).all().length).toBe(1); expect(db.select().from(tokenTable).all().length).toBe(1); - // migration_state recorded. + // migration_state records path + hash. const state = db.select().from(migrationStateTable).all(); - expect(state.find((s) => s.source === "server.json")?.status).toBe("imported"); + const serverRow = state.find((s) => s.source === "server.json"); + expect(serverRow?.status).toBe("imported"); + expect(serverRow?.path).toBe(serverJsonPath); + expect(serverRow?.hash.length).toBeGreaterThan(0); // Re-run with unchanged sources -> already-done, no duplicate rows. const second = await runPhase1ImportOnce(db, { serverJsonPath, tokensJsonPath, auditDir }); @@ -74,6 +77,32 @@ describe("runPhase1ImportOnce", () => { expect(db.select().from(tokenTable).all().length).toBe(1); }); + test("imports ONCE EVER: later source edits are ignored", async () => { + const { serverJsonPath, tokensJsonPath, auditDir } = setup(); + const db = await openDb({ path: join(tmp!, "test.db") }); + + await runPhase1ImportOnce(db, { serverJsonPath, tokensJsonPath, auditDir }); + expect(db.select().from(workspaceTable).all().length).toBe(1); + + // Simulate an older app version editing the original file after import. + writeFileSync( + serverJsonPath, + JSON.stringify({ + port: 8787, + workspaces: [ + { id: "ws_a", path: "/tmp/a", name: "A" }, + { id: "ws_b", path: "/tmp/b", name: "B" }, + ], + authorizedRoots: ["/tmp/a", "/tmp/b"], + }), + ); + + const second = await runPhase1ImportOnce(db, { serverJsonPath, tokensJsonPath, auditDir }); + // Already imported once -> skipped despite the new content. + expect(second["server.json"]!.status).toBe("already-done"); + expect(db.select().from(workspaceTable).all().length).toBe(1); + }); + test("reports missing sources without error", async () => { tmp = mkdtempSync(join(tmpdir(), "owimport-")); const db = await openDb({ path: join(tmp, "test.db") }); diff --git a/packages/desktop-db/src/import/desktop.ts b/packages/desktop-db/src/import/desktop.ts index 9601ebcfc9..93405becd2 100644 --- a/packages/desktop-db/src/import/desktop.ts +++ b/packages/desktop-db/src/import/desktop.ts @@ -1,8 +1,6 @@ -import { eq } from "drizzle-orm"; import type { DesktopDb } from "../client"; import { envVarTable, - migrationStateTable, preferenceTable, workspacePortTable, workspaceServerTokenTable, @@ -15,7 +13,7 @@ import { BOOTSTRAP_REQUIRE_SIGNIN_PREF, } from "../bootstrap"; import { type ImportResult, readJsonFile } from "./helpers"; -import { fileFingerprint, snapshotOnce } from "./fingerprint"; +import { runImportSourcesOnce, type ImportSource } from "./gate"; /** * Importers for the Electron desktop-only state files (under `app.getPath("userData")`): @@ -262,105 +260,33 @@ export interface DesktopImportOptions { bootstrapPath?: string; } -export type DesktopImportStatus = "imported" | "already-done" | "missing" | "error"; - -export interface DesktopImportEntry { - source: string; - status: DesktopImportStatus; - fingerprint: string; - rowCount: number; - backupPath: string | null; - error?: string; -} - -export type DesktopImportReport = Record; - -async function getState(db: DesktopDb, source: string) { - const rows = await db - .select() - .from(migrationStateTable) - .where(eq(migrationStateTable.source, source)); - return rows[0] ?? null; -} - -function recordState( - db: DesktopDb, - entry: { source: string; status: string; fingerprint: string; rowCount: number; backupPath: string | null }, -) { - const now = Date.now(); - db.insert(migrationStateTable) - .values({ ...entry, importedAt: now }) - .onConflictDoUpdate({ - target: migrationStateTable.source, - set: { - status: entry.status, - fingerprint: entry.fingerprint, - rowCount: entry.rowCount, - backupPath: entry.backupPath, - importedAt: now, - }, - }) - .run(); -} +export type { ImportOnceStatus as DesktopImportStatus, ImportOnceEntry as DesktopImportEntry } from "./gate"; +export type DesktopImportReport = Record; /** - * One-time import of the three Electron state files, gated by `migration_state` - * (keyed `electron:`). Snapshots `.pre-db.bak`, preserves the originals, and - * skips when the source fingerprint is unchanged. Cheap on subsequent starts. + * One-time import of the Electron state files (+ env.json, desktop-bootstrap.json), + * gated by `migration_state` (keyed `electron:` / `env.json` / etc.). + * + * Each source is imported AT MOST ONCE. Source files are never modified, copied, or + * deleted — they stay in place so an older (pre-DB) app version still works after a + * rollback. Cheap on subsequent starts (a single DB lookup per source before file I/O). */ export async function runDesktopImportOnce( db: DesktopDb, options: DesktopImportOptions, ): Promise { - const sources: Array<{ - key: string; - path: string; - run: (db: DesktopDb, path: string) => Promise; - }> = [ - { key: "electron:openwork-workspaces.json", path: options.workspacesPath, run: importElectronWorkspaces }, - { key: "electron:openwork-server-tokens.json", path: options.serverTokensPath, run: importElectronServerTokens }, - { key: "electron:openwork-server-state.json", path: options.serverStatePath, run: importElectronServerState }, + const sources: ImportSource[] = [ + { key: "electron:openwork-workspaces.json", path: options.workspacesPath, kind: "file", run: importElectronWorkspaces }, + { key: "electron:openwork-server-tokens.json", path: options.serverTokensPath, kind: "file", run: importElectronServerTokens }, + { key: "electron:openwork-server-state.json", path: options.serverStatePath, kind: "file", run: importElectronServerState }, ]; if (options.envPath) { - sources.push({ key: "env.json", path: options.envPath, run: importEnvJson }); + sources.push({ key: "env.json", path: options.envPath, kind: "file", run: importEnvJson }); } if (options.bootstrapPath) { - sources.push({ key: "desktop-bootstrap.json", path: options.bootstrapPath, run: importDesktopBootstrap }); - } - - const report: DesktopImportReport = {}; - - for (const source of sources) { - const fingerprint = await fileFingerprint(source.path); - if (fingerprint === null) { - report[source.key] = { source: source.key, status: "missing", fingerprint: "", rowCount: 0, backupPath: null }; - continue; - } - - const prior = await getState(db, source.key); - if (prior && prior.status === "imported" && prior.fingerprint === fingerprint) { - report[source.key] = { - source: source.key, - status: "already-done", - fingerprint, - rowCount: prior.rowCount, - backupPath: prior.backupPath ?? null, - }; - continue; - } - - try { - const backupPath = await snapshotOnce(source.path); - const result = await source.run(db, source.path); - recordState(db, { source: source.key, status: "imported", fingerprint, rowCount: result.count, backupPath }); - report[source.key] = { source: source.key, status: "imported", fingerprint, rowCount: result.count, backupPath }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - recordState(db, { source: source.key, status: "error", fingerprint, rowCount: 0, backupPath: null }); - report[source.key] = { source: source.key, status: "error", fingerprint, rowCount: 0, backupPath: null, error: message }; - } + sources.push({ key: "desktop-bootstrap.json", path: options.bootstrapPath, kind: "file", run: importDesktopBootstrap }); } - return report; + return runImportSourcesOnce(db, sources); } diff --git a/packages/desktop-db/src/import/fingerprint.ts b/packages/desktop-db/src/import/fingerprint.ts index 8e0dfb2d26..2407ad1c3a 100644 --- a/packages/desktop-db/src/import/fingerprint.ts +++ b/packages/desktop-db/src/import/fingerprint.ts @@ -1,20 +1,35 @@ -import { copyFile, readdir, stat } from "node:fs/promises"; +import { readFile, readdir, stat } from "node:fs/promises"; -/** ":" for a file, or null if it doesn't exist. */ -export async function fileFingerprint(path: string): Promise { +/** + * Fast, non-cryptographic content hash (FNV-1a, 32-bit, hex). Used only to record + * "what we imported" for diagnostics — NOT for security. We never modify or copy the + * source files; they stay exactly where they are so an older (pre-DB) app version can + * still read them after a rollback. + */ +function fnv1a(bytes: Uint8Array): string { + let hash = 0x811c9dc5; + for (let i = 0; i < bytes.length; i += 1) { + hash ^= bytes[i]!; + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +/** Content hash for a file, or null if it doesn't exist. Never mutates the file. */ +export async function fileHash(path: string): Promise { try { - const s = await stat(path); - return `${Math.round(s.mtimeMs)}:${s.size}`; + const buf = await readFile(path); + return fnv1a(new Uint8Array(buf)); } catch { return null; } } /** - * A combined fingerprint for a directory of files (used for the audit dir): sorted - * "=:" pairs joined with "|". Returns null if the dir is absent. + * Combined content hash for a directory of files (used for the audit dir): sorted + * "=" pairs joined with "|". Returns null if the dir is absent/empty. */ -export async function dirFingerprint(dir: string, suffix = ""): Promise { +export async function dirHash(dir: string, suffix = ""): Promise { let names: string[]; try { names = await readdir(dir); @@ -24,24 +39,19 @@ export async function dirFingerprint(dir: string, suffix = ""): Promise.pre-db.bak` snapshot of a source file (only if the source - * exists and the backup doesn't already exist). Returns the backup path, or null if - * the source is missing. Never deletes or modifies the source. - */ -export async function snapshotOnce(path: string): Promise { - const fp = await fileFingerprint(path); - if (!fp) return null; - const backup = `${path}.pre-db.bak`; - const existing = await fileFingerprint(backup); - if (!existing) { - await copyFile(path, backup); +/** Whether a file exists (no read). */ +export async function fileExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; } - return backup; } diff --git a/packages/desktop-db/src/import/gate.ts b/packages/desktop-db/src/import/gate.ts new file mode 100644 index 0000000000..e743692e36 --- /dev/null +++ b/packages/desktop-db/src/import/gate.ts @@ -0,0 +1,113 @@ +import { eq } from "drizzle-orm"; +import type { DesktopDb } from "../client"; +import { migrationStateTable } from "../schema/index"; +import { dirHash, fileHash } from "./fingerprint"; +import type { ImportResult } from "./helpers"; + +/** + * Shared "import a source exactly once, ever" gate for the migration_state table. + * + * Contract: + * - Source files are NEVER modified, copied, renamed, or deleted. They stay in place so + * an older (pre-DB) app version still works after a rollback. + * - A source is imported AT MOST ONCE: once its migration_state row has status + * "imported", we skip it forever, regardless of later content changes. + * - We record the source path + a non-cryptographic content hash for diagnostics only. + */ + +export type ImportOnceStatus = "imported" | "already-done" | "missing" | "error"; + +export interface ImportOnceEntry { + source: string; + status: ImportOnceStatus; + hash: string; + rowCount: number; + error?: string; +} + +export type ImportSource = { + /** Stable migration_state key (e.g. "server.json", "electron:openwork-workspaces.json"). */ + key: string; + /** Absolute path of the file or directory. */ + path: string; + /** "file" (hash file contents) or "dir" (hash dir listing). */ + kind: "file" | "dir"; + /** For "dir" kind, only files with this suffix are considered (e.g. ".jsonl"). */ + suffix?: string; + /** Import the source into the DB. Only invoked when not already imported. */ + run: (db: DesktopDb, path: string) => Promise; +}; + +async function sourceHash(source: ImportSource): Promise { + return source.kind === "dir" ? dirHash(source.path, source.suffix) : fileHash(source.path); +} + +function recordState( + db: DesktopDb, + entry: { source: string; status: ImportOnceStatus; path: string; hash: string; rowCount: number }, +) { + const now = Date.now(); + db.insert(migrationStateTable) + .values({ ...entry, importedAt: now }) + .onConflictDoUpdate({ + target: migrationStateTable.source, + set: { + status: entry.status, + path: entry.path, + hash: entry.hash, + rowCount: entry.rowCount, + importedAt: now, + }, + }) + .run(); +} + +/** Run a set of sources through the once-ever gate. */ +export async function runImportSourcesOnce( + db: DesktopDb, + sources: ImportSource[], +): Promise> { + const report: Record = {}; + + for (const source of sources) { + // Gate purely on "have we imported this source before?" — never re-import on change. + const priorRows = await db + .select() + .from(migrationStateTable) + .where(eq(migrationStateTable.source, source.key)); + const prior = priorRows[0]; + if (prior && prior.status === "imported") { + report[source.key] = { + source: source.key, + status: "already-done", + hash: prior.hash, + rowCount: prior.rowCount, + }; + continue; + } + + const hash = await sourceHash(source); + if (hash === null) { + report[source.key] = { source: source.key, status: "missing", hash: "", rowCount: 0 }; + continue; + } + + try { + const result = await source.run(db, source.path); + recordState(db, { + source: source.key, + status: "imported", + path: source.path, + hash, + rowCount: result.count, + }); + report[source.key] = { source: source.key, status: "imported", hash, rowCount: result.count }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + recordState(db, { source: source.key, status: "error", path: source.path, hash, rowCount: 0 }); + report[source.key] = { source: source.key, status: "error", hash, rowCount: 0, error: message }; + } + } + + return report; +} diff --git a/packages/desktop-db/src/import/import-once.ts b/packages/desktop-db/src/import/import-once.ts index c32ce3ea63..7b8a381ab2 100644 --- a/packages/desktop-db/src/import/import-once.ts +++ b/packages/desktop-db/src/import/import-once.ts @@ -1,12 +1,13 @@ -import { copyFile, mkdir, readdir } from "node:fs/promises"; -import { join } from "node:path"; -import { eq } from "drizzle-orm"; import type { DesktopDb } from "../client"; -import { migrationStateTable } from "../schema/index"; import { importServerJson } from "./server-json"; import { importTokensJson } from "./tokens-json"; import { importAuditDir } from "./audit-jsonl"; -import { dirFingerprint, fileFingerprint, snapshotOnce } from "./fingerprint"; +import { + runImportSourcesOnce, + type ImportOnceEntry, + type ImportOnceStatus, + type ImportSource, +} from "./gate"; import { resolveAuditDir, resolveServerJsonPath, @@ -14,86 +15,16 @@ import { type ImportOptions, } from "./paths"; -export type ImportOnceStatus = "imported" | "already-done" | "missing" | "error"; - -export interface ImportOnceEntry { - source: string; - status: ImportOnceStatus; - fingerprint: string; - rowCount: number; - backupPath: string | null; - error?: string; -} - +export type { ImportOnceStatus, ImportOnceEntry } from "./gate"; export type ImportOnceReport = Record; -async function getState(db: DesktopDb, source: string) { - const rows = await db - .select() - .from(migrationStateTable) - .where(eq(migrationStateTable.source, source)); - return rows[0] ?? null; -} - -function recordState( - db: DesktopDb, - entry: { source: string; status: string; fingerprint: string; rowCount: number; backupPath: string | null }, -) { - const now = Date.now(); - db.insert(migrationStateTable) - .values({ ...entry, importedAt: now }) - .onConflictDoUpdate({ - target: migrationStateTable.source, - set: { - status: entry.status, - fingerprint: entry.fingerprint, - rowCount: entry.rowCount, - backupPath: entry.backupPath, - importedAt: now, - }, - }) - .run(); -} - /** - * Snapshot every `*.jsonl` in the audit dir into `/../audit-pre-db-bak/`. - * Returns the backup dir path, or null if the audit dir is absent. - */ -async function snapshotAuditDir(auditDir: string): Promise { - let names: string[]; - try { - names = (await readdir(auditDir)).filter((n) => n.endsWith(".jsonl")); - } catch { - return null; - } - if (names.length === 0) return null; - const backupDir = join(auditDir, "..", "audit-pre-db-bak"); - await mkdir(backupDir, { recursive: true }); - for (const name of names) { - const dest = join(backupDir, name); - const existing = await fileFingerprint(dest); - if (!existing) await copyFile(join(auditDir, name), dest); - } - return backupDir; -} - -type Source = { - key: string; - fingerprint: () => Promise; - run: () => Promise<{ count: number; found: boolean; backupPath: string | null }>; -}; - -/** - * One-time Phase 1 import gated by the `migration_state` table. - * - * For each source (server.json, tokens.json, audit dir): - * - if a `migration_state` row already exists AND the source fingerprint is unchanged, - * skip ("already-done"); - * - otherwise snapshot the source to a `.pre-db.bak` (never deleting the original), - * import it, and record the new fingerprint. + * One-time Phase 1 import gated by `migration_state`. * - * Idempotent and cheap on subsequent starts (only stat()s the source files). - * Source files are preserved so the migration can be reverted. + * Sources (server.json, tokens.json, audit dir) are imported AT MOST ONCE. Source files + * are never modified, copied, or deleted — they stay in place so an older (pre-DB) app + * version still works after a rollback. Cheap on subsequent starts (a single DB lookup + * per source before any file I/O). */ export async function runPhase1ImportOnce( db: DesktopDb, @@ -103,102 +34,14 @@ export async function runPhase1ImportOnce( const tokensJsonPath = options.tokensJsonPath ?? resolveTokensJsonPath(); const auditDir = options.auditDir ?? resolveAuditDir(); - const sources: Source[] = [ - { - key: "server.json", - fingerprint: () => fileFingerprint(serverJsonPath), - run: async () => { - const backupPath = await snapshotOnce(serverJsonPath); - const result = await importServerJson(db, serverJsonPath); - return { ...result, backupPath }; - }, - }, - { - key: "tokens.json", - fingerprint: () => fileFingerprint(tokensJsonPath), - run: async () => { - const backupPath = await snapshotOnce(tokensJsonPath); - const result = await importTokensJson(db, tokensJsonPath); - return { ...result, backupPath }; - }, - }, + const sources: ImportSource[] = [ + { key: "server.json", path: serverJsonPath, kind: "file", run: importServerJson }, + { key: "tokens.json", path: tokensJsonPath, kind: "file", run: importTokensJson }, ]; if (!options.skipAudit) { - sources.push({ - key: "audit", - fingerprint: () => dirFingerprint(auditDir, ".jsonl"), - run: async () => { - const backupPath = await snapshotAuditDir(auditDir); - const result = await importAuditDir(db, auditDir); - return { ...result, backupPath }; - }, - }); - } - - const report: ImportOnceReport = {}; - - for (const source of sources) { - const fingerprint = await source.fingerprint(); - - if (fingerprint === null) { - report[source.key] = { - source: source.key, - status: "missing", - fingerprint: "", - rowCount: 0, - backupPath: null, - }; - continue; - } - - const prior = await getState(db, source.key); - if (prior && prior.status === "imported" && prior.fingerprint === fingerprint) { - report[source.key] = { - source: source.key, - status: "already-done", - fingerprint, - rowCount: prior.rowCount, - backupPath: prior.backupPath ?? null, - }; - continue; - } - - try { - const { count, backupPath } = await source.run(); - recordState(db, { - source: source.key, - status: "imported", - fingerprint, - rowCount: count, - backupPath, - }); - report[source.key] = { - source: source.key, - status: "imported", - fingerprint, - rowCount: count, - backupPath, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - recordState(db, { - source: source.key, - status: "error", - fingerprint, - rowCount: 0, - backupPath: null, - }); - report[source.key] = { - source: source.key, - status: "error", - fingerprint, - rowCount: 0, - backupPath: null, - error: message, - }; - } + sources.push({ key: "audit", path: auditDir, kind: "dir", suffix: ".jsonl", run: importAuditDir }); } - return report; + return runImportSourcesOnce(db, sources); } diff --git a/packages/desktop-db/src/import/index.ts b/packages/desktop-db/src/import/index.ts index fd95097dcb..658f52c684 100644 --- a/packages/desktop-db/src/import/index.ts +++ b/packages/desktop-db/src/import/index.ts @@ -20,7 +20,7 @@ export { resolveTokensJsonPath, type ImportOptions, } from "./paths"; -export { fileFingerprint, dirFingerprint, snapshotOnce } from "./fingerprint"; +export { fileHash, dirHash, fileExists } from "./fingerprint"; export { runPhase1ImportOnce, type ImportOnceReport, diff --git a/packages/desktop-db/src/schema/migration-state.ts b/packages/desktop-db/src/schema/migration-state.ts index dac8a3e7d0..270d2d4e41 100644 --- a/packages/desktop-db/src/schema/migration-state.ts +++ b/packages/desktop-db/src/schema/migration-state.ts @@ -4,23 +4,24 @@ import { epochMs } from "../columns"; /** * Tracks one-time file -> DB imports so we never re-import on every start. * - * Keyed by `source` (e.g. "server.json", "tokens.json", "audit"). The `fingerprint` - * is ":" of the source file (or a directory digest for audit); if the - * source file changes after import, the importer can decide to re-run. + * Keyed by `source` (e.g. "server.json", "tokens.json", "audit"). Each row records the + * imported source's `path` and a non-cryptographic content `hash` (for diagnostics). * - * Source files are NEVER deleted; on first successful import a `.pre-db.bak` - * snapshot is written (recorded in `backupPath`) so the migration can be reverted. + * IMPORTANT: the import is gated **once ever per source** — once a source has a row with + * status "imported", we skip it on every subsequent start regardless of later content + * changes. We NEVER modify, copy, rename, or delete the source files; they remain in + * place so an older (pre-DB) app version still works after a rollback. */ export const migrationStateTable = sqliteTable("migration_state", { source: text("source").primaryKey(), /** "imported" | "skipped" | "error" */ status: text("status").notNull(), - /** ":" of the imported source, or "" if not found. */ - fingerprint: text("fingerprint").notNull().default(""), + /** Absolute path of the imported source (file or dir), or "" if not found. */ + path: text("path").notNull().default(""), + /** Non-cryptographic content hash of the imported source (diagnostics only). */ + hash: text("hash").notNull().default(""), /** Number of rows imported. */ rowCount: integer("row_count").notNull().default(0), - /** Path to the one-time .pre-db.bak snapshot, if any. */ - backupPath: text("backup_path"), importedAt: epochMs("imported_at").notNull(), });