Arsitektur jsonb-persist
Updated: 2026-07-20 · Status: Outline
Outline: heading menetapkan cakupan; isi ditulis bertahap dari kode
renderers/jsonb-persist/.
1. Hybrid JSONB
Kolom inti relasional + payload JSONB. Tabel per Document mengikuti struktur normatif berikut (dijawab dari kontrak storage-agnostic di ../../spec/backend/01-core-basic.md §1–§2 dan ../../spec/backend/04-persist-backend.md):
CREATE TABLE {schema}.{module}_{plural} (
id uuid PRIMARY KEY, -- app-generated UUID v7, both drivers (SQLite column type: text)
tenant_id uuid NOT NULL,
version integer NOT NULL DEFAULT 1, -- optimistic concurrency
doc_status text, -- NULL = lifecycle-free
-- 'draft' | 'submitted' | 'cancelled'
amends uuid, -- UUID original yang di-cancel oleh amend
amended_by uuid, -- UUID versi baru dari amend
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz, -- kolom ini absen kalau persist.soft_delete: false
created_by uuid, updated_by uuid,
data jsonb NOT NULL DEFAULT '{}'
);Kontrak §2 terpenuhi: PK adalah UUID v7 di kedua driver, digenerate di app layer (NewUUIDv7 di renderers/jsonb-persist/tx.go) dan disertakan eksplisit pada tiap INSERT — bukan DEFAULT khusus driver. Sebelumnya SQLite memakai integer PRIMARY KEY AUTOINCREMENT; itu sudah ditutup, tidak lagi jadi penyimpangan per-backend.
Alasan desain: field bisnis (di data) berubah bentuknya jauh lebih sering daripada kolom struktural (id, tenant_id, version, lifecycle, audit timestamp) — memisahkan keduanya membuat structural diff (§2, migration engine 03-migration-engine.md) hanya perlu menyentuh DDL saat kolom struktural berubah, bukan setiap kali field bisnis ditambah. Trade-off: field di data tidak ter-index secara native — field yang butuh index/query cepat diangkat jadi generated column (§ Index Generation, 02-schema-strategies.md §3).
2. Pemenuhan Kontrak PersistBackend
Pemetaan tiap kemampuan wajib (../../spec/backend/04-persist-backend.md §2) ke mekanisme konkret backend ini:
| Kemampuan kontrak | Mekanisme jsonb-persist |
|---|---|
| Structural diff apply | renderers/jsonb-persist/migrate.go — PlanMigrations/ApplyMigrations menerjemahkan diff Document ke DDL Postgres/SQLite |
| Query resolution | Operator filter kontrak diterjemahkan ke SQL/JSONB path — lihat 04-query-and-keys.md §1 |
| Tree query resolution | Operator descendant_of/ancestor_of/child_of/root diterjemahkan ke prefix-match materialized path, tanpa recursive CTE — 02-schema-strategies.md §4, 04-query-and-keys.md §6 |
ctx.next_key | Tabel counter formspec_natural_key_counters, alokasi di bawah lock — 04-query-and-keys.md §2 |
| Index generation | Generated column dari data, indexed — 02-schema-strategies.md §3 (catatan dialek, lihat §4 di sana) |
| Uninstall extension bersih | Belum diimplementasikan — DDL ADD COLUMN ext_* dibuat saat extension dipasang, tapi tidak ada DROP COLUMN/rute uninstall di kode manapun. Lihat 02-schema-strategies.md §2. |
3. Transaksi, Outbox, Audit
DB.BeginTx/Tx sekarang benar-benar dipakai lewat InTx (helper di renderers/jsonb-persist/tx.go): EntityStore.Insert/Update/SoftDelete membungkus seluruh isi mutasinya — UPSERT counter natural key, INSERT/ UPDATE baris utama, sinkronisasi child table, audit log, dan (untuk Insert/Update) enqueue outbox — dalam satu transaksi, commit semua atau rollback semua. Ini menutup dua gap yang sebelumnya dicatat di sini:
- Counter natural key tidak lagi bisa "terpakai" tanpa insert — UPSERT counter (
NaturalKeyCounter.NextSequence) jalan di koneksi transaksi yang sama denganINSERTbaris; kalau insert gagal (validasi, guard, constraint), rollback membatalkan increment counter juga. Lihat04-query-and-keys.md§2. - Mutasi + outbox atomik untuk jalur create/update —
internal/api'sHandleCreate/HandleUpdateme-resolve emission event yangdurablesebelum memanggilInsert/Update, lalu mengirimkannya lewatInsertParams/UpdateParams.PendingEvents;EntityStoremeng-enqueue-nya keformspec_outboxdi transaksi yang sama dengan baris entity (enqueueOutboxdioutbox.go, dipanggil dengan DB yang terikat transaksi, bukans.dbpolos).action.DeliverEventsyang dipanggil sesudahnya menerima flagoutboxAlreadyEnqueued=truesehingga tidak meng-enqueue ulang — perannya di jalur ini murni pengiriman best-effort (push websocket langsung, tulis event log non-durable).
Custom action (Starlark/native/sidecar) kini juga atomik lewat TxScope (renderers/jsonb-persist/txscope.go). HandleCustomAction membuka satu TxScope per eksekusi action, membungkusnya ke ctx (db.WithTxScope); setiap EntityStore.Insert/Update/SoftDelete/ UpdateFields/IncrementField/DecrementField yang dipanggil dalam satu eksekusi itu — lewat resource.save()/resource.create() Starlark, handler native, atau callback sidecar (/ctx/entity/{op}, dikorelasikan lewat header X-FormSpec-Scope-Id, lihat ../runtimes/04-formspec-sidecar.md §4.3a) — ikut transaksi yang sama alih-alih commit sendiri-sendiri. Enqueue outbox untuk event durable juga naik ke transaksi yang sama (EnqueueOutboxTx), baru scope.Commit() di akhir; error di titik mana pun → scope.Rollback() membatalkan semua mutasi dalam eksekusi itu, bukan cuma mutasi terakhir.
Kontrak kedua yang ditegakkan TxScope: transaksi boleh mencakup banyak Module selama semuanya berbagi satu Datastore fisik yang sama (hari ini selalu begitu — belum ada Datastore per-Module, Fase 2.9) — TxScope.join membandingkan identitas store (db.DB yang mendasari), bukan nama Module. Baru kalau dua Module benar-benar terikat ke Datastore berbeda (nanti, saat Fase 2.9 ada), mutasi kedua akan gagal dengan ErrCrossStoreTx — mencegah dua transaksi SQL dipaksa berbagi satu koneksi yang secara fisik tidak mungkin, bukan melarang orkestrasi lintas-Module itu sendiri (yang tetap sah selama satu Datastore).
Gap yang tersisa, dicatat eksplisit, bukan disembunyikan:action.RunAfterPhase masih tidak mengembalikan error (fire-and-forget, tidak berubah) — mutasi di after-hook yang gagal di situ tidak memicu rollback scope, sama seperti perilaku sebelum TxScope ada. SDK lib-formspec-* (PHP/Python/TypeScript/dll di sdk/) belum ada yang mengirim X-FormSpec-Scope-Id — sampai diperbarui, callback ctx.entity.* dari app process sidecar tetap commit independen (bukan regresi, gap follow-up terpisah).
4. Status Implementasi Hari Ini
renderers/jsonb-persist.DB/Txbelum jadi seam PersistBackend yang bersih — bocor semantik SQL (ExecContext,QueryContext,Driver() *sql.DB) ke pemanggil, dan migration engine menghasilkanDDLResult(teks SQL) sebagai representasi diff-nya, bukan diff storage-agnostic yang baru diterjemahkan belakangan.- Mutasi entity + counter natural key + outbox atomik untuk create/update DAN custom action (§3) —
BeginTxdipakai lewatInTx(jalur mandiri) atauTxScope(jalur request-scoped, lintas beberapa panggilan store dalam satu eksekusi action). Sisi SDK sidecar untuk headerX-FormSpec-Scope-Idbelum diperbarui (gap tercatat di §3). - PK UUID v7 di kedua driver (§1) — penyimpangan SQLite sebelumnya sudah ditutup.
- Uninstall extension bersih (§2 tabel) belum ada implementasinya sama sekali, bukan cuma belum lengkap.
ExtensionStore(baca/tulis kolomext_*saat request) ada di kode tapi tidak pernah dipanggil dariEntityStore/HTTP handler manapun — kolom extension dibuat migrasi tapi belum bisa diisi/dibaca lewat jalur apa pun hari ini. Lihat02-schema-strategies.md§2.
Dicatat sebagai gap arsitektural — lihat ../../spec/backend/04-persist-backend.md §8 dan ../../architecture/08-repo-structure.md §4.