Endpoint tulis jarang berdiri sendirian. POST /artikel yang menerima tag harus menyisipkan satu baris artikel dan beberapa baris hubungan tag; jika yang kedua gagal, yang pertama harus ikut batal — kalau tidak, database pelan-pelan dipenuhi artikel setengah jadi. Di sekitarnya berdiri pertanyaan-pertanyaan lain yang sama nyata: bagaimana aplikasi tahu bahwa error yang diterimanya adalah “email sudah dipakai” (bukan bug), bagaimana dua klien yang menyunting artikel yang sama tidak saling menimpa, dan apa sebenarnya yang terjadi saat akun “dihapus”. Pada bagian ini keempatnya dijawab dengan transaksi, SQLSTATE, konkurensi optimis, dan satu evolusi kecil pada skema.

Skema Versi 2: Email yang Bisa Didaftarkan Ulang #

Berkas pembuka membangun ulang skema dengan satu perubahan yang kelihatan kecil tapi berdampak besar: constraint UNIQUE (email) dari Bagian 1 diganti index unik parsial WHERE dihapus_pada IS NULL. Konsekuensinya: keunikan email hanya berlaku di antara akun aktif. Akun yang dihapus lembut tidak lagi memaku emailnya selamanya — pengguna yang kembali bisa mendaftar ulang dengan email yang sama:

-- siapkan-tabel.sql — fondasi Bagian 3: skema versi 2 plus data contoh.
-- Satu perubahan penting dari Bagian 1: keunikan email kini dijaga
-- index unik PARSIAL dengan WHERE dihapus_pada IS NULL, bukan lagi
-- UNIQUE constraint biasa. Artinya email hanya wajib unik di antara
-- pengguna aktif — email milik akun yang dihapus lembut boleh
-- didaftarkan ulang. Bagian ini dimaksudkan mandiri dari database kosong.

CREATE TABLE pengguna (
    id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    email         text NOT NULL,
    password_hash text NOT NULL,
    dibuat_pada   timestamptz NOT NULL,
    dihapus_pada  timestamptz
);

CREATE TABLE artikel (
    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    penulis_id  uuid NOT NULL REFERENCES pengguna (id),
    judul       text NOT NULL,
    isi         text NOT NULL,
    status      text NOT NULL DEFAULT 'draft',
    dibuat_pada timestamptz NOT NULL,
    diubah_pada timestamptz NOT NULL,
    dihapus_pada timestamptz,
    CONSTRAINT judul_tidak_kosong CHECK (judul <> ''),
    CONSTRAINT isi_tidak_kosong  CHECK (isi <> ''),
    CONSTRAINT status_sah CHECK (status IN ('draft', 'published'))
);

CREATE TABLE tag (
    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    nama        text NOT NULL,
    dibuat_pada timestamptz NOT NULL,
    CONSTRAINT nama_tag_unik UNIQUE (nama),
    CONSTRAINT tag_selalu_lowercase CHECK (nama = lower(nama))
);

CREATE TABLE artikel_tag (
    artikel_id uuid NOT NULL REFERENCES artikel (id) ON DELETE CASCADE,
    tag_id     uuid NOT NULL REFERENCES tag (id) ON DELETE CASCADE,
    PRIMARY KEY (artikel_id, tag_id)
);

-- Keunikan email hanya untuk pengguna aktif.
CREATE UNIQUE INDEX pengguna_email_aktif
    ON pengguna (email)
    WHERE dihapus_pada IS NULL;

INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
VALUES
    ('11111111-1111-4111-8111-111111111111',
     '[email protected]', 'sandi_hash_budi_placeholder',
     TIMESTAMPTZ '2026-04-01 08:00:00+07'),
    ('22222222-2222-4222-8222-222222222222',
     '[email protected]', 'sandi_hash_siti_placeholder',
     TIMESTAMPTZ '2026-04-02 09:30:00+07'),
    ('33333333-3333-4333-8333-333333333333',
     '[email protected]', 'sandi_hash_dewi_placeholder',
     TIMESTAMPTZ '2026-04-03 10:15:00+07');

INSERT INTO tag (id, nama, dibuat_pada)
VALUES
    ('10000000-0000-4000-8000-000000000001', 'postgresql',
     TIMESTAMPTZ '2026-04-01 08:05:00+07'),
    ('10000000-0000-4000-8000-000000000002', 'sql',
     TIMESTAMPTZ '2026-04-01 08:05:00+07'),
    ('10000000-0000-4000-8000-000000000003', 'database',
     TIMESTAMPTZ '2026-04-01 08:05:00+07'),
    ('10000000-0000-4000-8000-000000000004', 'backend',
     TIMESTAMPTZ '2026-04-01 08:05:00+07'),
    ('10000000-0000-4000-8000-000000000005', 'kinerja',
     TIMESTAMPTZ '2026-04-01 08:05:00+07');

INSERT INTO artikel (id, penulis_id, judul, isi, status,
                     dibuat_pada, diubah_pada, dihapus_pada)
VALUES
    ('20000000-0000-4000-8000-000000000001',
     '11111111-1111-4111-8111-111111111111',
     'Kenali UUID sebagai Primary Key',
     'Primary key UUID membuat identifier sulit ditebak dan aman diekspos di URL.',
     'published',
     TIMESTAMPTZ '2026-05-01 09:00:00+07',
     TIMESTAMPTZ '2026-05-01 09:00:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000002',
     '22222222-2222-4222-8222-222222222222',
     'Rancangan Skema untuk REST API',
     'Skema yang baik membuat validasi endpoint menjadi murah.',
     'published',
     TIMESTAMPTZ '2026-05-03 10:00:00+07',
     TIMESTAMPTZ '2026-05-03 10:00:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000003',
     '33333333-3333-4333-8333-333333333333',
     'Pagination Keyset vs OFFSET',
     'OFFSET membaca lalu membuang baris; keyset melompat lewat index komposit.',
     'published',
     TIMESTAMPTZ '2026-05-05 08:30:00+07',
     TIMESTAMPTZ '2026-05-06 16:45:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000004',
     '11111111-1111-4111-8111-111111111111',
     'Soft Delete dan Jejak Audit',
     'Kolom dihapus_pada yang NULL memisahkan baris hidup dari baris mati.',
     'draft',
     TIMESTAMPTZ '2026-05-06 11:20:00+07',
     TIMESTAMPTZ '2026-05-06 11:20:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000005',
     '22222222-2222-4222-8222-222222222222',
     'Validasi Data di Tingkat Database',
     'Constraint yang tertulis di skema bekerja untuk semua aplikasi.',
     'published',
     TIMESTAMPTZ '2026-05-07 14:00:00+07',
     TIMESTAMPTZ '2026-05-07 14:00:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000006',
     '33333333-3333-4333-8333-333333333333',
     'EXPLAIN sebagai Alat Diagnosis Query',
     'Bacalah rencana eksekusi sebelum menambah index demi index.',
     'draft',
     TIMESTAMPTZ '2026-05-09 07:45:00+07',
     TIMESTAMPTZ '2026-05-09 07:45:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000007',
     '11111111-1111-4111-8111-111111111111',
     'Transaksi Atomik untuk Endpoint',
     'INSERT artikel dan tag harus berjalan dalam satu transaksi.',
     'published',
     TIMESTAMPTZ '2026-05-10 13:10:00+07',
     TIMESTAMPTZ '2026-05-10 13:10:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000008',
     '22222222-2222-4222-8222-222222222222',
     'Index Parsial untuk Data Hidup',
     'Index parsial hanya menampung baris yang belum dihapus lembut.',
     'published',
     TIMESTAMPTZ '2026-05-12 09:50:00+07',
     TIMESTAMPTZ '2026-05-12 09:50:00+07',
     TIMESTAMPTZ '2026-05-20 10:00:00+07');

INSERT INTO artikel_tag (artikel_id, tag_id)
VALUES
    ('20000000-0000-4000-8000-000000000001',
     '10000000-0000-4000-8000-000000000001'),
    ('20000000-0000-4000-8000-000000000001',
     '10000000-0000-4000-8000-000000000003'),
    ('20000000-0000-4000-8000-000000000002',
     '10000000-0000-4000-8000-000000000003'),
    ('20000000-0000-4000-8000-000000000002',
     '10000000-0000-4000-8000-000000000004'),
    ('20000000-0000-4000-8000-000000000003',
     '10000000-0000-4000-8000-000000000002'),
    ('20000000-0000-4000-8000-000000000003',
     '10000000-0000-4000-8000-000000000005'),
    ('20000000-0000-4000-8000-000000000004',
     '10000000-0000-4000-8000-000000000003'),
    ('20000000-0000-4000-8000-000000000004',
     '10000000-0000-4000-8000-000000000004'),
    ('20000000-0000-4000-8000-000000000005',
     '10000000-0000-4000-8000-000000000001'),
    ('20000000-0000-4000-8000-000000000005',
     '10000000-0000-4000-8000-000000000002'),
    ('20000000-0000-4000-8000-000000000006',
     '10000000-0000-4000-8000-000000000005'),
    ('20000000-0000-4000-8000-000000000007',
     '10000000-0000-4000-8000-000000000002'),
    ('20000000-0000-4000-8000-000000000007',
     '10000000-0000-4000-8000-000000000004'),
    ('20000000-0000-4000-8000-000000000008',
     '10000000-0000-4000-8000-000000000001'),
    ('20000000-0000-4000-8000-000000000008',
     '10000000-0000-4000-8000-000000000005');

-- Bukti skema versi 2: index unik email bersifat parsial.
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
  AND tablename = 'pengguna'
ORDER BY indexname;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE INDEX
INSERT 0 3
INSERT 0 5
INSERT 0 8
INSERT 0 15
      indexname       |                                                   indexdef                                                   
----------------------+--------------------------------------------------------------------------------------------------------------
 pengguna_email_aktif | CREATE UNIQUE INDEX pengguna_email_aktif ON public.pengguna USING btree (email) WHERE (dihapus_pada IS NULL)
 pengguna_pkey        | CREATE UNIQUE INDEX pengguna_pkey ON public.pengguna USING btree (id)
(2 rows)

Query penutupnya membuktikan wujud index baru: CREATE UNIQUE INDEX pengguna_email_aktif ... WHERE (dihapus_pada IS NULL). Klausul WHERE itulah yang membedakannya dari constraint UNIQUE biasa — baris akun terhapus tidak pernah masuk index, sehingga pemeriksaan keunikannya pun tidak menjangkau mereka. Teknik ini dibahas mendalam di chapter Partial Index .

POST Artikel dengan Tag: Satu Transaksi #

Badan request POST /artikel lazim berbentuk {"judul": ..., "tags": ["sql", "kinerja"]} — satu objek yang harus menjadi satu kenyataan. Dua INSERT di bawah dibungkus BEGIN ... COMMIT; di antara keduanya tidak ada jendela waktu ketika artikel sudah ada tapi tag-nya belum:

-- buat-dengan-tag.sql — POST /artikel dengan tag dalam SATU transaksi.
-- INSERT artikel dan INSERT artikel_tag harus berhasil bersama-sama atau
-- batal bersama-sama; tidak ada keadaan "artikel tanpa tag setengah jadi".
-- id artikel ditangkap lewat RETURNING ... \gset lalu dipakai berikutnya.

BEGIN;

INSERT INTO artikel (penulis_id, judul, isi, status,
                     dibuat_pada, diubah_pada)
VALUES ('33333333-3333-4333-8333-333333333333',
        'Rahasia Index Komposit',
        'Urutan kolom pada index komposit menentukan query mana yang terbantu.',
        'published',
        TIMESTAMPTZ '2026-05-14 08:00:00+07',
        TIMESTAMPTZ '2026-05-14 08:00:00+07')
RETURNING id AS id_baru \gset

INSERT INTO artikel_tag (artikel_id, tag_id)
VALUES (:'id_baru'::uuid, '10000000-0000-4000-8000-000000000002'),
       (:'id_baru'::uuid, '10000000-0000-4000-8000-000000000005');

COMMIT;

-- GET /artikel/:id untuk artikel baru: artikel dan kedua tag utuh.
-- Kolom id sengaja tidak ditampilkan karena gen_random_uuid()
-- menghasilkan nilai yang berbeda di setiap eksekusi.
SELECT jsonb_build_object(
           'judul', a.judul,
           'status', a.status,
           'dibuat_pada', a.dibuat_pada,
           'tags', COALESCE(
               jsonb_agg(t.nama ORDER BY t.nama)
                   FILTER (WHERE t.nama IS NOT NULL),
               '[]'::jsonb)
       ) AS respons
FROM artikel a
LEFT JOIN artikel_tag at2 ON at2.artikel_id = a.id
LEFT JOIN tag t ON t.id = at2.tag_id
WHERE a.id = :'id_baru'::uuid
GROUP BY a.judul, a.status, a.dibuat_pada;

Output yang Dihasilkan:

text
BEGIN
INSERT 0 1
INSERT 0 2
COMMIT
                                                              respons                                                               
------------------------------------------------------------------------------------------------------------------------------------
 {"tags": ["kinerja", "sql"], "judul": "Rahasia Index Komposit", "status": "published", "dibuat_pada": "2026-05-14T08:00:00+07:00"}
(1 row)

Alurnya lima langkah:

  1. BEGIN membuka transaksi — semua yang mengikutinya bergerak sebagai satu unit.
  2. INSERT INTO artikel ... RETURNING id AS id_baru \gset: id artikel ditangkap ke variabel psql id_baru tanpa ditampilkan. Di aplikasi nyata pola ini hidup di kode: kirim INSERT, baca RETURNING, pakai id-nya untuk INSERT berikutnya.
  3. INSERT INTO artikel_tag dua baris dengan :'id_baru'::uuid — variabel diqouting lalu dicast ke uuid.
  4. COMMIT mengukuhkan semuanya sekaligus. Andai baris tag kedua melanggar constraint, seluruh transaksi dibatalkan — artikel baru tidak pernah tinggal sendirian.
  5. Query verifikasi merangkai detail artikel baru: kedua tag utuh, dan kolom id sengaja tidak ditampilkan karena gen_random_uuid() menghasilkan nilai berbeda di setiap eksekusi.

Ketika Email Sudah Dipakai #

Aplikasi menerima error mentah dari server — dan error itu informatif. Berkas berikut sengaja gagal untuk memperlihatkan wujud aslinya:

-- email-sudah-dipakai.sql — POST /pendaftaran dengan email yang sudah
-- dipakai pengguna aktif: ditolak index unik parsial.
-- Berkas ini sengaja berakhir dengan error; psql keluar dengan kode 3.
-- Error inilah yang oleh aplikasi dipetakan menjadi 409 Conflict.
INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
VALUES ('99999999-9999-4999-8999-999999999999',
        '[email protected]', 'sandi_hash_palsu_placeholder',
        TIMESTAMPTZ '2026-05-14 09:00:00+07');

Output yang Dihasilkan:

text
psql:email-sudah-dipakai.sql:8: ERROR:  duplicate key value violates unique constraint "pengguna_email_aktif"
DETAIL:  Key (email)=([email protected]) already exists.

Nama index parsial pengguna_email_aktif tampil di pesan error, lengkap dengan DETAIL yang menyebut pasangan kunci yang bentrok. Pada koneksi aplikasi, error yang sama tiba sebagai exception — dan keputusan yang tepat tidak dibaca dari teksnya, melainkan dari kode SQLSTATE-nya.

Memetakan Error ke Status HTTP #

Teks pesan error bisa berubah antar versi PostgreSQL; kode SQLSTATE tidak. Konvensi REST memetakan kode-kode itu ke status HTTP yang bermakna bagi klien:

SQLSTATENamaPemicuStatus HTTP tepat
23505unique_violationemail sudah dipakai akun aktif409 Conflict
23503foreign_key_violationpenulis tidak dikenal422 Unprocessable Entity
23514check_violationstatus di luar draft/published422 Unprocessable Entity

DO block berikut meniru try/catch aplikasi: menangkap exception, membaca SQLSTATE lewat GET STACKED DIAGNOSTICS, lalu memetakan ke respons — pola yang di chapter Menangani Database Errors ditunjukkan hidup di psycopg dan node-postgres:

sql tangani-409.sql
-- tangani-409.sql — pola penanganan error constraint di sisi aplikasi:
-- tangkap exception, baca SQLSTATE, petakan ke status HTTP yang tepat.
-- DO block berikut meniru try/catch yang biasanya hidup di kode aplikasi
-- (pola yang sama dibahas pada chapter Menangani Database Errors).

CREATE TEMP TABLE pemetaan_error (
    urutan      integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kasus       text NOT NULL,
    sqlstate    text NOT NULL,
    status_http text NOT NULL,
    hasil       text NOT NULL
);

DO $$
DECLARE
    v_state text;
BEGIN
    -- Email yang sudah dipakai pengguna aktif: unique_violation.
    BEGIN
        INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
        VALUES ('99999999-9999-4999-8999-999999999998',
                '[email protected]', 'sandi_hash_palsu_placeholder',
                TIMESTAMPTZ '2026-05-14 10:00:00+07');
        INSERT INTO pemetaan_error (kasus, sqlstate, status_http, hasil)
        VALUES ('email sudah dipakai', '-', '-',
                'GAGAL: baris justru diterima');
    EXCEPTION WHEN unique_violation THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO pemetaan_error (kasus, sqlstate, status_http, hasil)
        VALUES ('email sudah dipakai', v_state, '409 Conflict',
                'LULUS: tertangkap dan dipetakan');
    END;

    -- Penulis tidak dikenal pada POST /artikel: foreign_key_violation.
    BEGIN
        INSERT INTO artikel (id, penulis_id, judul, isi, status,
                             dibuat_pada, diubah_pada)
        VALUES ('99999999-9999-4999-8999-999999999997',
                '99999999-9999-4999-8999-999999999999',
                'Penulis hantu', 'Penulis tidak ada di tabel pengguna.',
                'draft',
                TIMESTAMPTZ '2026-05-14 10:10:00+07',
                TIMESTAMPTZ '2026-05-14 10:10:00+07');
        INSERT INTO pemetaan_error (kasus, sqlstate, status_http, hasil)
        VALUES ('penulis tidak dikenal', '-', '-',
                'GAGAL: baris justru diterima');
    EXCEPTION WHEN foreign_key_violation THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO pemetaan_error (kasus, sqlstate, status_http, hasil)
        VALUES ('penulis tidak dikenal', v_state, '422 Unprocessable Entity',
                'LULUS: tertangkap dan dipetakan');
    END;

    -- Status di luar daftar sah pada POST /artikel: check_violation.
    BEGIN
        INSERT INTO artikel (id, penulis_id, judul, isi, status,
                             dibuat_pada, diubah_pada)
        VALUES ('99999999-9999-4999-8999-999999999996',
                '11111111-1111-4111-8111-111111111111',
                'Status karatan', 'Uji status archived.', 'archived',
                TIMESTAMPTZ '2026-05-14 10:20:00+07',
                TIMESTAMPTZ '2026-05-14 10:20:00+07');
        INSERT INTO pemetaan_error (kasus, sqlstate, status_http, hasil)
        VALUES ('status di luar draft/published', '-', '-',
                'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO pemetaan_error (kasus, sqlstate, status_http, hasil)
        VALUES ('status di luar draft/published', v_state,
                '422 Unprocessable Entity',
                'LULUS: tertangkap dan dipetakan');
    END;
END $$;

SELECT kasus, sqlstate, status_http, hasil
FROM pemetaan_error
ORDER BY urutan;

Output yang Dihasilkan:

text
CREATE TABLE
DO
             kasus              | sqlstate |       status_http        |              hasil              
--------------------------------+----------+--------------------------+---------------------------------
 email sudah dipakai            | 23505    | 409 Conflict             | LULUS: tertangkap dan dipetakan
 penulis tidak dikenal          | 23503    | 422 Unprocessable Entity | LULUS: tertangkap dan dipetakan
 status di luar draft/published | 23514    | 422 Unprocessable Entity | LULUS: tertangkap dan dipetakan
(3 rows)

Tiga baris LULUS berarti tiga pelanggaran tertangkap dengan kode yang benar — 23505, 23503, 23514 — dan masing-masing sudah membawa pasangan status HTTP-nya. Daftar lengkap semua kode hidup di Appendix A dokumentasi PostgreSQL (tautan di Referensi).

Konkurensi Optimis: Siapa yang Menang #

Dua editor membuka artikel yang sama. Tanpa penjagaan, yang menyimpan belakangan menimpa tulisan yang pertama tanpa pernah tahu ada yang lebih dulu — lost update, bug paling senyap di aplikasi kolaboratif. Solusi optimis tidak mengunci apa pun saat membaca; ia hanya menolak tulisan yang datang dengan salinan usang:

-- konkurensi-optimis.sql — PATCH /artikel/:id tanpa kunci pesimistis:
-- UPDATE hanya berhasil bila diubah_pada masih cocok dengan salinan yang
-- dibaca klien. Baris hasil 0 berarti orang lain lebih dulu menyimpan —
-- sinyal bagi aplikasi untuk membalas 409 Conflict.

-- Klien membaca artikel dan menyimpan salinan diubah_pada (If-Match).
SELECT id, judul, diubah_pada
FROM artikel
WHERE id = '20000000-0000-4000-8000-000000000003'
  AND dihapus_pada IS NULL;

-- Klien A menyimpan: salinan masih segar, satu baris ter-update.
UPDATE artikel
SET judul = 'Pagination Keyset vs OFFSET (revisi)',
    diubah_pada = TIMESTAMPTZ '2026-05-15 08:00:00+07'
WHERE id = '20000000-0000-4000-8000-000000000003'
  AND diubah_pada = TIMESTAMPTZ '2026-05-06 16:45:00+07'
  AND dihapus_pada IS NULL
RETURNING id, judul, diubah_pada;

-- Klien B menyimpan dengan salinan lama: diubah_pada sudah bergeser,
-- nol baris ter-update — tidak ada tulisan yang saling menimpa.
UPDATE artikel
SET judul = 'Pagination Keyset vs OFFSET (versi B)',
    diubah_pada = TIMESTAMPTZ '2026-05-15 08:30:00+07'
WHERE id = '20000000-0000-4000-8000-000000000003'
  AND diubah_pada = TIMESTAMPTZ '2026-05-06 16:45:00+07'
  AND dihapus_pada IS NULL
RETURNING id, judul, diubah_pada;

-- Kondisi akhir: revisi klien A menang, tulisan klien B tidak pernah masuk.
SELECT judul, diubah_pada
FROM artikel
WHERE id = '20000000-0000-4000-8000-000000000003';

Output yang Dihasilkan:

text
                  id                  |            judul            |      diubah_pada       
--------------------------------------+-----------------------------+------------------------
 20000000-0000-4000-8000-000000000003 | Pagination Keyset vs OFFSET | 2026-05-06 16:45:00+07
(1 row)

                  id                  |                judul                 |      diubah_pada       
--------------------------------------+--------------------------------------+------------------------
 20000000-0000-4000-8000-000000000003 | Pagination Keyset vs OFFSET (revisi) | 2026-05-15 08:00:00+07
(1 row)

UPDATE 1
 id | judul | diubah_pada 
----+-------+-------------
(0 rows)

UPDATE 0
                judul                 |      diubah_pada       
--------------------------------------+------------------------
 Pagination Keyset vs OFFSET (revisi) | 2026-05-15 08:00:00+07
(1 row)

Cara bacanya:

  1. Klien membaca artikel dan menyimpan diubah_pada — semacam nomor versi.
  2. Klien A menyimpan: WHERE ... diubah_pada = '2026-05-06 16:45:00+07' cocok, satu baris ter-update, diubah_pada bergeser ke 2026-05-15 08:00:00+07. Output UPDATE 1.
  3. Klien B menyimpan dengan salinan lama: predicate tidak cocok lagi, nol baris tersentuh. Output UPDATE 0 — dan di sinilah aplikasi menerjemahkan menjadi 409 Conflict plus pesan “artikel sudah berubah, muat ulang”.
  4. Kondisi akhir membuktikan versi A yang menang: judul versi B tidak pernah masuk.

Triknya ada di predicate WHERE id = ... AND diubah_pada = ...: UPDATE hanya menimpa baris yang masih berada di versi yang dikira klien. Mekanisme di baliknya — pembacaan konsisten dan penulisan yang aman dari MVCC — dibahas di chapter Penanganan Konkurensi .

Soft Delete dan Alur Login #

Sekarang giliran DELETE /pengguna/:id. Berbeda dengan namanya, operasinya adalah UPDATE yang mengisi dihapus_pada — dan seluruh endpoint lain otomatis menyesuaikan karena semuanya memang menyaring kolom itu:

-- hapus-lembut-dan-login.sql — DELETE /pengguna/:id sebagai soft delete,
-- dampaknya pada endpoint lain, dan alur login.
-- Soft delete: baris tetap ada (artikel penulis tidak yatim), tetapi
-- tidak lagi tampil di endpoint manapun yang menyaring dihapus_pada.

-- DELETE /pengguna/:id — bukan DELETE, melainkan UPDATE penanda waktu.
UPDATE pengguna
SET dihapus_pada = TIMESTAMPTZ '2026-05-15 09:00:00+07'
WHERE id = '22222222-2222-4222-8222-222222222222'
  AND dihapus_pada IS NULL
RETURNING id, email, dihapus_pada;

-- GET /pengguna — daftar hanya memuat akun aktif; siti hilang dari daftar.
SELECT id, email, dibuat_pada
FROM pengguna
WHERE dihapus_pada IS NULL
ORDER BY email;

-- POST /login untuk siti: nol baris — akun nonaktif tidak bisa masuk.
SELECT id, password_hash
FROM pengguna
WHERE email = '[email protected]'
  AND dihapus_pada IS NULL;

-- POST /login untuk budi: satu baris — verifikasi hash (bcrypt/argon2)
-- adalah urusan aplikasi, database hanya menyimpan dan menyerahkan hash.
SELECT id, password_hash
FROM pengguna
WHERE email = '[email protected]'
  AND dihapus_pada IS NULL;

-- POST /pendaftaran dengan email [email protected]: diterima!
-- Index unik parsial hanya menjaga keunikan email di antara akun AKTIF;
-- email milik akun terhapus bebas dipakai ulang.
INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
VALUES ('44444444-4444-4444-8444-444444444444',
        '[email protected]', 'sandi_hash_baru_placeholder',
        TIMESTAMPTZ '2026-05-16 10:00:00+07')
RETURNING id, email, dibuat_pada;

-- Bukti akhir: dua baris bernama email sama, tapi hanya satu yang aktif.
SELECT email,
       count(*) FILTER (WHERE dihapus_pada IS NULL) AS akun_aktif,
       count(*) AS total_baris
FROM pengguna
GROUP BY email
ORDER BY email;

Output yang Dihasilkan:

text
                  id                  |      email       |      dihapus_pada      
--------------------------------------+------------------+------------------------
 22222222-2222-4222-8222-222222222222 | [email protected] | 2026-05-15 09:00:00+07
(1 row)

UPDATE 1
                  id                  |      email       |      dibuat_pada       
--------------------------------------+------------------+------------------------
 11111111-1111-4111-8111-111111111111 | [email protected] | 2026-04-01 08:00:00+07
 33333333-3333-4333-8333-333333333333 | [email protected] | 2026-04-03 10:15:00+07
(2 rows)

 id | password_hash 
----+---------------
(0 rows)

                  id                  |        password_hash        
--------------------------------------+-----------------------------
 11111111-1111-4111-8111-111111111111 | sandi_hash_budi_placeholder
(1 row)

                  id                  |      email       |      dibuat_pada       
--------------------------------------+------------------+------------------------
 44444444-4444-4444-8444-444444444444 | [email protected] | 2026-05-16 10:00:00+07
(1 row)

INSERT 0 1
      email       | akun_aktif | total_baris 
------------------+------------+-------------
 [email protected] |          1 |           1
 [email protected] |          1 |           1
 [email protected] |          1 |           2
(3 rows)

Empat fakta berurutan dari output tersebut:

  1. UPDATE 1: akun siti kini ber-tanda waktu penghapusan — barisnya tetap ada, artikel-artikelnya tidak yatim, foreign key tetap utuh.
  2. Daftar pengguna hanya memuat dua akun aktif; WHERE dihapus_pada IS NULL bekerja diam-diam di semua endpoint.
  3. Login siti menghasilkan nol baris — akun nonaktif tidak bisa masuk, cukup dengan menyaring dihapus_pada IS NULL pada query kredensial. Baris berikutnya memperlihatkan login budi yang normal: database hanya menyerahkan password_hash; verifikasi bcrypt/argon2 tetap urusan aplikasi.
  4. Pendaftaran ulang [email protected] diterima — inilah buah index unik parsial dari awal bagian: email hanya dipaku selama akunnya aktif. Query terakhir merangkum kondisi akhirnya: dua baris bernama [email protected], tepat satu aktif.

Data kini lahir, berubah, dan mati dengan sopan santun — tinggal satu pertanyaan yang belum dijawab: cukup cepatkah semua ini ketika blog punya seribu artikel dan endpoint dipanggil ribuan kali per menit? Bagian 4 menjawabnya dengan index yang dibenarkan EXPLAIN, bukan tebakan.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

buat-dengan-tag.sql

Unduh berkas
sql
-- buat-dengan-tag.sql — POST /artikel dengan tag dalam SATU transaksi.
-- INSERT artikel dan INSERT artikel_tag harus berhasil bersama-sama atau
-- batal bersama-sama; tidak ada keadaan "artikel tanpa tag setengah jadi".
-- id artikel ditangkap lewat RETURNING ... \gset lalu dipakai berikutnya.

BEGIN;

INSERT INTO artikel (penulis_id, judul, isi, status,
                     dibuat_pada, diubah_pada)
VALUES ('33333333-3333-4333-8333-333333333333',
        'Rahasia Index Komposit',
        'Urutan kolom pada index komposit menentukan query mana yang terbantu.',
        'published',
        TIMESTAMPTZ '2026-05-14 08:00:00+07',
        TIMESTAMPTZ '2026-05-14 08:00:00+07')
RETURNING id AS id_baru \gset

INSERT INTO artikel_tag (artikel_id, tag_id)
VALUES (:'id_baru'::uuid, '10000000-0000-4000-8000-000000000002'),
       (:'id_baru'::uuid, '10000000-0000-4000-8000-000000000005');

COMMIT;

-- GET /artikel/:id untuk artikel baru: artikel dan kedua tag utuh.
-- Kolom id sengaja tidak ditampilkan karena gen_random_uuid()
-- menghasilkan nilai yang berbeda di setiap eksekusi.
SELECT jsonb_build_object(
           'judul', a.judul,
           'status', a.status,
           'dibuat_pada', a.dibuat_pada,
           'tags', COALESCE(
               jsonb_agg(t.nama ORDER BY t.nama)
                   FILTER (WHERE t.nama IS NOT NULL),
               '[]'::jsonb)
       ) AS respons
FROM artikel a
LEFT JOIN artikel_tag at2 ON at2.artikel_id = a.id
LEFT JOIN tag t ON t.id = at2.tag_id
WHERE a.id = :'id_baru'::uuid
GROUP BY a.judul, a.status, a.dibuat_pada;

email-sudah-dipakai.sql

Unduh berkas
sql
-- email-sudah-dipakai.sql — POST /pendaftaran dengan email yang sudah
-- dipakai pengguna aktif: ditolak index unik parsial.
-- Berkas ini sengaja berakhir dengan error; psql keluar dengan kode 3.
-- Error inilah yang oleh aplikasi dipetakan menjadi 409 Conflict.
INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
VALUES ('99999999-9999-4999-8999-999999999999',
        '[email protected]', 'sandi_hash_palsu_placeholder',
        TIMESTAMPTZ '2026-05-14 09:00:00+07');

hapus-lembut-dan-login.sql

Unduh berkas
sql
-- hapus-lembut-dan-login.sql — DELETE /pengguna/:id sebagai soft delete,
-- dampaknya pada endpoint lain, dan alur login.
-- Soft delete: baris tetap ada (artikel penulis tidak yatim), tetapi
-- tidak lagi tampil di endpoint manapun yang menyaring dihapus_pada.

-- DELETE /pengguna/:id — bukan DELETE, melainkan UPDATE penanda waktu.
UPDATE pengguna
SET dihapus_pada = TIMESTAMPTZ '2026-05-15 09:00:00+07'
WHERE id = '22222222-2222-4222-8222-222222222222'
  AND dihapus_pada IS NULL
RETURNING id, email, dihapus_pada;

-- GET /pengguna — daftar hanya memuat akun aktif; siti hilang dari daftar.
SELECT id, email, dibuat_pada
FROM pengguna
WHERE dihapus_pada IS NULL
ORDER BY email;

-- POST /login untuk siti: nol baris — akun nonaktif tidak bisa masuk.
SELECT id, password_hash
FROM pengguna
WHERE email = '[email protected]'
  AND dihapus_pada IS NULL;

-- POST /login untuk budi: satu baris — verifikasi hash (bcrypt/argon2)
-- adalah urusan aplikasi, database hanya menyimpan dan menyerahkan hash.
SELECT id, password_hash
FROM pengguna
WHERE email = '[email protected]'
  AND dihapus_pada IS NULL;

-- POST /pendaftaran dengan email [email protected]: diterima!
-- Index unik parsial hanya menjaga keunikan email di antara akun AKTIF;
-- email milik akun terhapus bebas dipakai ulang.
INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
VALUES ('44444444-4444-4444-8444-444444444444',
        '[email protected]', 'sandi_hash_baru_placeholder',
        TIMESTAMPTZ '2026-05-16 10:00:00+07')
RETURNING id, email, dibuat_pada;

-- Bukti akhir: dua baris bernama email sama, tapi hanya satu yang aktif.
SELECT email,
       count(*) FILTER (WHERE dihapus_pada IS NULL) AS akun_aktif,
       count(*) AS total_baris
FROM pengguna
GROUP BY email
ORDER BY email;

konkurensi-optimis.sql

Unduh berkas
sql
-- konkurensi-optimis.sql — PATCH /artikel/:id tanpa kunci pesimistis:
-- UPDATE hanya berhasil bila diubah_pada masih cocok dengan salinan yang
-- dibaca klien. Baris hasil 0 berarti orang lain lebih dulu menyimpan —
-- sinyal bagi aplikasi untuk membalas 409 Conflict.

-- Klien membaca artikel dan menyimpan salinan diubah_pada (If-Match).
SELECT id, judul, diubah_pada
FROM artikel
WHERE id = '20000000-0000-4000-8000-000000000003'
  AND dihapus_pada IS NULL;

-- Klien A menyimpan: salinan masih segar, satu baris ter-update.
UPDATE artikel
SET judul = 'Pagination Keyset vs OFFSET (revisi)',
    diubah_pada = TIMESTAMPTZ '2026-05-15 08:00:00+07'
WHERE id = '20000000-0000-4000-8000-000000000003'
  AND diubah_pada = TIMESTAMPTZ '2026-05-06 16:45:00+07'
  AND dihapus_pada IS NULL
RETURNING id, judul, diubah_pada;

-- Klien B menyimpan dengan salinan lama: diubah_pada sudah bergeser,
-- nol baris ter-update — tidak ada tulisan yang saling menimpa.
UPDATE artikel
SET judul = 'Pagination Keyset vs OFFSET (versi B)',
    diubah_pada = TIMESTAMPTZ '2026-05-15 08:30:00+07'
WHERE id = '20000000-0000-4000-8000-000000000003'
  AND diubah_pada = TIMESTAMPTZ '2026-05-06 16:45:00+07'
  AND dihapus_pada IS NULL
RETURNING id, judul, diubah_pada;

-- Kondisi akhir: revisi klien A menang, tulisan klien B tidak pernah masuk.
SELECT judul, diubah_pada
FROM artikel
WHERE id = '20000000-0000-4000-8000-000000000003';

siapkan-tabel.sql

Unduh berkas
sql
-- siapkan-tabel.sql — fondasi Bagian 3: skema versi 2 plus data contoh.
-- Satu perubahan penting dari Bagian 1: keunikan email kini dijaga
-- index unik PARSIAL dengan WHERE dihapus_pada IS NULL, bukan lagi
-- UNIQUE constraint biasa. Artinya email hanya wajib unik di antara
-- pengguna aktif — email milik akun yang dihapus lembut boleh
-- didaftarkan ulang. Bagian ini dimaksudkan mandiri dari database kosong.

CREATE TABLE pengguna (
    id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    email         text NOT NULL,
    password_hash text NOT NULL,
    dibuat_pada   timestamptz NOT NULL,
    dihapus_pada  timestamptz
);

CREATE TABLE artikel (
    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    penulis_id  uuid NOT NULL REFERENCES pengguna (id),
    judul       text NOT NULL,
    isi         text NOT NULL,
    status      text NOT NULL DEFAULT 'draft',
    dibuat_pada timestamptz NOT NULL,
    diubah_pada timestamptz NOT NULL,
    dihapus_pada timestamptz,
    CONSTRAINT judul_tidak_kosong CHECK (judul <> ''),
    CONSTRAINT isi_tidak_kosong  CHECK (isi <> ''),
    CONSTRAINT status_sah CHECK (status IN ('draft', 'published'))
);

CREATE TABLE tag (
    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    nama        text NOT NULL,
    dibuat_pada timestamptz NOT NULL,
    CONSTRAINT nama_tag_unik UNIQUE (nama),
    CONSTRAINT tag_selalu_lowercase CHECK (nama = lower(nama))
);

CREATE TABLE artikel_tag (
    artikel_id uuid NOT NULL REFERENCES artikel (id) ON DELETE CASCADE,
    tag_id     uuid NOT NULL REFERENCES tag (id) ON DELETE CASCADE,
    PRIMARY KEY (artikel_id, tag_id)
);

-- Keunikan email hanya untuk pengguna aktif.
CREATE UNIQUE INDEX pengguna_email_aktif
    ON pengguna (email)
    WHERE dihapus_pada IS NULL;

INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
VALUES
    ('11111111-1111-4111-8111-111111111111',
     '[email protected]', 'sandi_hash_budi_placeholder',
     TIMESTAMPTZ '2026-04-01 08:00:00+07'),
    ('22222222-2222-4222-8222-222222222222',
     '[email protected]', 'sandi_hash_siti_placeholder',
     TIMESTAMPTZ '2026-04-02 09:30:00+07'),
    ('33333333-3333-4333-8333-333333333333',
     '[email protected]', 'sandi_hash_dewi_placeholder',
     TIMESTAMPTZ '2026-04-03 10:15:00+07');

INSERT INTO tag (id, nama, dibuat_pada)
VALUES
    ('10000000-0000-4000-8000-000000000001', 'postgresql',
     TIMESTAMPTZ '2026-04-01 08:05:00+07'),
    ('10000000-0000-4000-8000-000000000002', 'sql',
     TIMESTAMPTZ '2026-04-01 08:05:00+07'),
    ('10000000-0000-4000-8000-000000000003', 'database',
     TIMESTAMPTZ '2026-04-01 08:05:00+07'),
    ('10000000-0000-4000-8000-000000000004', 'backend',
     TIMESTAMPTZ '2026-04-01 08:05:00+07'),
    ('10000000-0000-4000-8000-000000000005', 'kinerja',
     TIMESTAMPTZ '2026-04-01 08:05:00+07');

INSERT INTO artikel (id, penulis_id, judul, isi, status,
                     dibuat_pada, diubah_pada, dihapus_pada)
VALUES
    ('20000000-0000-4000-8000-000000000001',
     '11111111-1111-4111-8111-111111111111',
     'Kenali UUID sebagai Primary Key',
     'Primary key UUID membuat identifier sulit ditebak dan aman diekspos di URL.',
     'published',
     TIMESTAMPTZ '2026-05-01 09:00:00+07',
     TIMESTAMPTZ '2026-05-01 09:00:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000002',
     '22222222-2222-4222-8222-222222222222',
     'Rancangan Skema untuk REST API',
     'Skema yang baik membuat validasi endpoint menjadi murah.',
     'published',
     TIMESTAMPTZ '2026-05-03 10:00:00+07',
     TIMESTAMPTZ '2026-05-03 10:00:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000003',
     '33333333-3333-4333-8333-333333333333',
     'Pagination Keyset vs OFFSET',
     'OFFSET membaca lalu membuang baris; keyset melompat lewat index komposit.',
     'published',
     TIMESTAMPTZ '2026-05-05 08:30:00+07',
     TIMESTAMPTZ '2026-05-06 16:45:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000004',
     '11111111-1111-4111-8111-111111111111',
     'Soft Delete dan Jejak Audit',
     'Kolom dihapus_pada yang NULL memisahkan baris hidup dari baris mati.',
     'draft',
     TIMESTAMPTZ '2026-05-06 11:20:00+07',
     TIMESTAMPTZ '2026-05-06 11:20:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000005',
     '22222222-2222-4222-8222-222222222222',
     'Validasi Data di Tingkat Database',
     'Constraint yang tertulis di skema bekerja untuk semua aplikasi.',
     'published',
     TIMESTAMPTZ '2026-05-07 14:00:00+07',
     TIMESTAMPTZ '2026-05-07 14:00:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000006',
     '33333333-3333-4333-8333-333333333333',
     'EXPLAIN sebagai Alat Diagnosis Query',
     'Bacalah rencana eksekusi sebelum menambah index demi index.',
     'draft',
     TIMESTAMPTZ '2026-05-09 07:45:00+07',
     TIMESTAMPTZ '2026-05-09 07:45:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000007',
     '11111111-1111-4111-8111-111111111111',
     'Transaksi Atomik untuk Endpoint',
     'INSERT artikel dan tag harus berjalan dalam satu transaksi.',
     'published',
     TIMESTAMPTZ '2026-05-10 13:10:00+07',
     TIMESTAMPTZ '2026-05-10 13:10:00+07',
     NULL),
    ('20000000-0000-4000-8000-000000000008',
     '22222222-2222-4222-8222-222222222222',
     'Index Parsial untuk Data Hidup',
     'Index parsial hanya menampung baris yang belum dihapus lembut.',
     'published',
     TIMESTAMPTZ '2026-05-12 09:50:00+07',
     TIMESTAMPTZ '2026-05-12 09:50:00+07',
     TIMESTAMPTZ '2026-05-20 10:00:00+07');

INSERT INTO artikel_tag (artikel_id, tag_id)
VALUES
    ('20000000-0000-4000-8000-000000000001',
     '10000000-0000-4000-8000-000000000001'),
    ('20000000-0000-4000-8000-000000000001',
     '10000000-0000-4000-8000-000000000003'),
    ('20000000-0000-4000-8000-000000000002',
     '10000000-0000-4000-8000-000000000003'),
    ('20000000-0000-4000-8000-000000000002',
     '10000000-0000-4000-8000-000000000004'),
    ('20000000-0000-4000-8000-000000000003',
     '10000000-0000-4000-8000-000000000002'),
    ('20000000-0000-4000-8000-000000000003',
     '10000000-0000-4000-8000-000000000005'),
    ('20000000-0000-4000-8000-000000000004',
     '10000000-0000-4000-8000-000000000003'),
    ('20000000-0000-4000-8000-000000000004',
     '10000000-0000-4000-8000-000000000004'),
    ('20000000-0000-4000-8000-000000000005',
     '10000000-0000-4000-8000-000000000001'),
    ('20000000-0000-4000-8000-000000000005',
     '10000000-0000-4000-8000-000000000002'),
    ('20000000-0000-4000-8000-000000000006',
     '10000000-0000-4000-8000-000000000005'),
    ('20000000-0000-4000-8000-000000000007',
     '10000000-0000-4000-8000-000000000002'),
    ('20000000-0000-4000-8000-000000000007',
     '10000000-0000-4000-8000-000000000004'),
    ('20000000-0000-4000-8000-000000000008',
     '10000000-0000-4000-8000-000000000001'),
    ('20000000-0000-4000-8000-000000000008',
     '10000000-0000-4000-8000-000000000005');

-- Bukti skema versi 2: index unik email bersifat parsial.
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
  AND tablename = 'pengguna'
ORDER BY indexname;

tangani-409.sql

Unduh berkas
sql
-- tangani-409.sql — pola penanganan error constraint di sisi aplikasi:
-- tangkap exception, baca SQLSTATE, petakan ke status HTTP yang tepat.
-- DO block berikut meniru try/catch yang biasanya hidup di kode aplikasi
-- (pola yang sama dibahas pada chapter Menangani Database Errors).

CREATE TEMP TABLE pemetaan_error (
    urutan      integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kasus       text NOT NULL,
    sqlstate    text NOT NULL,
    status_http text NOT NULL,
    hasil       text NOT NULL
);

DO $$
DECLARE
    v_state text;
BEGIN
    -- Email yang sudah dipakai pengguna aktif: unique_violation.
    BEGIN
        INSERT INTO pengguna (id, email, password_hash, dibuat_pada)
        VALUES ('99999999-9999-4999-8999-999999999998',
                '[email protected]', 'sandi_hash_palsu_placeholder',
                TIMESTAMPTZ '2026-05-14 10:00:00+07');
        INSERT INTO pemetaan_error (kasus, sqlstate, status_http, hasil)
        VALUES ('email sudah dipakai', '-', '-',
                'GAGAL: baris justru diterima');
    EXCEPTION WHEN unique_violation THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO pemetaan_error (kasus, sqlstate, status_http, hasil)
        VALUES ('email sudah dipakai', v_state, '409 Conflict',
                'LULUS: tertangkap dan dipetakan');
    END;

    -- Penulis tidak dikenal pada POST /artikel: foreign_key_violation.
    BEGIN
        INSERT INTO artikel (id, penulis_id, judul, isi, status,
                             dibuat_pada, diubah_pada)
        VALUES ('99999999-9999-4999-8999-999999999997',
                '99999999-9999-4999-8999-999999999999',
                'Penulis hantu', 'Penulis tidak ada di tabel pengguna.',
                'draft',
                TIMESTAMPTZ '2026-05-14 10:10:00+07',
                TIMESTAMPTZ '2026-05-14 10:10:00+07');
        INSERT INTO pemetaan_error (kasus, sqlstate, status_http, hasil)
        VALUES ('penulis tidak dikenal', '-', '-',
                'GAGAL: baris justru diterima');
    EXCEPTION WHEN foreign_key_violation THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO pemetaan_error (kasus, sqlstate, status_http, hasil)
        VALUES ('penulis tidak dikenal', v_state, '422 Unprocessable Entity',
                'LULUS: tertangkap dan dipetakan');
    END;

    -- Status di luar daftar sah pada POST /artikel: check_violation.
    BEGIN
        INSERT INTO artikel (id, penulis_id, judul, isi, status,
                             dibuat_pada, diubah_pada)
        VALUES ('99999999-9999-4999-8999-999999999996',
                '11111111-1111-4111-8111-111111111111',
                'Status karatan', 'Uji status archived.', 'archived',
                TIMESTAMPTZ '2026-05-14 10:20:00+07',
                TIMESTAMPTZ '2026-05-14 10:20:00+07');
        INSERT INTO pemetaan_error (kasus, sqlstate, status_http, hasil)
        VALUES ('status di luar draft/published', '-', '-',
                'GAGAL: baris justru diterima');
    EXCEPTION WHEN check_violation THEN
        GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE;
        INSERT INTO pemetaan_error (kasus, sqlstate, status_http, hasil)
        VALUES ('status di luar draft/published', v_state,
                '422 Unprocessable Entity',
                'LULUS: tertangkap dan dipetakan');
    END;
END $$;

SELECT kasus, sqlstate, status_http, hasil
FROM pemetaan_error
ORDER BY urutan;