feat: Phase 5 — remove PostgreSQL from rForum, rSpace now fully PG-free

Migrate rForum provisioning metadata from shared PG pool to Automerge.
rForum was the last module using PostgreSQL; shared/db/pool.ts is now archived.

- Create modules/rforum/schemas.ts (ForumDoc, ForumInstance, ProvisionLog)
- Rewrite mod.ts: replace sql with Automerge getDoc/changeDoc, add onInit
- Rewrite provisioner.ts: pass SyncServer, logStep/updateInstance via changeDoc
- Fix dashboard snake_case → camelCase field references
- Archive schema.sql and shared/db/pool.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 16:25:26 -08:00
parent 1cbced27f8
commit 9443178d1c
6 changed files with 278 additions and 140 deletions

View File

@ -29,9 +29,9 @@ class FolkForumDashboard extends HTMLElement {
private loadDemoData() { private loadDemoData() {
this.instances = [ this.instances = [
{ id: "1", name: "Commons Hub", domain: "commons.rforum.online", status: "active", region: "nbg1", size: "cx22", admin_email: "admin@commons.example", vps_ip: "116.203.x.x", ssl_provisioned: true }, { id: "1", name: "Commons Hub", domain: "commons.rforum.online", status: "active", region: "nbg1", size: "cx22", adminEmail: "admin@commons.example", vpsIp: "116.203.x.x", sslProvisioned: true },
{ id: "2", name: "Design Guild", domain: "design.rforum.online", status: "provisioning", region: "fsn1", size: "cx22", admin_email: "admin@design.example", vps_ip: "168.119.x.x", ssl_provisioned: false }, { id: "2", name: "Design Guild", domain: "design.rforum.online", status: "provisioning", region: "fsn1", size: "cx22", adminEmail: "admin@design.example", vpsIp: "168.119.x.x", sslProvisioned: false },
{ id: "3", name: "Archive Project", domain: "archive.rforum.online", status: "destroyed", region: "hel1", size: "cx22", admin_email: "admin@archive.example", vps_ip: null, ssl_provisioned: false }, { id: "3", name: "Archive Project", domain: "archive.rforum.online", status: "destroyed", region: "hel1", size: "cx22", adminEmail: "admin@archive.example", vpsIp: null, sslProvisioned: false },
]; ];
this.loading = false; this.loading = false;
this.render(); this.render();
@ -299,7 +299,7 @@ class FolkForumDashboard extends HTMLElement {
</div> </div>
<div class="instance-meta"> <div class="instance-meta">
${inst.domain} &middot; ${inst.region} &middot; ${inst.size} ${inst.domain} &middot; ${inst.region} &middot; ${inst.size}
${inst.vps_ip ? ` &middot; ${inst.vps_ip}` : ""} ${inst.vpsIp ? ` &middot; ${inst.vpsIp}` : ""}
</div> </div>
</div> </div>
`).join("")} `).join("")}
@ -327,15 +327,15 @@ class FolkForumDashboard extends HTMLElement {
${inst.status === "active" ? `<a href="https://${inst.domain}" target="_blank" style="color:#64b5f6;font-size:13px">↗ Open Forum</a>` : ""} ${inst.status === "active" ? `<a href="https://${inst.domain}" target="_blank" style="color:#64b5f6;font-size:13px">↗ Open Forum</a>` : ""}
</div> </div>
${inst.error_message ? `<div style="background:#7a2a2a33;border:1px solid #7a2a2a;padding:10px;border-radius:6px;margin-bottom:16px;font-size:13px;color:#ef5350">${this.esc(inst.error_message)}</div>` : ""} ${inst.errorMessage ? `<div style="background:#7a2a2a33;border:1px solid #7a2a2a;padding:10px;border-radius:6px;margin-bottom:16px;font-size:13px;color:#ef5350">${this.esc(inst.errorMessage)}</div>` : ""}
<div class="detail-grid"> <div class="detail-grid">
<div class="detail-item"><label>Domain</label><div class="value">${inst.domain}</div></div> <div class="detail-item"><label>Domain</label><div class="value">${inst.domain}</div></div>
<div class="detail-item"><label>IP Address</label><div class="value">${inst.vps_ip || "—"}</div></div> <div class="detail-item"><label>IP Address</label><div class="value">${inst.vpsIp || "—"}</div></div>
<div class="detail-item"><label>Region</label><div class="value">${inst.region}</div></div> <div class="detail-item"><label>Region</label><div class="value">${inst.region}</div></div>
<div class="detail-item"><label>Server Size</label><div class="value">${inst.size}</div></div> <div class="detail-item"><label>Server Size</label><div class="value">${inst.size}</div></div>
<div class="detail-item"><label>Admin Email</label><div class="value">${inst.admin_email || "—"}</div></div> <div class="detail-item"><label>Admin Email</label><div class="value">${inst.adminEmail || "—"}</div></div>
<div class="detail-item"><label>SSL</label><div class="value">${inst.ssl_provisioned ? "✅ Active" : "⏳ Pending"}</div></div> <div class="detail-item"><label>SSL</label><div class="value">${inst.sslProvisioned ? "✅ Active" : "⏳ Pending"}</div></div>
</div> </div>
<div class="logs-section"> <div class="logs-section">

View File

@ -3,70 +3,96 @@
* configures DNS, installs Discourse, and verifies it's live. * configures DNS, installs Discourse, and verifies it's live.
*/ */
import { sql } from "../../../shared/db/pool"; import type { SyncServer } from "../../../server/local-first/sync-server";
import { FORUM_DOC_ID, type ForumDoc, type StepStatus } from "../schemas";
import { createServer, getServer, deleteServer } from "./hetzner"; import { createServer, getServer, deleteServer } from "./hetzner";
import { createDNSRecord, deleteDNSRecord } from "./dns"; import { createDNSRecord, deleteDNSRecord } from "./dns";
import { generateCloudInit, type DiscourseConfig } from "./cloud-init"; import { generateCloudInit, type DiscourseConfig } from "./cloud-init";
type StepStatus = "running" | "success" | "error" | "skipped"; function logStep(
syncServer: SyncServer,
async function logStep(
instanceId: string, instanceId: string,
step: string, step: string,
status: StepStatus, status: StepStatus,
message: string, message: string,
metadata: Record<string, unknown> = {}, metadata: Record<string, unknown> = {},
) { ) {
if (status === "running") { const now = Date.now();
await sql.unsafe( syncServer.changeDoc<ForumDoc>(FORUM_DOC_ID, `log ${step} ${status}`, (d) => {
`INSERT INTO rforum.provision_logs (instance_id, step, status, message, metadata) if (!d.provisionLogs[instanceId]) d.provisionLogs[instanceId] = [];
VALUES ($1, $2, $3, $4, $5::jsonb)`, const logs = d.provisionLogs[instanceId];
[instanceId, step, status, message, JSON.stringify(metadata)],
); if (status === "running") {
} else { logs.push({
await sql.unsafe( id: crypto.randomUUID(),
`UPDATE rforum.provision_logs SET status = $1, message = $2, metadata = $3::jsonb, completed_at = NOW() step,
WHERE instance_id = $4 AND step = $5 AND status = 'running'`, status,
[status, message, JSON.stringify(metadata), instanceId, step], message,
); metadata,
} startedAt: now,
completedAt: 0,
});
} else {
// Find the running entry for this step and update it
for (let i = logs.length - 1; i >= 0; i--) {
if (logs[i].step === step && logs[i].status === "running") {
logs[i].status = status;
logs[i].message = message;
logs[i].metadata = metadata;
logs[i].completedAt = now;
break;
}
}
}
});
} }
async function updateInstance(instanceId: string, fields: Record<string, unknown>) { function updateInstance(
const sets: string[] = []; syncServer: SyncServer,
const params: any[] = []; instanceId: string,
let idx = 1; fields: Partial<{
for (const [key, val] of Object.entries(fields)) { status: string;
sets.push(`${key} = $${idx}`); errorMessage: string;
params.push(val); vpsId: string;
idx++; vpsIp: string;
} dnsRecordId: string;
sets.push("updated_at = NOW()"); sslProvisioned: boolean;
params.push(instanceId); provisionedAt: number;
await sql.unsafe(`UPDATE rforum.instances SET ${sets.join(", ")} WHERE id = $${idx}`, params); destroyedAt: number;
}>,
) {
syncServer.changeDoc<ForumDoc>(FORUM_DOC_ID, `update instance ${instanceId}`, (d) => {
const inst = d.instances[instanceId];
if (!inst) return;
for (const [key, val] of Object.entries(fields)) {
(inst as any)[key] = val;
}
inst.updatedAt = Date.now();
});
} }
async function sleep(ms: number) { async function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms)); return new Promise((r) => setTimeout(r, ms));
} }
export async function provisionInstance(instanceId: string) { export async function provisionInstance(syncServer: SyncServer, instanceId: string) {
const [instance] = await sql.unsafe("SELECT * FROM rforum.instances WHERE id = $1", [instanceId]); const doc = syncServer.getDoc<ForumDoc>(FORUM_DOC_ID);
const instance = doc?.instances[instanceId];
if (!instance) throw new Error("Instance not found"); if (!instance) throw new Error("Instance not found");
await updateInstance(instanceId, { status: "provisioning" }); updateInstance(syncServer, instanceId, { status: "provisioning" });
try { try {
// Step 1: Create VPS // Step 1: Create VPS
await logStep(instanceId, "create_vps", "running", "Creating VPS..."); logStep(syncServer, instanceId, "create_vps", "running", "Creating VPS...");
const config: DiscourseConfig = { const config: DiscourseConfig = {
hostname: instance.domain, hostname: instance.domain,
adminEmail: instance.admin_email, adminEmail: instance.adminEmail,
...(instance.smtp_config?.host ? { ...(instance.smtpConfig?.host ? {
smtpHost: instance.smtp_config.host, smtpHost: instance.smtpConfig.host as string,
smtpPort: instance.smtp_config.port, smtpPort: instance.smtpConfig.port as number,
smtpUser: instance.smtp_config.user, smtpUser: instance.smtpConfig.user as string,
smtpPassword: instance.smtp_config.password, smtpPassword: instance.smtpConfig.password as string,
} : {}), } : {}),
}; };
const userData = generateCloudInit(config); const userData = generateCloudInit(config);
@ -76,11 +102,11 @@ export async function provisionInstance(instanceId: string) {
region: instance.region, region: instance.region,
userData, userData,
}); });
await updateInstance(instanceId, { vps_id: serverId, vps_ip: ip }); updateInstance(syncServer, instanceId, { vpsId: serverId, vpsIp: ip });
await logStep(instanceId, "create_vps", "success", `VPS created: ${ip}`, { serverId, ip }); logStep(syncServer, instanceId, "create_vps", "success", `VPS created: ${ip}`, { serverId, ip });
// Step 2: Wait for boot // Step 2: Wait for boot
await logStep(instanceId, "wait_ready", "running", "Waiting for VPS to boot..."); logStep(syncServer, instanceId, "wait_ready", "running", "Waiting for VPS to boot...");
let booted = false; let booted = false;
for (let i = 0; i < 60; i++) { for (let i = 0; i < 60; i++) {
await sleep(5000); await sleep(5000);
@ -91,26 +117,26 @@ export async function provisionInstance(instanceId: string) {
} }
} }
if (!booted) { if (!booted) {
await logStep(instanceId, "wait_ready", "error", "VPS failed to boot within 5 minutes"); logStep(syncServer, instanceId, "wait_ready", "error", "VPS failed to boot within 5 minutes");
await updateInstance(instanceId, { status: "error", error_message: "VPS boot timeout" }); updateInstance(syncServer, instanceId, { status: "error", errorMessage: "VPS boot timeout" });
return; return;
} }
await logStep(instanceId, "wait_ready", "success", "VPS is running"); logStep(syncServer, instanceId, "wait_ready", "success", "VPS is running");
// Step 3: Configure DNS // Step 3: Configure DNS
await logStep(instanceId, "configure_dns", "running", "Configuring DNS..."); logStep(syncServer, instanceId, "configure_dns", "running", "Configuring DNS...");
const subdomain = instance.domain.replace(".rforum.online", ""); const subdomain = instance.domain.replace(".rforum.online", "");
const dns = await createDNSRecord(subdomain, ip); const dns = await createDNSRecord(subdomain, ip);
if (dns) { if (dns) {
await updateInstance(instanceId, { dns_record_id: dns.recordId }); updateInstance(syncServer, instanceId, { dnsRecordId: dns.recordId });
await logStep(instanceId, "configure_dns", "success", `DNS record created for ${instance.domain}`); logStep(syncServer, instanceId, "configure_dns", "success", `DNS record created for ${instance.domain}`);
} else { } else {
await logStep(instanceId, "configure_dns", "skipped", "DNS configuration skipped — configure manually"); logStep(syncServer, instanceId, "configure_dns", "skipped", "DNS configuration skipped — configure manually");
} }
// Step 4: Wait for Discourse install // Step 4: Wait for Discourse install
await updateInstance(instanceId, { status: "installing" }); updateInstance(syncServer, instanceId, { status: "installing" });
await logStep(instanceId, "install_discourse", "running", "Installing Discourse (this takes 10-15 minutes)..."); logStep(syncServer, instanceId, "install_discourse", "running", "Installing Discourse (this takes 10-15 minutes)...");
let installed = false; let installed = false;
for (let i = 0; i < 60; i++) { for (let i = 0; i < 60; i++) {
await sleep(15000); await sleep(15000);
@ -123,51 +149,52 @@ export async function provisionInstance(instanceId: string) {
} catch {} } catch {}
} }
if (!installed) { if (!installed) {
await logStep(instanceId, "install_discourse", "error", "Discourse did not respond within 15 minutes"); logStep(syncServer, instanceId, "install_discourse", "error", "Discourse did not respond within 15 minutes");
await updateInstance(instanceId, { status: "error", error_message: "Discourse install timeout" }); updateInstance(syncServer, instanceId, { status: "error", errorMessage: "Discourse install timeout" });
return; return;
} }
await logStep(instanceId, "install_discourse", "success", "Discourse is responding"); logStep(syncServer, instanceId, "install_discourse", "success", "Discourse is responding");
// Step 5: Verify live // Step 5: Verify live
await updateInstance(instanceId, { status: "configuring" }); updateInstance(syncServer, instanceId, { status: "configuring" });
await logStep(instanceId, "verify_live", "running", "Verifying Discourse is live..."); logStep(syncServer, instanceId, "verify_live", "running", "Verifying Discourse is live...");
try { try {
const res = await fetch(`https://${instance.domain}`, { redirect: "manual" }); const res = await fetch(`https://${instance.domain}`, { redirect: "manual" });
if (res.status === 200 || res.status === 302) { if (res.status === 200 || res.status === 302) {
await updateInstance(instanceId, { updateInstance(syncServer, instanceId, {
status: "active", status: "active",
ssl_provisioned: true, sslProvisioned: true,
provisioned_at: new Date().toISOString(), provisionedAt: Date.now(),
}); });
await logStep(instanceId, "verify_live", "success", "Forum is live with SSL!"); logStep(syncServer, instanceId, "verify_live", "success", "Forum is live with SSL!");
} else { } else {
await updateInstance(instanceId, { status: "active", provisioned_at: new Date().toISOString() }); updateInstance(syncServer, instanceId, { status: "active", provisionedAt: Date.now() });
await logStep(instanceId, "verify_live", "success", "Forum is live (SSL pending)"); logStep(syncServer, instanceId, "verify_live", "success", "Forum is live (SSL pending)");
} }
} catch { } catch {
await updateInstance(instanceId, { status: "active", provisioned_at: new Date().toISOString() }); updateInstance(syncServer, instanceId, { status: "active", provisionedAt: Date.now() });
await logStep(instanceId, "verify_live", "success", "Forum provisioned (SSL may take a few minutes)"); logStep(syncServer, instanceId, "verify_live", "success", "Forum provisioned (SSL may take a few minutes)");
} }
} catch (e: any) { } catch (e: any) {
console.error("[Forum] Provisioning error:", e); console.error("[Forum] Provisioning error:", e);
await updateInstance(instanceId, { status: "error", error_message: e.message }); updateInstance(syncServer, instanceId, { status: "error", errorMessage: e.message });
await logStep(instanceId, "unknown", "error", e.message); logStep(syncServer, instanceId, "unknown", "error", e.message);
} }
} }
export async function destroyInstance(instanceId: string) { export async function destroyInstance(syncServer: SyncServer, instanceId: string) {
const [instance] = await sql.unsafe("SELECT * FROM rforum.instances WHERE id = $1", [instanceId]); const doc = syncServer.getDoc<ForumDoc>(FORUM_DOC_ID);
const instance = doc?.instances[instanceId];
if (!instance) return; if (!instance) return;
await updateInstance(instanceId, { status: "destroying" }); updateInstance(syncServer, instanceId, { status: "destroying" });
if (instance.vps_id) { if (instance.vpsId) {
await deleteServer(instance.vps_id); await deleteServer(instance.vpsId);
} }
if (instance.dns_record_id) { if (instance.dnsRecordId) {
await deleteDNSRecord(instance.dns_record_id); await deleteDNSRecord(instance.dnsRecordId);
} }
await updateInstance(instanceId, { status: "destroyed", destroyed_at: new Date().toISOString() }); updateInstance(syncServer, instanceId, { status: "destroyed", destroyedAt: Date.now() });
} }

View File

@ -4,40 +4,39 @@
*/ */
import { Hono } from "hono"; import { Hono } from "hono";
import { readFileSync } from "node:fs"; import * as Automerge from "@automerge/automerge";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell, renderExternalAppShell } from "../../server/shell"; import { renderShell, renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import { provisionInstance, destroyInstance } from "./lib/provisioner"; import { provisionInstance, destroyInstance } from "./lib/provisioner";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing"; import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import {
forumSchema,
FORUM_DOC_ID,
type ForumDoc,
type ForumInstance,
} from './schemas';
let _syncServer: SyncServer | null = null;
const routes = new Hono(); const routes = new Hono();
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
// ── DB initialization ──
async function initDB() {
try {
await sql.unsafe(SCHEMA_SQL);
console.log("[Forum] DB schema initialized");
} catch (e: any) {
console.error("[Forum] DB init error:", e.message);
}
}
initDB();
// ── Helpers ── // ── Helpers ──
async function getOrCreateUser(did: string): Promise<any> {
const [existing] = await sql.unsafe("SELECT * FROM rforum.users WHERE did = $1", [did]); function ensureDoc(): ForumDoc {
if (existing) return existing; let doc = _syncServer!.getDoc<ForumDoc>(FORUM_DOC_ID);
const [user] = await sql.unsafe( if (!doc) {
"INSERT INTO rforum.users (did) VALUES ($1) RETURNING *", doc = Automerge.change(Automerge.init<ForumDoc>(), 'init forum doc', (d) => {
[did], const init = forumSchema.init();
); d.meta = init.meta;
return user; d.instances = {};
d.provisionLogs = {};
});
_syncServer!.setDoc(FORUM_DOC_ID, doc);
}
return doc;
} }
// ── API: List instances ── // ── API: List instances ──
@ -47,12 +46,13 @@ routes.get("/api/instances", async (c) => {
let claims; let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const user = await getOrCreateUser(claims.sub); const doc = ensureDoc();
const rows = await sql.unsafe( const instances = Object.values(doc.instances).filter(
"SELECT * FROM rforum.instances WHERE user_id = $1 AND status != 'destroyed' ORDER BY created_at DESC", (inst) => inst.userId === claims.sub && inst.status !== 'destroyed',
[user.id],
); );
return c.json({ instances: rows }); // Sort by createdAt descending
instances.sort((a, b) => b.createdAt - a.createdAt);
return c.json({ instances });
}); });
// ── API: Create instance ── // ── API: Create instance ──
@ -62,7 +62,6 @@ routes.post("/api/instances", async (c) => {
let claims; let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const user = await getOrCreateUser(claims.sub);
const body = await c.req.json<{ const body = await c.req.json<{
name: string; name: string;
subdomain: string; subdomain: string;
@ -79,17 +78,43 @@ routes.post("/api/instances", async (c) => {
const domain = `${body.subdomain}.rforum.online`; const domain = `${body.subdomain}.rforum.online`;
// Check uniqueness // Check uniqueness
const [existing] = await sql.unsafe("SELECT id FROM rforum.instances WHERE domain = $1", [domain]); const doc = ensureDoc();
const existing = Object.values(doc.instances).find((inst) => inst.domain === domain);
if (existing) return c.json({ error: "Domain already taken" }, 409); if (existing) return c.json({ error: "Domain already taken" }, 409);
const [instance] = await sql.unsafe( const id = crypto.randomUUID();
`INSERT INTO rforum.instances (user_id, name, domain, region, size, admin_email, smtp_config) const now = Date.now();
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) RETURNING *`,
[user.id, body.name, domain, body.region || "nbg1", body.size || "cx22", body.admin_email, JSON.stringify(body.smtp_config || {})], const instance: ForumInstance = {
); id,
userId: claims.sub,
name: body.name,
domain,
status: 'pending',
errorMessage: '',
discourseVersion: 'stable',
provider: 'hetzner',
vpsId: '',
vpsIp: '',
region: body.region || 'nbg1',
size: body.size || 'cx22',
adminEmail: body.admin_email,
smtpConfig: body.smtp_config || {},
dnsRecordId: '',
sslProvisioned: false,
createdAt: now,
updatedAt: now,
provisionedAt: 0,
destroyedAt: 0,
};
_syncServer!.changeDoc<ForumDoc>(FORUM_DOC_ID, 'create instance', (d) => {
d.instances[id] = instance;
d.provisionLogs[id] = [];
});
// Start provisioning asynchronously // Start provisioning asynchronously
provisionInstance(instance.id).catch((e) => { provisionInstance(_syncServer!, id).catch((e) => {
console.error("[Forum] Provision failed:", e); console.error("[Forum] Provision failed:", e);
}); });
@ -103,17 +128,11 @@ routes.get("/api/instances/:id", async (c) => {
let claims; let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const user = await getOrCreateUser(claims.sub); const doc = ensureDoc();
const [instance] = await sql.unsafe( const instance = doc.instances[c.req.param("id")];
"SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2", if (!instance || instance.userId !== claims.sub) return c.json({ error: "Instance not found" }, 404);
[c.req.param("id"), user.id],
);
if (!instance) return c.json({ error: "Instance not found" }, 404);
const logs = await sql.unsafe( const logs = doc.provisionLogs[instance.id] || [];
"SELECT * FROM rforum.provision_logs WHERE instance_id = $1 ORDER BY created_at ASC",
[instance.id],
);
return c.json({ instance, logs }); return c.json({ instance, logs });
}); });
@ -125,16 +144,13 @@ routes.delete("/api/instances/:id", async (c) => {
let claims; let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const user = await getOrCreateUser(claims.sub); const doc = ensureDoc();
const [instance] = await sql.unsafe( const instance = doc.instances[c.req.param("id")];
"SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2", if (!instance || instance.userId !== claims.sub) return c.json({ error: "Instance not found" }, 404);
[c.req.param("id"), user.id],
);
if (!instance) return c.json({ error: "Instance not found" }, 404);
if (instance.status === "destroyed") return c.json({ error: "Already destroyed" }, 400); if (instance.status === "destroyed") return c.json({ error: "Already destroyed" }, 400);
// Destroy asynchronously // Destroy asynchronously
destroyInstance(instance.id).catch((e) => { destroyInstance(_syncServer!, instance.id).catch((e) => {
console.error("[Forum] Destroy failed:", e); console.error("[Forum] Destroy failed:", e);
}); });
@ -143,10 +159,8 @@ routes.delete("/api/instances/:id", async (c) => {
// ── API: Get provision logs ── // ── API: Get provision logs ──
routes.get("/api/instances/:id/logs", async (c) => { routes.get("/api/instances/:id/logs", async (c) => {
const logs = await sql.unsafe( const doc = ensureDoc();
"SELECT * FROM rforum.provision_logs WHERE instance_id = $1 ORDER BY created_at ASC", const logs = doc.provisionLogs[c.req.param("id")] || [];
[c.req.param("id")],
);
return c.json({ logs }); return c.json({ logs });
}); });
@ -191,6 +205,7 @@ export const forumModule: RSpaceModule = {
icon: "💬", icon: "💬",
description: "Deploy and manage Discourse forums", description: "Deploy and manage Discourse forums",
scoping: { defaultScope: 'global', userConfigurable: true }, scoping: { defaultScope: 'global', userConfigurable: true },
docSchemas: [{ pattern: 'global:forum:instances', description: 'Forum provisioning metadata', init: forumSchema.init }],
routes, routes,
landingPage: renderLanding, landingPage: renderLanding,
standaloneDomain: "rforum.online", standaloneDomain: "rforum.online",
@ -215,4 +230,8 @@ export const forumModule: RSpaceModule = {
{ path: "threads", name: "Threads", icon: "💬", description: "Forum discussion threads" }, { path: "threads", name: "Threads", icon: "💬", description: "Forum discussion threads" },
{ path: "categories", name: "Categories", icon: "📂", description: "Forum categories and topics" }, { path: "categories", name: "Categories", icon: "📂", description: "Forum categories and topics" },
], ],
async onInit(ctx) {
_syncServer = ctx.syncServer;
console.log("[Forum] Module initialized (Automerge-only, no PG)");
},
}; };

92
modules/rforum/schemas.ts Normal file
View File

@ -0,0 +1,92 @@
/**
* rForum Automerge document types provisioning metadata for Discourse instances.
*
* One global doc (`global:forum:instances`) holds all forum instances and provision logs.
* Not space-scoped instances belong to users, not spaces.
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Instance types ──
export type InstanceStatus =
| 'pending'
| 'provisioning'
| 'installing'
| 'configuring'
| 'active'
| 'error'
| 'destroying'
| 'destroyed';
export interface ForumInstance {
id: string;
userId: string;
name: string;
domain: string;
status: InstanceStatus;
errorMessage: string;
discourseVersion: string;
provider: string;
vpsId: string;
vpsIp: string;
region: string;
size: string;
adminEmail: string;
smtpConfig: Record<string, unknown>;
dnsRecordId: string;
sslProvisioned: boolean;
createdAt: number;
updatedAt: number;
provisionedAt: number;
destroyedAt: number;
}
// ── Provision log types ──
export type StepStatus = 'running' | 'success' | 'error' | 'skipped';
export interface ProvisionLog {
id: string;
step: string;
status: StepStatus;
message: string;
metadata: Record<string, unknown>;
startedAt: number;
completedAt: number;
}
// ── Document root ──
export interface ForumDoc {
meta: {
module: string;
collection: string;
version: number;
createdAt: number;
};
instances: Record<string, ForumInstance>;
provisionLogs: Record<string, ProvisionLog[]>;
}
// ── Schema definition ──
export const forumSchema: DocSchema<ForumDoc> = {
module: 'forum',
collection: 'instances',
version: 1,
init: (): ForumDoc => ({
meta: {
module: 'forum',
collection: 'instances',
version: 1,
createdAt: Date.now(),
},
instances: {},
provisionLogs: {},
}),
};
// ── Document ID ──
export const FORUM_DOC_ID = 'global:forum:instances' as const;