Drops the legacy paliad.deadline_rules table after 3 weeks of dual-write
shadowing (mig 136 → B.2 dual-write → B.3 read cutover via view). The
new tables — paliad.procedural_events, paliad.sequencing_rules,
paliad.legal_sources — are the sole source of truth from this commit
forward.
Pre-flip drift verified clean against prod:
deadline_rules=231 == sequencing_rules=231 == procedural_events=231
legal_sources=87
missing_sr=0, orphaned_sr=0, mismatched_lifecycle=0
* internal/db/migrations/140_drop_deadline_rules.up.sql (new) —
Single TX, audit-first:
1. CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules
(precedent migs 091/093/095/098 — snapshot in same TX as destructive op).
2. Final reconciliation UPDATE on paliad.deadlines (no-op when
drift is already 0; defensive against last-minute writes).
3. DROP TRIGGER deadline_rules_audit_aiud.
4. Re-point FKs to sequencing_rules:
- paliad.appointments.deadline_rule_id → paliad.sequencing_rules(id)
- paliad.deadline_rule_backfill_orphans.resolved_rule_id → paliad.sequencing_rules(id)
(the id values are identical — sr.id inherited dr.id at mig 136.)
5. DROP COLUMN paliad.deadlines.rule_id.
6. DROP TABLE paliad.deadline_rules.
7. CREATE INSTEAD OF INSERT + INSTEAD OF UPDATE triggers on
paliad.deadline_rules_unified. Triggers route writes into the
three new tables in the same TX, preserving the legacy column
shape on the wire so RuleEditorService SQL only needs a
table-name swap, not a structural rewrite. Synthetic-code mint
expression is byte-identical to mig 136 + the B.2 dual-write
helper. POST assertions confirm the table is gone, the column
is gone, and the snapshot matches.
Trigger design notes (1:N caveat documented in-trigger):
- PE identity columns (code, name, name_en, description, event_kind,
primary_party_default, legal_source_id, concept_id) mirror from
the writing sequencing-rule.
- PE lifecycle columns (lifecycle_state, published_at, is_active)
deliberately do NOT mirror — a draft sequencing-rule cloned from
a published source shares the source's PE; we don't want the
clone's 'draft' lifecycle to leak back onto the source's PE.
Practical bound today (1:1 corpus); explicit comment in-trigger
for the eventual 1:N pattern.
* internal/db/migrations/140_drop_deadline_rules.down.sql (new) —
Best-effort restore from the snapshot. Triggers / indexes /
CHECK constraints from historical migrations are NOT replayed;
operator must reapply 078/079/091/095/098/122/128/134/135 to
bring the restored table to working shape. The down path is for
catastrophic recovery, not casual revert.
* internal/services/rule_editor_service.go —
Six syncDualWriteFromDeadlineRule(...) calls removed (the
INSTEAD OF triggers now do the fan-out). Five
INSERT/UPDATE paliad.deadline_rules statements (Create,
UpdateDraft, CloneAsDraft INSERT+SELECT, Publish, peer-archive,
flipLifecycle) renamed to paliad.deadline_rules_unified —
trigger handles the routing.
* internal/services/rule_editor_orphans.go — ResolveOrphan no
longer writes deadlines.rule_id (column dropped). Sets
sequencing_rule_id directly + derives procedural_event_id from
the matching sequencing_rules row in the same UPDATE statement.
* internal/services/deadline_service.go — deadlineColumns now
lists sequencing_rule_id (Deadline.RuleID still binds to it via
the db tag rename below). Update path's appendSet("rule_id",…)
flipped to appendSet("sequencing_rule_id",…) and post-write
derivation moved to the renamed syncDeadlineProceduralEventID
helper.
* internal/services/projection_service.go,
internal/services/submission_vars.go — `WHERE rule_id = $X`
reads on paliad.deadlines flipped to sequencing_rule_id.
* internal/models/models.go — Deadline.RuleID db tag changed from
"rule_id" to "sequencing_rule_id". Field name + JSON name kept
for backward compat with the frontend and existing Go callers;
semantic value is identical (same UUID).
* internal/services/dual_write.go — Massively trimmed.
Removed: syncDualWriteFromDeadlineRule, syncDeadlineDualLinks,
CheckDualWriteDrift, DualWriteDriftReport, HasDrift,
StartDualWriteDriftCheckLoop. All referenced
paliad.deadline_rules which no longer exists.
Kept (renamed): syncDeadlineProceduralEventID — derives
procedural_event_id from sequencing_rule_id after any
DeadlineService.Update that touched the back-link.
* cmd/server/main.go — Removed the StartDualWriteDriftCheckLoop
bootstrap call (and its `time` import that only that call
needed). Comment notes the retirement.
* internal/services/dual_write_test.go — Removed the final
CheckDualWriteDrift assertion in
TestDualWrite_RuleEditorLifecycle (function deleted). The
per-step asserts against procedural_events / sequencing_rules
/ legal_sources cover the same contract by direct query.
Hard rules followed:
- Audit-first: snapshot precedes destructive ops in the same TX.
- No silent data loss: pre-drop drift was zero; snapshot captures
the final state; FK re-points use identical UUIDs.
- INSTEAD OF triggers documented in mig 140 — single source of
truth for the legacy→new mapping.
- Down migration is honest about its scope (catastrophic recovery
only).
Build + vet clean. TestMigrations_NoDuplicateSlot passes. Live-DB
tests skipped (no TEST_DATABASE_URL in this env) — they'll exercise
the full mig 140 + INSTEAD OF triggers in CI.
298 lines
11 KiB
Go
298 lines
11 KiB
Go
// Slice B.2 dual-write tests (t-paliad-305 / m/paliad#93).
|
|
//
|
|
// Asserts the parallel projection — paliad.procedural_events +
|
|
// paliad.sequencing_rules + paliad.legal_sources — stays in lock-step
|
|
// with paliad.deadline_rules through the full RuleEditorService
|
|
// lifecycle. Skipped when TEST_DATABASE_URL is unset.
|
|
|
|
package services
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/db"
|
|
)
|
|
|
|
// TestDualWrite_RuleEditorLifecycle walks Create → UpdateDraft →
|
|
// CloneAsDraft → Publish → Archive → Restore on RuleEditorService and
|
|
// after each operation asserts that paliad.sequencing_rules has the
|
|
// 1:1 mirror, paliad.procedural_events carries the projected identity,
|
|
// and paliad.legal_sources carries the citation.
|
|
func TestDualWrite_RuleEditorLifecycle(t *testing.T) {
|
|
url := os.Getenv("TEST_DATABASE_URL")
|
|
if url == "" {
|
|
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
|
}
|
|
if err := db.ApplyMigrations(url); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
pool, err := sqlx.Connect("postgres", url)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
ctx := context.Background()
|
|
rules := NewDeadlineRuleService(pool)
|
|
svc := NewRuleEditorService(pool, rules)
|
|
|
|
cleanup := func() {
|
|
pool.ExecContext(ctx,
|
|
`SELECT set_config('paliad.audit_reason', 'slice b.2 test cleanup', true)`)
|
|
// Order matters: sequencing_rules → procedural_events → legal_sources
|
|
// (FK direction). deadline_rules cleanup last because mig 079 audit
|
|
// trigger captures the DELETE.
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN (
|
|
SELECT id FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'
|
|
)`)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events
|
|
WHERE code LIKE 'sliceb2.%' OR code LIKE 'null.sliceb2%'`)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.legal_sources
|
|
WHERE citation LIKE 'SLICEB2.%'`)
|
|
pool.ExecContext(ctx,
|
|
`DELETE FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'`)
|
|
pool.ExecContext(ctx,
|
|
`DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_TEST_PT'`)
|
|
}
|
|
cleanup()
|
|
defer cleanup()
|
|
|
|
var ptID int
|
|
if err := pool.GetContext(ctx, &ptID, `
|
|
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
|
|
VALUES ('SLICEB2_TEST_PT', 'Slice B.2 Test PT', 'Slice B.2 Test PT', 'fristenrechner', 'UPC', true)
|
|
RETURNING id`); err != nil {
|
|
t.Fatalf("seed proceeding_type: %v", err)
|
|
}
|
|
|
|
subCode := "sliceb2.create"
|
|
legalSrc := "SLICEB2.PatG.1"
|
|
|
|
// 1. Create — assert the parallel rows land.
|
|
created, err := svc.Create(ctx, CreateRuleInput{
|
|
Name: "SLICEB2_TEST_create",
|
|
NameEN: "SLICEB2_TEST_create_EN",
|
|
ProceedingTypeID: &ptID,
|
|
SubmissionCode: &subCode,
|
|
LegalSource: &legalSrc,
|
|
DurationValue: 30,
|
|
DurationUnit: "days",
|
|
Priority: "mandatory",
|
|
}, "B.2 dual-write create test")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// legal_sources should now carry SLICEB2.PatG.1
|
|
var lsCount int
|
|
if err := pool.GetContext(ctx, &lsCount,
|
|
`SELECT COUNT(*) FROM paliad.legal_sources WHERE citation = $1`, legalSrc); err != nil {
|
|
t.Fatalf("query legal_sources: %v", err)
|
|
}
|
|
if lsCount != 1 {
|
|
t.Errorf("legal_sources after Create: got %d, want 1 for citation %q", lsCount, legalSrc)
|
|
}
|
|
|
|
// procedural_events should carry the submission_code
|
|
var peName, peLifecycle string
|
|
if err := pool.GetContext(ctx, &peName,
|
|
`SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
|
|
t.Fatalf("query procedural_events name: %v", err)
|
|
}
|
|
if peName != "SLICEB2_TEST_create" {
|
|
t.Errorf("procedural_events.name after Create: got %q, want %q", peName, "SLICEB2_TEST_create")
|
|
}
|
|
if err := pool.GetContext(ctx, &peLifecycle,
|
|
`SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
|
|
t.Fatalf("query procedural_events lifecycle: %v", err)
|
|
}
|
|
if peLifecycle != "draft" {
|
|
t.Errorf("procedural_events.lifecycle_state after Create: got %q, want %q", peLifecycle, "draft")
|
|
}
|
|
|
|
// sequencing_rules should have id = created.id and link to PE
|
|
var srCount, srMatchPE int
|
|
if err := pool.GetContext(ctx, &srCount,
|
|
`SELECT COUNT(*) FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
|
|
t.Fatalf("query sequencing_rules count: %v", err)
|
|
}
|
|
if srCount != 1 {
|
|
t.Errorf("sequencing_rules row after Create: got %d, want 1 for id %s", srCount, created.ID)
|
|
}
|
|
if err := pool.GetContext(ctx, &srMatchPE, `
|
|
SELECT COUNT(*) FROM paliad.sequencing_rules sr
|
|
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
|
WHERE sr.id = $1 AND pe.code = $2`, created.ID, subCode); err != nil {
|
|
t.Fatalf("query sr→pe join: %v", err)
|
|
}
|
|
if srMatchPE != 1 {
|
|
t.Errorf("sequencing_rules.procedural_event_id after Create: got %d join hits, want 1", srMatchPE)
|
|
}
|
|
|
|
// 2. UpdateDraft — change name + legal_source. Assert propagation.
|
|
newName := "SLICEB2_TEST_updated"
|
|
newLegal := "SLICEB2.ZPO.2"
|
|
_, err = svc.UpdateDraft(ctx, created.ID, RulePatch{
|
|
Name: &newName,
|
|
LegalSource: &newLegal,
|
|
}, "B.2 dual-write update test")
|
|
if err != nil {
|
|
t.Fatalf("UpdateDraft: %v", err)
|
|
}
|
|
|
|
var afterName string
|
|
if err := pool.GetContext(ctx, &afterName,
|
|
`SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
|
|
t.Fatalf("query pe.name post-update: %v", err)
|
|
}
|
|
if afterName != newName {
|
|
t.Errorf("procedural_events.name after UpdateDraft: got %q, want %q", afterName, newName)
|
|
}
|
|
|
|
// New citation must appear in legal_sources, and procedural_events.legal_source_id
|
|
// must point at it (idempotent UPSERT — the old SLICEB2.PatG.1 row stays).
|
|
var pePointsAtNewLegal int
|
|
if err := pool.GetContext(ctx, &pePointsAtNewLegal, `
|
|
SELECT COUNT(*) FROM paliad.procedural_events pe
|
|
JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
|
|
WHERE pe.code = $1 AND ls.citation = $2`, subCode, newLegal); err != nil {
|
|
t.Fatalf("query pe→ls join: %v", err)
|
|
}
|
|
if pePointsAtNewLegal != 1 {
|
|
t.Errorf("procedural_events.legal_source_id after UpdateDraft: got %d hits, want 1", pePointsAtNewLegal)
|
|
}
|
|
|
|
// 3. Publish — flip to published. Assert lifecycle mirror.
|
|
_, err = svc.Publish(ctx, created.ID, "B.2 dual-write publish test")
|
|
if err != nil {
|
|
t.Fatalf("Publish: %v", err)
|
|
}
|
|
var srLifecycle, peLifecycleAfterPub string
|
|
if err := pool.GetContext(ctx, &srLifecycle,
|
|
`SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
|
|
t.Fatalf("query sr.lifecycle: %v", err)
|
|
}
|
|
if srLifecycle != "published" {
|
|
t.Errorf("sequencing_rules.lifecycle_state after Publish: got %q, want %q", srLifecycle, "published")
|
|
}
|
|
if err := pool.GetContext(ctx, &peLifecycleAfterPub,
|
|
`SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
|
|
t.Fatalf("query pe.lifecycle post-publish: %v", err)
|
|
}
|
|
if peLifecycleAfterPub != "published" {
|
|
t.Errorf("procedural_events.lifecycle_state after Publish: got %q, want %q", peLifecycleAfterPub, "published")
|
|
}
|
|
|
|
// 4. Archive — flip to archived. Assert mirror.
|
|
_, err = svc.Archive(ctx, created.ID, "B.2 dual-write archive test")
|
|
if err != nil {
|
|
t.Fatalf("Archive: %v", err)
|
|
}
|
|
var srLifecycleArchived string
|
|
if err := pool.GetContext(ctx, &srLifecycleArchived,
|
|
`SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
|
|
t.Fatalf("query sr.lifecycle post-archive: %v", err)
|
|
}
|
|
if srLifecycleArchived != "archived" {
|
|
t.Errorf("sequencing_rules.lifecycle_state after Archive: got %q, want %q", srLifecycleArchived, "archived")
|
|
}
|
|
|
|
// Slice B.4 (mig 140, t-paliad-305): the legacy paliad.deadline_rules
|
|
// table is gone and so is CheckDualWriteDrift — there's no parallel
|
|
// side to compare against. The INSTEAD OF triggers on the view
|
|
// guarantee parity by construction (single TX fan-out from one
|
|
// SQL write to three target tables).
|
|
}
|
|
|
|
// TestDualWrite_SyntheticCodeForNullSubmission asserts that a rule
|
|
// created with submission_code=NULL gets a synthetic 'null.<8hex>'
|
|
// procedural_events row matching mig 136's mint expression — so a new
|
|
// draft without a code participates in the dual-write contract without
|
|
// colliding with any code-bearing rule.
|
|
func TestDualWrite_SyntheticCodeForNullSubmission(t *testing.T) {
|
|
url := os.Getenv("TEST_DATABASE_URL")
|
|
if url == "" {
|
|
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
|
}
|
|
if err := db.ApplyMigrations(url); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
pool, err := sqlx.Connect("postgres", url)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
ctx := context.Background()
|
|
rules := NewDeadlineRuleService(pool)
|
|
svc := NewRuleEditorService(pool, rules)
|
|
|
|
cleanup := func() {
|
|
pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'slice b.2 null-code cleanup', true)`)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN (
|
|
SELECT id FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'
|
|
)`)
|
|
// Synthetic PE rows are keyed off the rule's uuid; delete by name reference.
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events
|
|
WHERE code IN (
|
|
SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8)
|
|
FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'
|
|
)`)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'`)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_NC_PT'`)
|
|
}
|
|
cleanup()
|
|
defer cleanup()
|
|
|
|
var ptID int
|
|
if err := pool.GetContext(ctx, &ptID, `
|
|
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
|
|
VALUES ('SLICEB2_NC_PT', 'NC PT', 'NC PT', 'fristenrechner', 'UPC', true)
|
|
RETURNING id`); err != nil {
|
|
t.Fatalf("seed proceeding_type: %v", err)
|
|
}
|
|
|
|
created, err := svc.Create(ctx, CreateRuleInput{
|
|
Name: "SLICEB2_TEST_nullcode",
|
|
NameEN: "SLICEB2_TEST_nullcode_EN",
|
|
ProceedingTypeID: &ptID,
|
|
// SubmissionCode intentionally NIL → tests the synthetic-code branch.
|
|
DurationValue: 5,
|
|
DurationUnit: "days",
|
|
Priority: "mandatory",
|
|
}, "B.2 dual-write null-code test")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Compute the expected synthetic code in the same way mig 136 / the
|
|
// dual-write helper do — keep the expression in lock-step with the
|
|
// SQL via this Go-side mirror.
|
|
var expectedCode string
|
|
if err := pool.GetContext(ctx, &expectedCode,
|
|
`SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8)
|
|
FROM paliad.deadline_rules WHERE id = $1`, created.ID); err != nil {
|
|
t.Fatalf("compute expected synthetic code: %v", err)
|
|
}
|
|
|
|
var actualCode string
|
|
if err := pool.GetContext(ctx, &actualCode, `
|
|
SELECT pe.code
|
|
FROM paliad.procedural_events pe
|
|
JOIN paliad.sequencing_rules sr ON sr.procedural_event_id = pe.id
|
|
WHERE sr.id = $1`, created.ID); err != nil {
|
|
t.Fatalf("query procedural_events via sequencing_rules: %v", err)
|
|
}
|
|
if actualCode != expectedCode {
|
|
t.Errorf("synthetic code mismatch: got %q, want %q", actualCode, expectedCode)
|
|
}
|
|
if len(actualCode) != len("null.")+8 {
|
|
t.Errorf("synthetic code length: got %d, want 13 (null.+8hex)", len(actualCode))
|
|
}
|
|
}
|