diff --git a/docs/01_architecture.md b/docs/01_architecture.md index b2be344..a1854cc 100644 --- a/docs/01_architecture.md +++ b/docs/01_architecture.md @@ -39,14 +39,15 @@ Sovereign is a privacy-first collaboration platform that gives communities and o ## Data & Persistence - **Layered Prisma schemas**: `platform/prisma/base.prisma` defines canonical tables, each plugin contributes to `plugins//prisma/extension.prisma`, and the build step composes them into `platform/prisma/schema.prisma`. +- **Dedicated Databases**: Plugins can opt-out of the shared schema by setting `"sovereign": { "database": { "mode": "dedicated" } }` in `plugin.json`. These plugins manage their own `prisma/schema.prisma` and migrations using `node tools/plugin-db-manage.mjs`. - **SQLite-first with upgrade path**: Local deployments default to SQLite for minimal friction. Because Prisma is the boundary, migrating to PostgreSQL (or other SQL backends) only updates datasource configuration. - **No manual schema edits**: Developers run `yarn prisma:compose` (root or via workspace) whenever models change; CI enforces the generated schema to avoid drift. ## Plugin Runtime (`plugins//`) -- **Manifest-driven**: Every plugin ships a `plugin.json` describing ID, engine compatibility, framework (`js` or `react`), plugin type (`module` or `project`), entry file, exposed routes, capabilities, and optional dev-server metadata. -- **Lifecycle hooks**: Entry modules export `render`, `configure`, and `getRoutes` today, with planned `onInstall/onEnable` hooks for seeding data or migrations. -- **Asset strategy**: Static assets under `public/` are copied verbatim. Code under `src/` or `routes/` is transpiled but keeps file extensions so dynamic imports remain stable. +- **Manifest-driven**: Every plugin ships a `plugin.json` describing ID, engine compatibility, framework (`js` or `react`), plugin type (`module` or `project`), entry points (`web`, `api`), capabilities, and optional UI metadata. +- **Lifecycle hooks**: The root `index.js` may export lifecycle hooks like `onDelete`. Runtime initialization happens via `entryPoints` defined in `plugin.json`. +- **Asset strategy**: Static assets under `public/` are copied verbatim (if present). Plugin code is typically organized in `routes/` or `src/`, with entry points explicitly defined in the manifest. - **Development ergonomics**: SPA plugins can declare a Vite dev server so the platform proxies HMR traffic automatically when `NODE_ENV !== "production"`. ### Module vs. Project Plugins @@ -66,9 +67,11 @@ Sovereign is a privacy-first collaboration platform that gives communities and o ## Routing Model - **Core routes**: `platform/src/routes/` hosts first-party HTTP endpoints (home, auth, admin). Each route module is an Express router wired with platform middlewares such as `requireAuth`, `requireRole`, and `exposeGlobals`. -- **Plugin router builder**: `platform/src/ext-host/build-routes.js` walks the generated `manifest.json`, resolves each plugin’s entry points, and mounts them: - - SPA plugins get `/namespace` view routes plus `/api/plugins/namespace` APIs if they expose an API entry. - - Custom plugins can expose both `web` and `api` routers; the builder applies auth/layout middlewares and mounts them at `/namespace` and `/api/plugins/namespace`. +- **Plugin router builder**: `platform/src/ext-host/build-routes.js` iterates over enabled plugins in `manifest.json`. For each plugin, it resolves `entryPoints` (e.g., `web`, `api`) defined in `plugin.json`. + - **Entry Exports**: The entry file must export an Express `Router` or a factory function `(context) => Router`. + - **Mounting**: + - `web` entries are mounted at `/${namespace}` with view middlewares (auth, layout). + - `api` entries are mounted at `/api/plugins/${namespace}` with API middlewares. - **Context injection**: Before mounting, the builder resolves a plugin context (cacheable per namespace) that contains the env, logger, and granted platform capabilities. Router factories can consume that context to reach Prisma, file helpers, etc. - **Auth layering**: Every plugin route automatically receives `requireAuth` and any `featureAccess.roles` guard plus `exposeGlobals`, ensuring consistent user/session state without per-plugin boilerplate. diff --git a/docs/plugins/03_plugins-database.md b/docs/plugins/03_plugins-database.md new file mode 100644 index 0000000..396ac68 --- /dev/null +++ b/docs/plugins/03_plugins-database.md @@ -0,0 +1,88 @@ +# Plugin Database Architecture + +Sovereign supports two database models for plugins: **Shared** (default) and **Dedicated**. + +## 1. Shared Database (Default) + +In the shared model, your plugin's Prisma schema is composed into the main platform schema. This is the easiest way to get started and allows for seamless relations with core tables (though direct relations are discouraged in favor of loose coupling). + +### How it works + +1. Place your `extension.prisma` in `plugins//prisma/extension.prisma`. +2. Run `yarn prisma:compose` (or `yarn prepare:db`) to merge it into `platform/prisma/schema.prisma`. +3. The platform manages migrations and the Prisma client. +4. Access the database via the `database` capability (injects the global `prisma` client). + +### Configuration + +No special configuration needed. Just ensure `plugin.json` does **not** have `sovereign.database.mode` set to `dedicated`. + +--- + +## 2. Dedicated Database + +In the dedicated model, your plugin manages its own isolated database. This is useful for complex plugins that need independent scaling, have conflicting schema requirements, or want full control over their migrations. + +### How it works + +1. Place your `schema.prisma` in `plugins//prisma/schema.prisma`. +2. **Important**: This file must be a complete Prisma schema with its own `datasource` and `generator` blocks. +3. The platform's compose tool will **ignore** this plugin. +4. You must manage migrations and client generation using the `plugin-db-manage` tool. + +### Configuration + +In your `plugin.json`: + +```json +{ + "sovereign": { + "database": { + "mode": "dedicated" + } + } +} +``` + +### Management Tool + +Use the `tools/plugin-db-manage.mjs` script to manage your dedicated database. + +```bash +# Generate Prisma Client +node tools/plugin-db-manage.mjs generate + +# Run Migrations (Dev) +node tools/plugin-db-manage.mjs migrate + +# Deploy Migrations (Prod) +node tools/plugin-db-manage.mjs deploy + +# Open Prisma Studio +node tools/plugin-db-manage.mjs studio +``` + +### Accessing the Database + +Since your tables are not in the global Prisma client, you cannot use the `database` capability to access your own data. Instead, you should import your generated client directly. + +```javascript +// In your plugin code +import { PrismaClient } from "@prisma/client"; // Note: This might need to be an alias or specific path depending on generation output +// OR if you generated to a custom location: +// import { PrismaClient } from "./generated/client"; + +const prisma = new PrismaClient(); +``` + +> **Note**: The `database` capability is still useful if you need read-only access to core tables (like `User` or `Tenant`) from the shared platform database. + +## Summary + +| Feature | Shared (Default) | Dedicated | +| :-------------- | :--------------------------- | :-------------------------- | +| **Schema File** | `extension.prisma` (partial) | `schema.prisma` (full) | +| **Migrations** | Managed by Platform | Managed by Plugin | +| **Client** | Global `prisma` instance | Plugin-specific instance | +| **Isolation** | Low (Shared tables) | High (Separate DB possible) | +| **Complexity** | Low | Moderate | diff --git a/tools/database-prisma-compose.mjs b/tools/database-prisma-compose.mjs index 58d48d7..2542b59 100644 --- a/tools/database-prisma-compose.mjs +++ b/tools/database-prisma-compose.mjs @@ -55,10 +55,32 @@ const baseSDL = await fs.readFile(base, "utf8"); const pluginParts = await Promise.all( extFiles.map(async (file) => { const rel = path.relative(root, file); + const name = pluginName(file); + + // Check plugin.json for database mode + const pluginDir = path.dirname(file); // .../plugins/name/prisma + const manifestPath = path.join(pluginDir, "..", "plugin.json"); + try { + const manifestContent = await fs.readFile(manifestPath, "utf8"); + const manifest = JSON.parse(manifestContent); + const dbMode = manifest?.sovereign?.database?.mode || "shared"; + + if (dbMode === "dedicated") { + console.log(`[prisma:compose] Skipping dedicated database plugin: ${name}`); + return null; + } + } catch (err) { + if (err.code !== "ENOENT") { + console.warn( + `[prisma:compose] Warning: Could not read plugin.json for ${name}: ${err.message}` + ); + } + // If no plugin.json, assume shared/legacy behavior + } + const sdl = await fs.readFile(file, "utf8"); ensureValidExtension(sdl, rel); const body = sdl.trim(); - const name = pluginName(file); const header = [`/// --- Plugin: ${name} ---`, `/// Source: ${rel}`].join("\n"); const section = body ? `${header}\n\n${body}` : header; return { name, rel, section }; @@ -68,7 +90,7 @@ const pluginParts = await Promise.all( const pieces = [ banner.trimEnd(), baseSDL.trimEnd(), - ...pluginParts.map((part) => part.section.trimEnd()), + ...pluginParts.filter(Boolean).map((part) => part.section.trimEnd()), ].filter(Boolean); const combined = `${pieces.join("\n\n")}\n`; diff --git a/tools/plugin-db-manage.mjs b/tools/plugin-db-manage.mjs new file mode 100644 index 0000000..5b06e5b --- /dev/null +++ b/tools/plugin-db-manage.mjs @@ -0,0 +1,111 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execa } from "execa"; + +const args = process.argv.slice(2); +const command = args[0]; +const pluginNamespace = args[1]; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, ".."); +const pluginsDir = path.join(root, "plugins"); + +function printUsage() { + console.log("Usage: node tools/plugin-db-manage.mjs "); + console.log("Commands:"); + console.log(" generate - Generate Prisma client for the plugin"); + console.log(" migrate - Run migrations (dev)"); + console.log(" deploy - Deploy migrations (prod)"); + console.log(" studio - Open Prisma Studio"); + process.exit(1); +} + +if (!command || !pluginNamespace) { + printUsage(); +} + +async function findPluginDir(namespace) { + // Try direct match first (e.g. "blog" -> plugins/blog) + let target = path.join(pluginsDir, namespace); + try { + const stat = await fs.stat(target); + if (stat.isDirectory()) return target; + } catch {} + + // Try searching for matching namespace in plugin.json + const entries = await fs.readdir(pluginsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const pDir = path.join(pluginsDir, entry.name); + try { + const manifest = JSON.parse(await fs.readFile(path.join(pDir, "plugin.json"), "utf8")); + if (manifest.namespace === namespace || manifest.id === namespace) { + return pDir; + } + } catch {} + } + return null; +} + +async function main() { + const pluginDir = await findPluginDir(pluginNamespace); + if (!pluginDir) { + console.error(`Error: Plugin "${pluginNamespace}" not found.`); + process.exit(1); + } + + const schemaPath = path.join(pluginDir, "prisma/schema.prisma"); + try { + await fs.access(schemaPath); + } catch { + console.error(`Error: No prisma/schema.prisma found in ${pluginDir}`); + console.error("This tool is only for plugins with dedicated databases."); + process.exit(1); + } + + console.log(`[plugin-db] Managing database for ${pluginNamespace} in ${pluginDir}`); + + const prismaBin = path.join(root, "node_modules/.bin/prisma"); + const envFile = path.join(root, ".env"); + + // Load env from root .env to ensure DATABASE_URL or other env vars are available if needed + // But typically dedicated plugins should have their own env config or use a different var. + // For now, we assume the user runs this with appropriate env vars set, or we load root .env. + // We'll let prisma load .env from root if we run it from root. + + let prismaArgs = []; + switch (command) { + case "generate": + prismaArgs = ["generate", "--schema", schemaPath]; + break; + case "migrate": + prismaArgs = ["migrate", "dev", "--schema", schemaPath]; + break; + case "deploy": + prismaArgs = ["migrate", "deploy", "--schema", schemaPath]; + break; + case "studio": + prismaArgs = ["studio", "--schema", schemaPath]; + break; + default: + printUsage(); + } + + console.log(`Running: prisma ${prismaArgs.join(" ")}`); + + try { + await execa(prismaBin, prismaArgs, { + cwd: root, // Run from root so it picks up root .env + stdio: "inherit", + }); + } catch (err) { + console.error("Command failed."); + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});