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() {
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} &middot; ${inst.region} &middot; ${inst.size}
${inst.vps_ip ? ` &middot; ${inst.vps_ip}` : ""}
${inst.vpsIp ? ` &middot; ${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">

View File

@ -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() });
}

View File

@ -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)");
},
};

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;