Backend & Persistensi
Cara menyimpan dokumen, me-render-nya untuk end-user, dan menjalankan SaaS multi-tenant di atas nuforge. Runtime-nya backend-agnostic — sebuah dokumen hanyalah data yang Anda serialisasi, simpan, dan muat.
Versi yang bisa dijalankan dari semua di bawah ini ada di
examples/03-backend(app Next.js dengan store in-memory, dua tenant, dan editor save / publish):pnpm --filter @nuforge/example-backend dev.
Apa itu "dokumen"?
Dokumen adalah nu.state — program plus state extension apa pun. Ada tiga format
serialisasi; pilih sesuai kebutuhan.
| Format | Round-trip | Cocok untuk |
|---|---|---|
| JSON (id stabil) | t.flatten(nu.state) ↔ t.unflatten(...) | Dokumen editor — id node terjaga (history + kolaborasi tetap utuh). Rekomendasi. |
| DSL teks | Parser.stringify(nu.state.program) ↔ Parser.parseProgram(src) | Versioning git-friendly, review manusia. Id di-regenerate saat parse. |
| CRDT snapshot | Loro doc.export({ mode: 'snapshot' }) | Penyuntingan multi-user real-time + persistensi sekaligus (lihat Kolaborasi). |
import { Nu } from '@nuforge/core';
import * as t from '@nuforge/types';
// SIMPAN — snapshot JSON yang id-nya stabil
const document = JSON.stringify(t.flatten(nu.state));
// MUAT
nu.load(t.unflatten(JSON.parse(document)) as t.State);
State extension (tema, setting editor) tersimpan otomatis — ia ada di
state.extensions[key].value, yang merupakan bagian dari State.
Menyimpan dokumen
Skema Postgres minimal (setiap baris ber-scope tenant):
create table tenants (id uuid primary key, name text);
create table pages (
id uuid primary key,
tenant_id uuid not null references tenants(id),
slug text not null,
name text,
document jsonb not null, -- t.flatten(state)
status text not null default 'draft', -- 'draft' | 'published'
version int not null default 1,
updated_at timestamptz default now(),
unique (tenant_id, slug)
);
create table page_versions (
id uuid primary key,
page_id uuid not null references pages(id),
document jsonb not null,
author_id uuid,
created_at timestamptz default now()
);
Simpan dan muat lewat API route:
// PUT /api/pages/:id — body: { document: FlattenedState }
export async function savePage(id: string, document: unknown, tenantId: string) {
await db.page.update({
where: { id, tenantId },
data: { document, version: { increment: 1 }, updatedAt: new Date() },
});
await db.pageVersion.create({ data: { pageId: id, document } });
}
// GET /api/pages/:id
export async function loadPage(id: string, tenantId: string) {
const page = await db.page.findFirstOrThrow({ where: { id, tenantId } });
return page.document; // FlattenedState — serahkan ke t.unflatten di klien
}
Autosave
Berlangganan perubahan yang ter-commit dan debounce penyimpanan:
let timer: ReturnType<typeof setTimeout>;
nu.listenToChangeset(() => {
clearTimeout(timer);
timer = setTimeout(() => {
fetch(`/api/pages/${pageId}`, {
method: 'PUT',
body: JSON.stringify({ document: t.flatten(nu.state) }),
});
}, 800);
});
Draft vs published & versioning
- Pakai kolom
status(atau dua baris). Publish menyalin draft ke slot published (opsional pre-render — lihat di bawah). - Tiap save signifikan di-append ke
page_versions. Restore cukupnu.load(t.unflatten(version.document)).
Me-render halaman untuk end-user
Pilih pengiriman yang sesuai dengan halaman.
- SSR / RSC (dinamis, data per-request) — render dengan entry server:
// app/[tenant]/[slug]/page.tsx — React Server Component
import { Nu } from '@nuforge/core';
import { NuStatic } from '@nuforge/react/server';
import * as t from '@nuforge/types';
export default async function RenderPage({
params,
}: {
params: { tenant: string; slug: string };
}) {
const page = await db.page.findFirstOrThrow({
where: { tenantId: params.tenant, slug: params.slug, status: 'published' },
});
const data = await loadTenantData(params.tenant); // data dinamis halaman
const nu = Nu.create({
externals: {
states: [t.externalState({ name: 'data', init: data })], // $data di DSL
components: tenantComponents(params.tenant), // <$Chart/> dll
},
});
nu.load(t.unflatten(page.document) as t.State);
const frame = nu.createFrame({ component: { name: 'App' } });
return <NuStatic frame={frame} />;
}
Inilah jembatan untuk data dinamis: host yang mengambilnya dan
menyuntikkannya sebagai external state; DSL membaca $data. (DSL sendiri tak
bisa fetch — by design; lihat Externals.)
- HTML statis (cache CDN, email) — render sekali saat publish:
import { toHtml } from '@nuforge/codegen';
const html = toHtml(frame.view); // simpan/serve sebagai halaman statis
- Ekspor ke kode (situs statis / Next.js build) —
toReact(program)menghasilkan komponen.tsxnyata (dengan'use client'hanya saat perlu). Lihat Ekspor Kode.
Multi-tenant
Dua lapis: isolasi data (standar SaaS) dan isolasi runtime (allow-list nuforge yang mengerjakan bagian berat).
Isolasi data
tenant_iddi setiap baris plus row-level security (Postgres RLS) atau scoping di lapisan query. Dokumen halaman adalah data opaque seperti baris lain.- Shared-DB +
tenant_idadalah pola umum; pada skala besar, schema- atau DB-per-tenant.
Isolasi runtime (kenapa nuforge aman di sini)
Saat Anda me-render halaman tenant A, Nu.create({ externals }) berisi hanya
data dan komponen tenant A. Karena DSL allow-listed dan external state
di-deep-freeze, sebuah halaman tidak bisa menjangkau di luar yang Anda
inject — jadi tak ada kebocoran antar-tenant secara struktural. Konkretnya:
- Data per-tenant — inject hanya nilai tenant itu sebagai
externals.states. - Block/komponen per-tenant —
editor.blocks.register(...)danexternals.componentsberbeda per tenant (mis. komponen brand mereka). - Tema/token per-tenant — external state
$theme, atau CSS variable disrcDoccanvas (lihat SDK Editor).
Validasi dokumen tak tepercaya
Jika dokumen bisa berasal dari sumber tak tepercaya, validasi terhadap schema
sebelum load. Parser dan evaluator sudah memblokir sintaks dan akses properti
berbahaya; untuk konten pihak ketiga yang benar-benar tak tepercaya, tambahkan
sandbox di titik ekstensi. Lihat Model Keamanan.
Kolaborasi real-time (opsional)
Loro memberi persistensi dan penyuntingan multi-user sekaligus:
import { LoroSyncProvider } from '@nuforge/collaboration';
import { LoroDoc } from 'loro-crdt';
const doc = new LoroDoc();
new LoroSyncProvider(nu, doc).init();
// Relay doc.export(update) antar peer via WebSocket;
// snapshot doc.export({ mode: 'snapshot' }) ke DB secara berkala.
Server menyimpan snapshot sebagai source-of-truth dan menyiarkan update. Lihat Kolaborasi.
Checklist keamanan
- ✅ Validasi schema dokumen sebelum
load/unflatten, terutama dari kolaborasi atau import. - ✅ Inject hanya data tenant saat ini sebagai externals (isolasi).
- ✅ Scope setiap query dengan
tenant_id(RLS atau lapisan query). - ✅ External function Anda adalah attack surface — jangan ekspos kapabilitas berbahaya (akses DB mentah, fs) ke DSL.
- ⚠️ Untuk konten multi-tenant tak tepercaya penuh, tambahkan sandbox di titik ekstensi.
Langkah berikutnya
- Externals — inject data, fungsi, komponen per-tenant.
- Ekspor Kode — publish ke HTML statis atau React.
- Kolaborasi — sync real-time via Loro.
- Model Keamanan — jaminan di balik allow-list.