Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions docs/01_architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ns>/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/<namespace>/`)

- **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
Expand All @@ -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.

Expand Down
88 changes: 88 additions & 0 deletions docs/plugins/03_plugins-database.md
Original file line number Diff line number Diff line change
@@ -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/<namespace>/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/<namespace>/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 <namespace>

# Run Migrations (Dev)
node tools/plugin-db-manage.mjs migrate <namespace>

# Deploy Migrations (Prod)
node tools/plugin-db-manage.mjs deploy <namespace>

# Open Prisma Studio
node tools/plugin-db-manage.mjs studio <namespace>
```

### 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 |
26 changes: 24 additions & 2 deletions tools/database-prisma-compose.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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`;

Expand Down
111 changes: 111 additions & 0 deletions tools/plugin-db-manage.mjs
Original file line number Diff line number Diff line change
@@ -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 <command> <plugin-namespace>");
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);
});