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.

FormatRound-tripCocok untuk
JSON (id stabil)t.flatten(nu.state)t.unflatten(...)Dokumen editor — id node terjaga (history + kolaborasi tetap utuh). Rekomendasi.
DSL teksParser.stringify(nu.state.program)Parser.parseProgram(src)Versioning git-friendly, review manusia. Id di-regenerate saat parse.
CRDT snapshotLoro 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 cukup nu.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 .tsx nyata (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_id di setiap baris plus row-level security (Postgres RLS) atau scoping di lapisan query. Dokumen halaman adalah data opaque seperti baris lain.
  • Shared-DB + tenant_id adalah 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-tenanteditor.blocks.register(...) dan externals.components berbeda per tenant (mis. komponen brand mereka).
  • Tema/token per-tenant — external state $theme, atau CSS variable di srcDoc canvas (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