From 1f2b4a9ece1420e746a7240d8972d021aa115111 Mon Sep 17 00:00:00 2001 From: Mason Sharp Date: Fri, 17 Jul 2026 14:15:49 -0700 Subject: [PATCH 1/2] fix(repair): make pick_freshest compare commit_ts from spock metadata pick_freshest resolved its `key` only from the stripped diff row, but commit_ts (and other spock metadata) are moved into n1Meta/n2Meta during diff loading. So `pick_freshest: {key: commit_ts}` found nil on both sides and fell back to `tie` every time, never actually comparing timestamps. Resolve the key via lookupValue, which falls back to the metadata map, so `key: commit_ts` compares commit timestamps while ordinary data columns (e.g. updated_at) keep working. Tests: - unit: commit_ts-from-meta (both directions), one-side-null (the side with a timestamp wins), both-null (tie), and app-column-from-row. - integration: one-pass latest-commit-wins end-to-end (newer commit_ts side wins) and pick_freshest by an app-level column (subscription_date). --- internal/consistency/repair/executor.go | 14 +- .../consistency/repair/pick_freshest_test.go | 89 +++++++++++ tests/integration/advanced_repair_test.go | 142 ++++++++++++++++++ 3 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 internal/consistency/repair/pick_freshest_test.go diff --git a/internal/consistency/repair/executor.go b/internal/consistency/repair/executor.go index f5c5a5a..ba54794 100644 --- a/internal/consistency/repair/executor.go +++ b/internal/consistency/repair/executor.go @@ -723,15 +723,11 @@ func unionKeys(maps ...map[string]any) map[string]struct{} { } func freshestSide(key string, tie string, row planDiffRow) string { - val1 := row.n1Row - val2 := row.n2Row - var k1, k2 any - if val1 != nil { - k1 = val1[key] - } - if val2 != nil { - k2 = val2[key] - } + // Resolve the key via lookupValue so it works for both ordinary data + // columns (in n1Row/n2Row) and spock metadata like commit_ts, which the + // executor strips from the row and keeps in n1Meta/n2Meta. + k1, _ := lookupValue("n1", key, row) + k2, _ := lookupValue("n2", key, row) n1Ok := k1 != nil n2Ok := k2 != nil if !n1Ok && n2Ok { diff --git a/internal/consistency/repair/pick_freshest_test.go b/internal/consistency/repair/pick_freshest_test.go new file mode 100644 index 0000000..bfb783d --- /dev/null +++ b/internal/consistency/repair/pick_freshest_test.go @@ -0,0 +1,89 @@ +// /////////////////////////////////////////////////////////////////////////// +// +// # ACE - Active Consistency Engine +// +// Copyright (C) 2023 - 2026, pgEdge (https://www.pgedge.com/) +// +// This software is released under the PostgreSQL License: +// https://opensource.org/license/postgresql +// +// /////////////////////////////////////////////////////////////////////////// + +package repair + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// commit_ts is carried in the spock metadata map (_spock_metadata_), which the +// executor keeps in n1Meta/n2Meta while stripping it from n1Row/n2Row. These +// tests ensure pick_freshest's freshestSide resolves the key from metadata too, +// so `key: commit_ts` actually compares commit timestamps instead of always +// falling through to the tie node. +func TestFreshestSide_CommitTsFromMeta(t *testing.T) { + older := "2026-01-15T12:20:07.805412-08:00" + newer := "2026-01-15T12:21:39.662530-08:00" + + t.Run("n2 newer", func(t *testing.T) { + row := planDiffRow{ + n1Row: map[string]any{"id": 1, "v": "a"}, + n2Row: map[string]any{"id": 1, "v": "b"}, + n1Meta: map[string]any{"commit_ts": older}, + n2Meta: map[string]any{"commit_ts": newer}, + } + require.Equal(t, "n2", freshestSide("commit_ts", "n1", row)) + }) + + t.Run("n1 newer", func(t *testing.T) { + row := planDiffRow{ + n1Row: map[string]any{"id": 1, "v": "a"}, + n2Row: map[string]any{"id": 1, "v": "b"}, + n1Meta: map[string]any{"commit_ts": newer}, + n2Meta: map[string]any{"commit_ts": older}, + } + require.Equal(t, "n1", freshestSide("commit_ts", "n2", row)) + }) +} + +// When only one side has a commit_ts (e.g. the other was written with +// track_commit_timestamp off), the side that has one wins; when neither has +// one, the tie node is chosen. +func TestFreshestSide_CommitTsNullHandling(t *testing.T) { + ts := "2026-01-15T12:21:39.662530-08:00" + + t.Run("only n2 has commit_ts -> n2", func(t *testing.T) { + row := planDiffRow{ + n1Meta: map[string]any{}, + n2Meta: map[string]any{"commit_ts": ts}, + } + require.Equal(t, "n2", freshestSide("commit_ts", "n1", row)) + }) + + t.Run("only n1 has commit_ts -> n1", func(t *testing.T) { + row := planDiffRow{ + n1Meta: map[string]any{"commit_ts": ts}, + n2Meta: map[string]any{}, + } + require.Equal(t, "n1", freshestSide("commit_ts", "n2", row)) + }) + + t.Run("neither has commit_ts -> tie", func(t *testing.T) { + row := planDiffRow{ + n1Meta: map[string]any{}, + n2Meta: map[string]any{}, + } + require.Equal(t, "n2", freshestSide("commit_ts", "n2", row)) + }) +} + +// An application-level timestamp column lives in the row itself and must keep +// working after the metadata fallback is added. +func TestFreshestSide_AppColumnFromRow(t *testing.T) { + row := planDiffRow{ + n1Row: map[string]any{"updated_at": "2026-01-15T12:00:00-08:00"}, + n2Row: map[string]any{"updated_at": "2026-01-15T13:00:00-08:00"}, + } + require.Equal(t, "n2", freshestSide("updated_at", "n1", row)) +} diff --git a/tests/integration/advanced_repair_test.go b/tests/integration/advanced_repair_test.go index f29ad56..c0ec016 100644 --- a/tests/integration/advanced_repair_test.go +++ b/tests/integration/advanced_repair_test.go @@ -20,6 +20,7 @@ import ( "testing" "time" + "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" ) @@ -212,6 +213,147 @@ tables: require.True(t, hasStaleSkipEntry(entries, 2001, "delete", "stale_delete_missing_on_n1")) } +// TestAdvancedRepairPlan_PickFreshestByCommitTS verifies the one-pass +// latest-commit-wins plan: pick_freshest with key: commit_ts keeps the row from +// the side with the newer Spock commit timestamp. It also guards the regression +// where pick_freshest read commit_ts from the (stripped) row instead of the +// spock metadata and therefore always fell back to `tie`. +func TestAdvancedRepairPlan_PickFreshestByCommitTS(t *testing.T) { + ctx := context.Background() + qualifiedTableName := "public.customers" + + setupDivergence(t, ctx, qualifiedTableName) + diffFile := runTableDiff(t, qualifiedTableName, []string{serviceN1, serviceN2}) + + // Precondition: setupDivergence UPDATEs index 1 & 2 on n2 after the initial + // inserts, so n2's commit_ts for those rows is strictly newer than n1's. + // That ordering is what "latest commit wins" must resolve to. + for _, idx := range []int{1, 2} { + var tsN1, tsN2 time.Time + require.NoError(t, pgCluster.Node1Pool.QueryRow(ctx, + fmt.Sprintf("SELECT pg_xact_commit_timestamp(xmin) FROM %s WHERE index = %d", qualifiedTableName, idx)).Scan(&tsN1)) + require.NoError(t, pgCluster.Node2Pool.QueryRow(ctx, + fmt.Sprintf("SELECT pg_xact_commit_timestamp(xmin) FROM %s WHERE index = %d", qualifiedTableName, idx)).Scan(&tsN2)) + require.Truef(t, tsN2.After(tsN1), + "precondition for index %d: n2 commit_ts %s should be newer than n1 %s", idx, tsN2, tsN1) + } + + // One-pass plan: pick_freshest by commit_ts resolves the modified rows to + // the newer (n2) side; apply_from rules propagate the single-sided inserts + // so the tables fully converge. + plan := ` +version: 1 +tables: + public.customers: + rules: + - name: latest_commit_wins + diff_type: [row_mismatch] + action: + type: custom + helpers: + pick_freshest: { key: commit_ts, tie: n1 } + - name: insert_missing_on_n2 + diff_type: [missing_on_n2] + action: { type: apply_from, from: n1, mode: insert } + - name: insert_missing_on_n1 + diff_type: [missing_on_n1] + action: { type: apply_from, from: n2, mode: insert } +` + planPath := filepath.Join(t.TempDir(), "repair.yaml") + require.NoError(t, os.WriteFile(planPath, []byte(plan), 0o644)) + + task := newTestTableRepairTask("", qualifiedTableName, diffFile) + task.RepairPlanPath = planPath + require.NoError(t, task.ValidateAndPrepare()) + require.NoError(t, task.Run(true)) + + assertNoTableDiff(t, qualifiedTableName) + + // The newer (n2) side won for the modified rows, on BOTH nodes. + for _, idx := range []int{1, 2} { + want := fmt.Sprintf("modified.email%d@example.com", idx) + var e1, e2 string + require.NoError(t, pgCluster.Node1Pool.QueryRow(ctx, + fmt.Sprintf("SELECT email FROM %s WHERE index = %d", qualifiedTableName, idx)).Scan(&e1)) + require.NoError(t, pgCluster.Node2Pool.QueryRow(ctx, + fmt.Sprintf("SELECT email FROM %s WHERE index = %d", qualifiedTableName, idx)).Scan(&e2)) + require.Equalf(t, want, e1, "index %d on n1 should be the newer (n2) value", idx) + require.Equalf(t, want, e2, "index %d on n2 should be the newer (n2) value", idx) + } +} + +// TestAdvancedRepairPlan_PickFreshestByAppColumn verifies pick_freshest with an +// application-level timestamp column (subscription_date). Unlike commit_ts this +// is ordinary row data, so it is immune to freezing and works even where +// commit_ts would be NULL. index 1 is newer on n2, index 2 is newer on n1, so +// the winning side differs per row. +func TestAdvancedRepairPlan_PickFreshestByAppColumn(t *testing.T) { + ctx := context.Background() + qualifiedTableName := "public.customers" + env := newSpockEnv() + t.Cleanup(func() { resetSharedTable(t, "customers") }) + + // Clean slate on both nodes. + for _, pool := range []*pgxpool.Pool{pgCluster.Node1Pool, pgCluster.Node2Pool} { + env.withRepairMode(t, ctx, pool, func(conn *pgxpool.Conn) { + _, err := conn.Exec(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", qualifiedTableName)) + require.NoError(t, err) + }) + } + + // Same PKs on both nodes but differing first_name and subscription_date, so + // each row is a row_mismatch resolvable by freshness of subscription_date. + insert := func(pool *pgxpool.Pool, idx int, firstName, subDate string) { + env.withRepairMode(t, ctx, pool, func(conn *pgxpool.Conn) { + _, err := conn.Exec(ctx, + fmt.Sprintf("INSERT INTO %s (index, customer_id, first_name, last_name, email, subscription_date) VALUES ($1,$2,$3,$4,$5,$6)", qualifiedTableName), + idx, fmt.Sprintf("CUST-%d", idx), firstName, "Last", fmt.Sprintf("e%d@example.com", idx), subDate) + require.NoError(t, err) + }) + } + insert(pgCluster.Node1Pool, 1, "N1", "2020-01-01 00:00:00") // index 1: n2 newer -> n2 wins + insert(pgCluster.Node2Pool, 1, "N2", "2021-01-01 00:00:00") + insert(pgCluster.Node1Pool, 2, "N1", "2023-01-01 00:00:00") // index 2: n1 newer -> n1 wins + insert(pgCluster.Node2Pool, 2, "N2", "2022-01-01 00:00:00") + + diffFile := runTableDiff(t, qualifiedTableName, []string{serviceN1, serviceN2}) + + plan := ` +version: 1 +tables: + public.customers: + rules: + - name: latest_by_subscription_date + diff_type: [row_mismatch] + action: + type: custom + helpers: + pick_freshest: { key: subscription_date, tie: n1 } +` + planPath := filepath.Join(t.TempDir(), "repair.yaml") + require.NoError(t, os.WriteFile(planPath, []byte(plan), 0o644)) + + task := newTestTableRepairTask("", qualifiedTableName, diffFile) + task.RepairPlanPath = planPath + require.NoError(t, task.ValidateAndPrepare()) + require.NoError(t, task.Run(true)) + + assertNoTableDiff(t, qualifiedTableName) + + // index 1 -> n2 (newer subscription_date) won; index 2 -> n1 won. The whole + // winning row is applied, so first_name identifies the side that won. + want := map[int]string{1: "N2", 2: "N1"} + for idx, wantName := range want { + var f1, f2 string + require.NoError(t, pgCluster.Node1Pool.QueryRow(ctx, + fmt.Sprintf("SELECT first_name FROM %s WHERE index = %d", qualifiedTableName, idx)).Scan(&f1)) + require.NoError(t, pgCluster.Node2Pool.QueryRow(ctx, + fmt.Sprintf("SELECT first_name FROM %s WHERE index = %d", qualifiedTableName, idx)).Scan(&f2)) + require.Equalf(t, wantName, f1, "index %d on n1 should be the fresher side", idx) + require.Equalf(t, wantName, f2, "index %d on n2 should be the fresher side", idx) + } +} + func listStaleSkipLogs(t *testing.T) []string { t.Helper() paths, err := filepath.Glob(filepath.Join("reports", "*", "stale_repair_skips_*.json")) From 4aa717452154cee8269269d8b0dbd34f0e672cbd Mon Sep 17 00:00:00 2001 From: Mason Sharp Date: Fri, 17 Jul 2026 14:52:21 -0700 Subject: [PATCH 2/2] docs(repair): add one-pass latest-commit-wins example and plan Add examples/repair-plan-latest-wins.yaml (fully commented, one-pass pick_freshest by commit_ts, shared across tables via a YAML anchor, with opt-in alternatives and a per-table override) and a "5g" section to advanced-repair-examples.md documenting the pattern, per-case resolution, and the caveats: positional n1/n2, track_commit_timestamp, string comparison, last-write-wins, and when commit_ts actually goes NULL. --- .../repair/advanced-repair-examples.md | 56 ++++++ .../examples/repair-plan-latest-wins.yaml | 172 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 docs/commands/repair/examples/repair-plan-latest-wins.yaml diff --git a/docs/commands/repair/advanced-repair-examples.md b/docs/commands/repair/advanced-repair-examples.md index 8941db4..01e1fb5 100644 --- a/docs/commands/repair/advanced-repair-examples.md +++ b/docs/commands/repair/advanced-repair-examples.md @@ -169,6 +169,62 @@ tables: allow_stale_repairs: false ``` +## 5g) One-pass "latest commit wins" (complete, multi-table) + +A ready-to-run plan that resolves every `row_mismatch` in a single pass by +keeping the row with the newer Spock commit timestamp, reused across several +tables via a YAML anchor. See +[`examples/repair-plan-latest-wins.yaml`](examples/repair-plan-latest-wins.yaml) +for the fully-commented version (opt-in alternatives, per-table override, and +the caveats in detail). + +The core rule, shared by every table via the `&latest_wins` anchor: +```yaml +tables: + public.inventory: + rules: &latest_wins + - name: latest_commit_wins + diff_type: [row_mismatch] + action: + type: custom + helpers: + pick_freshest: { key: commit_ts, tie: n1 } + public.orders: + rules: *latest_wins +``` + +How each case resolves, all in one pass: + +| Situation | Result | +| --- | --- | +| Both sides have `commit_ts`, and they differ | The newer one wins | +| One side's `commit_ts` is NULL | The side that still has a timestamp wins | +| Timestamps are equal, or both are NULL | The positional `tie` node | + +Caveats: + +- **`n1`/`n2` are positional, not node names.** For each compared pair, `n1` + is the alphabetically-first node name and `n2` the second. `tie: n1` means + "prefer the alphabetically-first node on a tie", not a node you can name; the + plan DSL cannot target a physical node (that is what `--source-of-truth` is + for). +- **`track_commit_timestamp` must be on** across all nodes, or a node's rows + always read a NULL `commit_ts` regardless of recency. +- **`commit_ts` is compared as a string**, so "newer wins" is only reliable + when all nodes emit it with the same UTC offset (ideally UTC). +- **Last-write-wins is not the same as always-correct** — a recent errant + update beats an older good value. +- **A NULL `commit_ts` is rare.** PostgreSQL preserves a tuple's raw `xmin` + across freezing (the frozen bit is set but `xmin` still returns the original + xid), so a frozen row keeps a resolvable `commit_ts`. It only goes NULL once + the xid ages past the commit-timestamp retention horizon — roughly 50M–200M + transactions, tied to `vacuum_freeze_min_age` / `autovacuum_freeze_max_age` — + and `pg_commit_ts` is truncated, or if `track_commit_timestamp` was off when + the row was written. + +Always run with `--dry-run --generate-report` first and review the per-row +decisions before applying. + ## 6) Custom row per PK Pin a specific PK to a hand-crafted row: diff --git a/docs/commands/repair/examples/repair-plan-latest-wins.yaml b/docs/commands/repair/examples/repair-plan-latest-wins.yaml new file mode 100644 index 0000000..515808b --- /dev/null +++ b/docs/commands/repair/examples/repair-plan-latest-wins.yaml @@ -0,0 +1,172 @@ +# ACE advanced repair plan: one-pass "latest commit wins". +# +# A single pick_freshest rule resolves every divergent row (row_mismatch) by +# keeping the side with the newer Spock commit timestamp (commit_ts). commit_ts +# comes from _spock_metadata_ in the diff file and is populated from +# pg_xact_commit_timestamp(xmin). +# +# MULTI-TABLE FORM: the rule set is defined ONCE (anchored as &latest_wins on +# the first table) and reused on the others via `rules: *latest_wins`. Edit the +# anchored block once and every aliased table updates automatically. +# +# --------------------------------------------------------------------------- +# IMPORTANT CAVEATS - read before running +# --------------------------------------------------------------------------- +# 0. n1 / n2 ARE POSITIONAL, NOT NODE NAMES. For each compared pair, n1 is the +# alphabetically-first of the two node names and n2 is the second (assigned +# at runtime from the diff pair; nothing to configure). So `tie: n1`, +# keep_n1/keep_n2, and apply_from {from: n1|n2} all refer to a positional +# slot, not a node you can name. The DSL cannot target a physical node by +# name -- that is what standard repair's --source-of-truth is for. Also note +# there is no wildcard table key: every table you repair must be listed +# under `tables:` (aliasing the shared rule set keeps that cheap), and +# table-repair processes ONE table's diff file per invocation. +# +# 1. ALWAYS DRY-RUN FIRST. Start with --dry-run (and --generate-report) and +# review the per-row decisions before applying for real: +# ace table-repair --diff-file __diffs-.json \ +# --repair-plan repair-plan-latest-wins.yaml \ +# --dry-run --generate-report +# Only drop --dry-run once the report looks correct. +# +# 2. TIMEZONE / STRING COMPARISON. commit_ts is compared as a STRING, not as a +# parsed timestamp. "Newer wins" is only reliable when every node emits +# commit_ts with the SAME UTC offset (ideally all UTC). Nodes in different +# timezones can make the freshest comparison pick the wrong side. +# +# 3. track_commit_timestamp MUST BE ON everywhere. If one node has it off, that +# node's rows always show a NULL commit_ts regardless of how recently they +# changed, which silently breaks the "side with a timestamp is newer" logic. +# +# 4. LATEST-WRITE != ALWAYS-CORRECT. This is a last-write-wins strategy: it +# picks the most recently written row, which is not necessarily the +# semantically correct one (a recent errant update will win over an older +# good value). That is inherent to LWW, not a bug in this plan. +# +# 5. NULL commit_ts is RARE. PostgreSQL preserves a tuple's raw xmin across +# freezing (VACUUM FREEZE sets the frozen bit but SELECT xmin still returns +# the original xid), so a frozen tuple keeps a resolvable commit_ts. It only +# becomes NULL when pg_commit_ts is actually truncated -- i.e. the xid ages +# past the commit-timestamp retention horizon (tied to datfrozenxid) -- or +# when track_commit_timestamp was off at commit time. When it does happen +# this plan handles it in-pass: if only one +# side is NULL the side that still has a timestamp wins (usually the recently +# written one); if BOTH are NULL there is no recency signal, so the positional +# `tie` node is chosen. If you would rather NOT guess on the ambiguous cases, +# use ALTERNATIVE B (defer them to a --source-of-truth pass). +# +# 6. REQUIRES the pick_freshest commit_ts fix. pick_freshest resolves `key` +# from _spock_metadata_ (where commit_ts lives). On older ACE builds that +# lacked this, `key: commit_ts` silently fell back to `tie` every time. If in +# doubt, confirm with --dry-run that the NEWER side is actually chosen. +# +# when-grammar reminders: use `and`/`or`/`not`, single `=`, `!=`, +# `is null` / `is not null`. NOT supported: `&&`, `||`, `==`. + +version: 1 + +tables: + # >>> replace these keys with your qualified (schema.table) names <<< + # The FIRST table defines the shared rule set (&latest_wins); the rest reuse + # it with `rules: *latest_wins`. Editing the anchored block updates them all. + + public.inventory: + rules: &latest_wins + # ================================================================= + # ACTIVE RULE - one-pass latest-commit-wins (shared by every table) + # ================================================================= + # A single pick_freshest rule resolves EVERY row_mismatch in one pass: + # - both sides have a commit_ts, different -> the NEWER one wins + # - one side's commit_ts is NULL (frozen/untracked) -> the side that + # still HAS a timestamp wins (it is the recently-written one) + # - timestamps are exactly EQUAL, or BOTH are NULL -> the `tie` node + # (positional n1 = alphabetically-first node; see caveat 0) + - name: latest_commit_wins + diff_type: [row_mismatch] + action: + type: custom + helpers: + pick_freshest: + key: commit_ts + tie: n1 + + # --- Rows that exist on only ONE side -------------------------------- + # pick_freshest cannot apply (nothing to compare). Uncomment to also + # resolve inserts/deletes in the same pass; otherwise they are left alone. + # + # - name: propagate_inserts_to_n2 + # diff_type: [missing_on_n2] + # action: { type: apply_from, from: n1, mode: upsert } + # + # - name: propagate_inserts_to_n1 + # diff_type: [missing_on_n1] + # action: { type: apply_from, from: n2, mode: upsert } + + # ================================================================= + # OPT-IN ALTERNATIVES - commented out + # ================================================================= + # These edit the SHARED rule set, so a change applies to every table that + # aliases *latest_wins. To vary a single table instead, give that table + # its own explicit `rules:` list (see the override example at the bottom). + + # --- ALTERNATIVE A (best when available): app-level timestamp column --- + # If the table has a real, application-maintained timestamp column (e.g. + # updated_at), use it as the key. Being ordinary column data it is immune + # to freezing / wraparound, so it never goes NULL and gives correct + # latest-wins for every row. Replaces the ACTIVE rule above. + # + # - name: latest_by_app_column + # diff_type: [row_mismatch] + # action: + # type: custom + # helpers: + # pick_freshest: { key: updated_at, tie: n1 } + + # --- ALTERNATIVE B (two-pass): defer ambiguous rows to source-of-truth - + # Instead of letting `tie` guess on equal/NULL cases, resolve only the + # clear cases here and SKIP the ambiguous ones, then run a second pass + # `table-repair --source-of-truth ` to clean up the skipped rows by + # a named authoritative node. Each case is its own rule (first match + # wins), so you control equal vs NULL independently. Replaces the ACTIVE + # rule above. + # + # - name: freshest_clear_case # both present AND different + # diff_type: [row_mismatch] + # when: n1.commit_ts is not null and n2.commit_ts is not null and n1.commit_ts != n2.commit_ts + # action: + # type: custom + # helpers: + # pick_freshest: { key: commit_ts, tie: n1 } + # + # - name: one_side_null_keep_ts_side # exactly one side has a commit_ts + # diff_type: [row_mismatch] + # when: (n1.commit_ts is null and n2.commit_ts is not null) or (n1.commit_ts is not null and n2.commit_ts is null) + # action: + # type: custom + # helpers: + # pick_freshest: { key: commit_ts, tie: n1 } + # + # - name: ambiguous_defer # equal timestamps OR both NULL + # diff_type: [row_mismatch] + # when: n1.commit_ts = n2.commit_ts or (n1.commit_ts is null and n2.commit_ts is null) + # action: { type: skip } + + # Additional tables reuse the exact same rule set via the alias: + public.orders: + rules: *latest_wins + + public.customers: + rules: *latest_wins + + # --- PER-TABLE OVERRIDE EXAMPLE (commented) -------------------------------- + # To give one table DIFFERENT logic (e.g. it has an updated_at column), do NOT + # alias -- give it its own rules list: + # + # public.events: + # rules: + # - name: latest_by_app_column + # diff_type: [row_mismatch] + # action: + # type: custom + # helpers: + # pick_freshest: { key: updated_at, tie: n1 }