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:
parent
1cbced27f8
commit
9443178d1c
|
|
@ -29,9 +29,9 @@ class FolkForumDashboard extends HTMLElement {
|
|||
|
||||
private loadDemoData() {
|
||||
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: "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: "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: "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", 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", adminEmail: "admin@archive.example", vpsIp: null, sslProvisioned: false },
|
||||
];
|
||||
this.loading = false;
|
||||
this.render();
|
||||
|
|
@ -299,7 +299,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
</div>
|
||||
<div class="instance-meta">
|
||||
${inst.domain} · ${inst.region} · ${inst.size}
|
||||
${inst.vps_ip ? ` · ${inst.vps_ip}` : ""}
|
||||
${inst.vpsIp ? ` · ${inst.vpsIp}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`).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>` : ""}
|
||||
</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-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>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>SSL</label><div class="value">${inst.ssl_provisioned ? "✅ Active" : "⏳ Pending"}</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.sslProvisioned ? "✅ Active" : "⏳ Pending"}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="logs-section">
|
||||
|
|
|
|||
|
|
@ -3,70 +3,96 @@
|
|||
* 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 { createDNSRecord, deleteDNSRecord } from "./dns";
|
||||
import { generateCloudInit, type DiscourseConfig } from "./cloud-init";
|
||||
|
||||
type StepStatus = "running" | "success" | "error" | "skipped";
|
||||
|
||||
async function logStep(
|
||||
function logStep(
|
||||
syncServer: SyncServer,
|
||||
instanceId: string,
|
||||
step: string,
|
||||
status: StepStatus,
|
||||
message: string,
|
||||
metadata: Record<string, unknown> = {},
|
||||
) {
|
||||
if (status === "running") {
|
||||
await sql.unsafe(
|
||||
`INSERT INTO rforum.provision_logs (instance_id, step, status, message, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)`,
|
||||
[instanceId, step, status, message, JSON.stringify(metadata)],
|
||||
);
|
||||
} else {
|
||||
await sql.unsafe(
|
||||
`UPDATE rforum.provision_logs SET status = $1, message = $2, metadata = $3::jsonb, completed_at = NOW()
|
||||
WHERE instance_id = $4 AND step = $5 AND status = 'running'`,
|
||||
[status, message, JSON.stringify(metadata), instanceId, step],
|
||||
);
|
||||
}
|
||||
const now = Date.now();
|
||||
syncServer.changeDoc<ForumDoc>(FORUM_DOC_ID, `log ${step} ${status}`, (d) => {
|
||||
if (!d.provisionLogs[instanceId]) d.provisionLogs[instanceId] = [];
|
||||
const logs = d.provisionLogs[instanceId];
|
||||
|
||||
if (status === "running") {
|
||||
logs.push({
|
||||
id: crypto.randomUUID(),
|
||||
step,
|
||||
status,
|
||||
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>) {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
sets.push(`${key} = $${idx}`);
|
||||
params.push(val);
|
||||
idx++;
|
||||
}
|
||||
sets.push("updated_at = NOW()");
|
||||
params.push(instanceId);
|
||||
await sql.unsafe(`UPDATE rforum.instances SET ${sets.join(", ")} WHERE id = $${idx}`, params);
|
||||
function updateInstance(
|
||||
syncServer: SyncServer,
|
||||
instanceId: string,
|
||||
fields: Partial<{
|
||||
status: string;
|
||||
errorMessage: string;
|
||||
vpsId: string;
|
||||
vpsIp: string;
|
||||
dnsRecordId: string;
|
||||
sslProvisioned: boolean;
|
||||
provisionedAt: number;
|
||||
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) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export async function provisionInstance(instanceId: string) {
|
||||
const [instance] = await sql.unsafe("SELECT * FROM rforum.instances WHERE id = $1", [instanceId]);
|
||||
export async function provisionInstance(syncServer: SyncServer, instanceId: string) {
|
||||
const doc = syncServer.getDoc<ForumDoc>(FORUM_DOC_ID);
|
||||
const instance = doc?.instances[instanceId];
|
||||
if (!instance) throw new Error("Instance not found");
|
||||
|
||||
await updateInstance(instanceId, { status: "provisioning" });
|
||||
updateInstance(syncServer, instanceId, { status: "provisioning" });
|
||||
|
||||
try {
|
||||
// Step 1: Create VPS
|
||||
await logStep(instanceId, "create_vps", "running", "Creating VPS...");
|
||||
logStep(syncServer, instanceId, "create_vps", "running", "Creating VPS...");
|
||||
const config: DiscourseConfig = {
|
||||
hostname: instance.domain,
|
||||
adminEmail: instance.admin_email,
|
||||
...(instance.smtp_config?.host ? {
|
||||
smtpHost: instance.smtp_config.host,
|
||||
smtpPort: instance.smtp_config.port,
|
||||
smtpUser: instance.smtp_config.user,
|
||||
smtpPassword: instance.smtp_config.password,
|
||||
adminEmail: instance.adminEmail,
|
||||
...(instance.smtpConfig?.host ? {
|
||||
smtpHost: instance.smtpConfig.host as string,
|
||||
smtpPort: instance.smtpConfig.port as number,
|
||||
smtpUser: instance.smtpConfig.user as string,
|
||||
smtpPassword: instance.smtpConfig.password as string,
|
||||
} : {}),
|
||||
};
|
||||
const userData = generateCloudInit(config);
|
||||
|
|
@ -76,11 +102,11 @@ export async function provisionInstance(instanceId: string) {
|
|||
region: instance.region,
|
||||
userData,
|
||||
});
|
||||
await updateInstance(instanceId, { vps_id: serverId, vps_ip: ip });
|
||||
await logStep(instanceId, "create_vps", "success", `VPS created: ${ip}`, { serverId, ip });
|
||||
updateInstance(syncServer, instanceId, { vpsId: serverId, vpsIp: ip });
|
||||
logStep(syncServer, instanceId, "create_vps", "success", `VPS created: ${ip}`, { serverId, ip });
|
||||
|
||||
// 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;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(5000);
|
||||
|
|
@ -91,26 +117,26 @@ export async function provisionInstance(instanceId: string) {
|
|||
}
|
||||
}
|
||||
if (!booted) {
|
||||
await logStep(instanceId, "wait_ready", "error", "VPS failed to boot within 5 minutes");
|
||||
await updateInstance(instanceId, { status: "error", error_message: "VPS boot timeout" });
|
||||
logStep(syncServer, instanceId, "wait_ready", "error", "VPS failed to boot within 5 minutes");
|
||||
updateInstance(syncServer, instanceId, { status: "error", errorMessage: "VPS boot timeout" });
|
||||
return;
|
||||
}
|
||||
await logStep(instanceId, "wait_ready", "success", "VPS is running");
|
||||
logStep(syncServer, instanceId, "wait_ready", "success", "VPS is running");
|
||||
|
||||
// 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 dns = await createDNSRecord(subdomain, ip);
|
||||
if (dns) {
|
||||
await updateInstance(instanceId, { dns_record_id: dns.recordId });
|
||||
await logStep(instanceId, "configure_dns", "success", `DNS record created for ${instance.domain}`);
|
||||
updateInstance(syncServer, instanceId, { dnsRecordId: dns.recordId });
|
||||
logStep(syncServer, instanceId, "configure_dns", "success", `DNS record created for ${instance.domain}`);
|
||||
} 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
|
||||
await updateInstance(instanceId, { status: "installing" });
|
||||
await logStep(instanceId, "install_discourse", "running", "Installing Discourse (this takes 10-15 minutes)...");
|
||||
updateInstance(syncServer, instanceId, { status: "installing" });
|
||||
logStep(syncServer, instanceId, "install_discourse", "running", "Installing Discourse (this takes 10-15 minutes)...");
|
||||
let installed = false;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(15000);
|
||||
|
|
@ -123,51 +149,52 @@ export async function provisionInstance(instanceId: string) {
|
|||
} catch {}
|
||||
}
|
||||
if (!installed) {
|
||||
await logStep(instanceId, "install_discourse", "error", "Discourse did not respond within 15 minutes");
|
||||
await updateInstance(instanceId, { status: "error", error_message: "Discourse install timeout" });
|
||||
logStep(syncServer, instanceId, "install_discourse", "error", "Discourse did not respond within 15 minutes");
|
||||
updateInstance(syncServer, instanceId, { status: "error", errorMessage: "Discourse install timeout" });
|
||||
return;
|
||||
}
|
||||
await logStep(instanceId, "install_discourse", "success", "Discourse is responding");
|
||||
logStep(syncServer, instanceId, "install_discourse", "success", "Discourse is responding");
|
||||
|
||||
// Step 5: Verify live
|
||||
await updateInstance(instanceId, { status: "configuring" });
|
||||
await logStep(instanceId, "verify_live", "running", "Verifying Discourse is live...");
|
||||
updateInstance(syncServer, instanceId, { status: "configuring" });
|
||||
logStep(syncServer, instanceId, "verify_live", "running", "Verifying Discourse is live...");
|
||||
try {
|
||||
const res = await fetch(`https://${instance.domain}`, { redirect: "manual" });
|
||||
if (res.status === 200 || res.status === 302) {
|
||||
await updateInstance(instanceId, {
|
||||
updateInstance(syncServer, instanceId, {
|
||||
status: "active",
|
||||
ssl_provisioned: true,
|
||||
provisioned_at: new Date().toISOString(),
|
||||
sslProvisioned: true,
|
||||
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 {
|
||||
await updateInstance(instanceId, { status: "active", provisioned_at: new Date().toISOString() });
|
||||
await logStep(instanceId, "verify_live", "success", "Forum is live (SSL pending)");
|
||||
updateInstance(syncServer, instanceId, { status: "active", provisionedAt: Date.now() });
|
||||
logStep(syncServer, instanceId, "verify_live", "success", "Forum is live (SSL pending)");
|
||||
}
|
||||
} catch {
|
||||
await updateInstance(instanceId, { status: "active", provisioned_at: new Date().toISOString() });
|
||||
await logStep(instanceId, "verify_live", "success", "Forum provisioned (SSL may take a few minutes)");
|
||||
updateInstance(syncServer, instanceId, { status: "active", provisionedAt: Date.now() });
|
||||
logStep(syncServer, instanceId, "verify_live", "success", "Forum provisioned (SSL may take a few minutes)");
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("[Forum] Provisioning error:", e);
|
||||
await updateInstance(instanceId, { status: "error", error_message: e.message });
|
||||
await logStep(instanceId, "unknown", "error", e.message);
|
||||
updateInstance(syncServer, instanceId, { status: "error", errorMessage: e.message });
|
||||
logStep(syncServer, instanceId, "unknown", "error", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function destroyInstance(instanceId: string) {
|
||||
const [instance] = await sql.unsafe("SELECT * FROM rforum.instances WHERE id = $1", [instanceId]);
|
||||
export async function destroyInstance(syncServer: SyncServer, instanceId: string) {
|
||||
const doc = syncServer.getDoc<ForumDoc>(FORUM_DOC_ID);
|
||||
const instance = doc?.instances[instanceId];
|
||||
if (!instance) return;
|
||||
|
||||
await updateInstance(instanceId, { status: "destroying" });
|
||||
updateInstance(syncServer, instanceId, { status: "destroying" });
|
||||
|
||||
if (instance.vps_id) {
|
||||
await deleteServer(instance.vps_id);
|
||||
if (instance.vpsId) {
|
||||
await deleteServer(instance.vpsId);
|
||||
}
|
||||
if (instance.dns_record_id) {
|
||||
await deleteDNSRecord(instance.dns_record_id);
|
||||
if (instance.dnsRecordId) {
|
||||
await deleteDNSRecord(instance.dnsRecordId);
|
||||
}
|
||||
|
||||
await updateInstance(instanceId, { status: "destroyed", destroyed_at: new Date().toISOString() });
|
||||
updateInstance(syncServer, instanceId, { status: "destroyed", destroyedAt: Date.now() });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,40 +4,39 @@
|
|||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import * as Automerge from "@automerge/automerge";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import { provisionInstance, destroyInstance } from "./lib/provisioner";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
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 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 ──
|
||||
async function getOrCreateUser(did: string): Promise<any> {
|
||||
const [existing] = await sql.unsafe("SELECT * FROM rforum.users WHERE did = $1", [did]);
|
||||
if (existing) return existing;
|
||||
const [user] = await sql.unsafe(
|
||||
"INSERT INTO rforum.users (did) VALUES ($1) RETURNING *",
|
||||
[did],
|
||||
);
|
||||
return user;
|
||||
|
||||
function ensureDoc(): ForumDoc {
|
||||
let doc = _syncServer!.getDoc<ForumDoc>(FORUM_DOC_ID);
|
||||
if (!doc) {
|
||||
doc = Automerge.change(Automerge.init<ForumDoc>(), 'init forum doc', (d) => {
|
||||
const init = forumSchema.init();
|
||||
d.meta = init.meta;
|
||||
d.instances = {};
|
||||
d.provisionLogs = {};
|
||||
});
|
||||
_syncServer!.setDoc(FORUM_DOC_ID, doc);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── API: List instances ──
|
||||
|
|
@ -47,12 +46,13 @@ routes.get("/api/instances", async (c) => {
|
|||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const user = await getOrCreateUser(claims.sub);
|
||||
const rows = await sql.unsafe(
|
||||
"SELECT * FROM rforum.instances WHERE user_id = $1 AND status != 'destroyed' ORDER BY created_at DESC",
|
||||
[user.id],
|
||||
const doc = ensureDoc();
|
||||
const instances = Object.values(doc.instances).filter(
|
||||
(inst) => inst.userId === claims.sub && inst.status !== 'destroyed',
|
||||
);
|
||||
return c.json({ instances: rows });
|
||||
// Sort by createdAt descending
|
||||
instances.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return c.json({ instances });
|
||||
});
|
||||
|
||||
// ── API: Create instance ──
|
||||
|
|
@ -62,7 +62,6 @@ routes.post("/api/instances", async (c) => {
|
|||
let claims;
|
||||
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<{
|
||||
name: string;
|
||||
subdomain: string;
|
||||
|
|
@ -79,17 +78,43 @@ routes.post("/api/instances", async (c) => {
|
|||
const domain = `${body.subdomain}.rforum.online`;
|
||||
|
||||
// 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);
|
||||
|
||||
const [instance] = await sql.unsafe(
|
||||
`INSERT INTO rforum.instances (user_id, name, domain, region, size, admin_email, smtp_config)
|
||||
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 id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
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
|
||||
provisionInstance(instance.id).catch((e) => {
|
||||
provisionInstance(_syncServer!, id).catch((e) => {
|
||||
console.error("[Forum] Provision failed:", e);
|
||||
});
|
||||
|
||||
|
|
@ -103,17 +128,11 @@ routes.get("/api/instances/:id", async (c) => {
|
|||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const user = await getOrCreateUser(claims.sub);
|
||||
const [instance] = await sql.unsafe(
|
||||
"SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2",
|
||||
[c.req.param("id"), user.id],
|
||||
);
|
||||
if (!instance) return c.json({ error: "Instance not found" }, 404);
|
||||
const doc = ensureDoc();
|
||||
const instance = doc.instances[c.req.param("id")];
|
||||
if (!instance || instance.userId !== claims.sub) return c.json({ error: "Instance not found" }, 404);
|
||||
|
||||
const logs = await sql.unsafe(
|
||||
"SELECT * FROM rforum.provision_logs WHERE instance_id = $1 ORDER BY created_at ASC",
|
||||
[instance.id],
|
||||
);
|
||||
const logs = doc.provisionLogs[instance.id] || [];
|
||||
|
||||
return c.json({ instance, logs });
|
||||
});
|
||||
|
|
@ -125,16 +144,13 @@ routes.delete("/api/instances/:id", async (c) => {
|
|||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const user = await getOrCreateUser(claims.sub);
|
||||
const [instance] = await sql.unsafe(
|
||||
"SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2",
|
||||
[c.req.param("id"), user.id],
|
||||
);
|
||||
if (!instance) return c.json({ error: "Instance not found" }, 404);
|
||||
const doc = ensureDoc();
|
||||
const instance = doc.instances[c.req.param("id")];
|
||||
if (!instance || instance.userId !== claims.sub) return c.json({ error: "Instance not found" }, 404);
|
||||
if (instance.status === "destroyed") return c.json({ error: "Already destroyed" }, 400);
|
||||
|
||||
// Destroy asynchronously
|
||||
destroyInstance(instance.id).catch((e) => {
|
||||
destroyInstance(_syncServer!, instance.id).catch((e) => {
|
||||
console.error("[Forum] Destroy failed:", e);
|
||||
});
|
||||
|
||||
|
|
@ -143,10 +159,8 @@ routes.delete("/api/instances/:id", async (c) => {
|
|||
|
||||
// ── API: Get provision logs ──
|
||||
routes.get("/api/instances/:id/logs", async (c) => {
|
||||
const logs = await sql.unsafe(
|
||||
"SELECT * FROM rforum.provision_logs WHERE instance_id = $1 ORDER BY created_at ASC",
|
||||
[c.req.param("id")],
|
||||
);
|
||||
const doc = ensureDoc();
|
||||
const logs = doc.provisionLogs[c.req.param("id")] || [];
|
||||
return c.json({ logs });
|
||||
});
|
||||
|
||||
|
|
@ -191,6 +205,7 @@ export const forumModule: RSpaceModule = {
|
|||
icon: "💬",
|
||||
description: "Deploy and manage Discourse forums",
|
||||
scoping: { defaultScope: 'global', userConfigurable: true },
|
||||
docSchemas: [{ pattern: 'global:forum:instances', description: 'Forum provisioning metadata', init: forumSchema.init }],
|
||||
routes,
|
||||
landingPage: renderLanding,
|
||||
standaloneDomain: "rforum.online",
|
||||
|
|
@ -215,4 +230,8 @@ export const forumModule: RSpaceModule = {
|
|||
{ path: "threads", name: "Threads", icon: "💬", description: "Forum discussion threads" },
|
||||
{ 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)");
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue