Skip to content

Commit 73830c5

Browse files
committed
feat: use dedicated database per plugin if need
1 parent ac50422 commit 73830c5

4 files changed

Lines changed: 224 additions & 2 deletions

File tree

docs/01_architecture.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Sovereign is a privacy-first collaboration platform that gives communities and o
3939
## Data & Persistence
4040

4141
- **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`.
42+
- **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`.
4243
- **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.
4344
- **No manual schema edits**: Developers run `yarn prisma:compose` (root or via workspace) whenever models change; CI enforces the generated schema to avoid drift.
4445

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Plugin Database Architecture
2+
3+
Sovereign supports two database models for plugins: **Shared** (default) and **Dedicated**.
4+
5+
## 1. Shared Database (Default)
6+
7+
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).
8+
9+
### How it works
10+
11+
1. Place your `extension.prisma` in `plugins/<namespace>/prisma/extension.prisma`.
12+
2. Run `yarn prisma:compose` (or `yarn prepare:db`) to merge it into `platform/prisma/schema.prisma`.
13+
3. The platform manages migrations and the Prisma client.
14+
4. Access the database via the `database` capability (injects the global `prisma` client).
15+
16+
### Configuration
17+
18+
No special configuration needed. Just ensure `plugin.json` does **not** have `sovereign.database.mode` set to `dedicated`.
19+
20+
---
21+
22+
## 2. Dedicated Database
23+
24+
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.
25+
26+
### How it works
27+
28+
1. Place your `schema.prisma` in `plugins/<namespace>/prisma/schema.prisma`.
29+
2. **Important**: This file must be a complete Prisma schema with its own `datasource` and `generator` blocks.
30+
3. The platform's compose tool will **ignore** this plugin.
31+
4. You must manage migrations and client generation using the `plugin-db-manage` tool.
32+
33+
### Configuration
34+
35+
In your `plugin.json`:
36+
37+
```json
38+
{
39+
"sovereign": {
40+
"database": {
41+
"mode": "dedicated"
42+
}
43+
}
44+
}
45+
```
46+
47+
### Management Tool
48+
49+
Use the `tools/plugin-db-manage.mjs` script to manage your dedicated database.
50+
51+
```bash
52+
# Generate Prisma Client
53+
node tools/plugin-db-manage.mjs generate <namespace>
54+
55+
# Run Migrations (Dev)
56+
node tools/plugin-db-manage.mjs migrate <namespace>
57+
58+
# Deploy Migrations (Prod)
59+
node tools/plugin-db-manage.mjs deploy <namespace>
60+
61+
# Open Prisma Studio
62+
node tools/plugin-db-manage.mjs studio <namespace>
63+
```
64+
65+
### Accessing the Database
66+
67+
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.
68+
69+
```javascript
70+
// In your plugin code
71+
import { PrismaClient } from "@prisma/client"; // Note: This might need to be an alias or specific path depending on generation output
72+
// OR if you generated to a custom location:
73+
// import { PrismaClient } from "./generated/client";
74+
75+
const prisma = new PrismaClient();
76+
```
77+
78+
> **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.
79+
80+
## Summary
81+
82+
| Feature | Shared (Default) | Dedicated |
83+
| :-------------- | :--------------------------- | :-------------------------- |
84+
| **Schema File** | `extension.prisma` (partial) | `schema.prisma` (full) |
85+
| **Migrations** | Managed by Platform | Managed by Plugin |
86+
| **Client** | Global `prisma` instance | Plugin-specific instance |
87+
| **Isolation** | Low (Shared tables) | High (Separate DB possible) |
88+
| **Complexity** | Low | Moderate |

tools/database-prisma-compose.mjs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,32 @@ const baseSDL = await fs.readFile(base, "utf8");
5555
const pluginParts = await Promise.all(
5656
extFiles.map(async (file) => {
5757
const rel = path.relative(root, file);
58+
const name = pluginName(file);
59+
60+
// Check plugin.json for database mode
61+
const pluginDir = path.dirname(file); // .../plugins/name/prisma
62+
const manifestPath = path.join(pluginDir, "..", "plugin.json");
63+
try {
64+
const manifestContent = await fs.readFile(manifestPath, "utf8");
65+
const manifest = JSON.parse(manifestContent);
66+
const dbMode = manifest?.sovereign?.database?.mode || "shared";
67+
68+
if (dbMode === "dedicated") {
69+
console.log(`[prisma:compose] Skipping dedicated database plugin: ${name}`);
70+
return null;
71+
}
72+
} catch (err) {
73+
if (err.code !== "ENOENT") {
74+
console.warn(
75+
`[prisma:compose] Warning: Could not read plugin.json for ${name}: ${err.message}`
76+
);
77+
}
78+
// If no plugin.json, assume shared/legacy behavior
79+
}
80+
5881
const sdl = await fs.readFile(file, "utf8");
5982
ensureValidExtension(sdl, rel);
6083
const body = sdl.trim();
61-
const name = pluginName(file);
6284
const header = [`/// --- Plugin: ${name} ---`, `/// Source: ${rel}`].join("\n");
6385
const section = body ? `${header}\n\n${body}` : header;
6486
return { name, rel, section };
@@ -68,7 +90,7 @@ const pluginParts = await Promise.all(
6890
const pieces = [
6991
banner.trimEnd(),
7092
baseSDL.trimEnd(),
71-
...pluginParts.map((part) => part.section.trimEnd()),
93+
...pluginParts.filter(Boolean).map((part) => part.section.trimEnd()),
7294
].filter(Boolean);
7395
const combined = `${pieces.join("\n\n")}\n`;
7496

tools/plugin-db-manage.mjs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { promises as fs } from "node:fs";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { execa } from "execa";
5+
6+
const args = process.argv.slice(2);
7+
const command = args[0];
8+
const pluginNamespace = args[1];
9+
10+
const here = path.dirname(fileURLToPath(import.meta.url));
11+
const root = path.resolve(here, "..");
12+
const pluginsDir = path.join(root, "plugins");
13+
14+
function printUsage() {
15+
console.log("Usage: node tools/plugin-db-manage.mjs <command> <plugin-namespace>");
16+
console.log("Commands:");
17+
console.log(" generate - Generate Prisma client for the plugin");
18+
console.log(" migrate - Run migrations (dev)");
19+
console.log(" deploy - Deploy migrations (prod)");
20+
console.log(" studio - Open Prisma Studio");
21+
process.exit(1);
22+
}
23+
24+
if (!command || !pluginNamespace) {
25+
printUsage();
26+
}
27+
28+
async function findPluginDir(namespace) {
29+
// Try direct match first (e.g. "blog" -> plugins/blog)
30+
let target = path.join(pluginsDir, namespace);
31+
try {
32+
const stat = await fs.stat(target);
33+
if (stat.isDirectory()) return target;
34+
} catch {}
35+
36+
// Try searching for matching namespace in plugin.json
37+
const entries = await fs.readdir(pluginsDir, { withFileTypes: true });
38+
for (const entry of entries) {
39+
if (!entry.isDirectory()) continue;
40+
const pDir = path.join(pluginsDir, entry.name);
41+
try {
42+
const manifest = JSON.parse(await fs.readFile(path.join(pDir, "plugin.json"), "utf8"));
43+
if (manifest.namespace === namespace || manifest.id === namespace) {
44+
return pDir;
45+
}
46+
} catch {}
47+
}
48+
return null;
49+
}
50+
51+
async function main() {
52+
const pluginDir = await findPluginDir(pluginNamespace);
53+
if (!pluginDir) {
54+
console.error(`Error: Plugin "${pluginNamespace}" not found.`);
55+
process.exit(1);
56+
}
57+
58+
const schemaPath = path.join(pluginDir, "prisma/schema.prisma");
59+
try {
60+
await fs.access(schemaPath);
61+
} catch {
62+
console.error(`Error: No prisma/schema.prisma found in ${pluginDir}`);
63+
console.error("This tool is only for plugins with dedicated databases.");
64+
process.exit(1);
65+
}
66+
67+
console.log(`[plugin-db] Managing database for ${pluginNamespace} in ${pluginDir}`);
68+
69+
const prismaBin = path.join(root, "node_modules/.bin/prisma");
70+
const envFile = path.join(root, ".env");
71+
72+
// Load env from root .env to ensure DATABASE_URL or other env vars are available if needed
73+
// But typically dedicated plugins should have their own env config or use a different var.
74+
// For now, we assume the user runs this with appropriate env vars set, or we load root .env.
75+
// We'll let prisma load .env from root if we run it from root.
76+
77+
let prismaArgs = [];
78+
switch (command) {
79+
case "generate":
80+
prismaArgs = ["generate", "--schema", schemaPath];
81+
break;
82+
case "migrate":
83+
prismaArgs = ["migrate", "dev", "--schema", schemaPath];
84+
break;
85+
case "deploy":
86+
prismaArgs = ["migrate", "deploy", "--schema", schemaPath];
87+
break;
88+
case "studio":
89+
prismaArgs = ["studio", "--schema", schemaPath];
90+
break;
91+
default:
92+
printUsage();
93+
}
94+
95+
console.log(`Running: prisma ${prismaArgs.join(" ")}`);
96+
97+
try {
98+
await execa(prismaBin, prismaArgs, {
99+
cwd: root, // Run from root so it picks up root .env
100+
stdio: "inherit",
101+
});
102+
} catch (err) {
103+
console.error("Command failed.");
104+
process.exit(1);
105+
}
106+
}
107+
108+
main().catch((err) => {
109+
console.error(err);
110+
process.exit(1);
111+
});

0 commit comments

Comments
 (0)