feat(dimensions): PR9 cutover — cost_center/project become GENERATED columns, dual-write removed (#870)

The dual-write window ends (dev_docs/dimensions_implementation_plan.md PR9):
journal_entry_lines.cost_center/project are now GENERATED ALWAYS AS
(NULLIF(dimensions->>'1'/'6','')) STORED — divergence from the bag is
impossible by construction instead of by convention.

- migration 20260702230000: drift pre-flight (refuses cutover on
  inconsistent data; prod verified 0 drift across 593k rows), column swap
  (DROP metadata-only + one-rewrite ADD pair), and atomic redefinition of
  the two SQL writers — retag_line_dimensions (SET dimensions only) and
  bulk_book_transactions (INSERT names the bag only)
- TS writers stripped of the mirror spread: engine buildLineInserts
  (covers create/update/reversal), storno-service (reversal + correction),
  SIE import bulk insert, sandbox seed
- lineDimensionColumns() removed from dimension-resolver — nothing derives
  mirrors in TypeScript anymore; normalizeLineDimensions + the deprecated
  cost_center/project INPUT aliases stay (API contract, they normalize
  into the bag); JournalEntryLine ROW type keeps the fields (generated
  columns still SELECT)
- immutability carve-out unchanged BY DESIGN: its whole-row diff already
  subtracts dimensions/cost_center/project on both sides, which is exactly
  what makes it correct with generated columns (BEFORE-trigger NEW carries
  not-yet-recomputed mirror values)
- audited every reader (v1 journal-entries, MCP query_journal filters +
  group_by, rc-basis-gaps) — reads are untouched; no index, view, or
  constraint referenced the TEXT columns, so DROP COLUMN cascades nothing
- new pg suite: generated derivation, explicit-mirror-write rejection,
  draft-update recompute; existing retag/substrate/bulk-book suites
  updated to bag-only writes (their mirror assertions now exercise the
  generation expression)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 23:28:42 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 163fbd8222
commit fb3f0a9cee
12 changed files with 713 additions and 78 deletions
@@ -1,11 +1,8 @@
import { describe, it, expect } from 'vitest'
import {
normalizeLineDimensions,
lineDimensionColumns,
coerceDimensionsBag,
validateEntryDimensions,
DIM_COST_CENTER,
DIM_PROJECT,
} from '@/lib/bookkeeping/dimension-resolver'
// Imported from errors.ts on purpose: proves the re-export surface every
// server consumer uses (the class itself lives in dimension-errors.ts).
@@ -63,10 +60,11 @@ describe('normalizeLineDimensions', () => {
).toEqual({ '6': 'P001' })
})
it("canonicalizes leading-zero keys ('01' -> '1') so mirrors are derived", () => {
it("canonicalizes leading-zero keys ('01' -> '1') so the generated mirrors derive", () => {
// The DB generates cost_center/project from keys '1'/'6' (PR9 cutover) —
// '01' must land on '1' or the generated mirror misses the value.
const dims = normalizeLineDimensions({ dimensions: { '01': 'KS01', '06': 'P001' } })
expect(dims).toEqual({ '1': 'KS01', '6': 'P001' })
expect(lineDimensionColumns(dims)).toEqual({ cost_center: 'KS01', project: 'P001' })
})
it("clearing via a leading-zero key ('01': '') also clears the alias-filled '1'", () => {
@@ -120,24 +118,9 @@ describe('coerceDimensionsBag (boundary validator for staged payloads)', () => {
})
})
describe('lineDimensionColumns', () => {
it('derives both mirrors from the map', () => {
expect(lineDimensionColumns({ [DIM_COST_CENTER]: 'KS01', [DIM_PROJECT]: 'P001' })).toEqual({
cost_center: 'KS01',
project: 'P001',
})
})
it('returns nulls for missing keys', () => {
expect(lineDimensionColumns({})).toEqual({ cost_center: null, project: null })
expect(lineDimensionColumns({ '7': 'ANST-4' })).toEqual({ cost_center: null, project: null })
})
it('round-trips with normalizeLineDimensions (mirror consistency)', () => {
const dims = normalizeLineDimensions({ cost_center: 'KS01', dimensions: { '6': 'P001' } })
expect(lineDimensionColumns(dims)).toEqual({ cost_center: 'KS01', project: 'P001' })
})
})
// lineDimensionColumns() tests were removed with the function in the PR9
// cutover — mirror derivation now lives in the database as GENERATED columns
// and is covered by tests/pg/dimensions-generated-cutover.pg.test.ts.
describe('validateEntryDimensions (soft registry validation, PR3)', () => {
const enabledSettings = { data: { dimensions_enabled: true } }
@@ -145,10 +145,11 @@ describe('createDraftEntry — dimension validation wiring', () => {
// Toggle checked once, registry never consulted (free-text passthrough).
expect(queriedTables().filter((t) => t === 'company_settings')).toHaveLength(1)
expect(queriedTables()).not.toContain('dimensions')
// The free-text tag still lands on the inserted lines (bag + mirror).
// The free-text tag still lands on the inserted lines (bag only — the
// project mirror is GENERATED at the database since the PR9 cutover).
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
expect(lineRows[0].dimensions).toEqual({ '6': 'FRITEXT-PROJEKT' })
expect(lineRows[0].project).toBe('FRITEXT-PROJEKT')
expect('project' in lineRows[0]).toBe(false)
})
it('rejects an unknown code before ANY row is inserted (toggle on)', async () => {
@@ -194,8 +195,10 @@ describe('createDraftEntry — dimension validation wiring', () => {
expect(entry.id).toBe('entry-1')
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
expect(lineRows[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(lineRows[0].cost_center).toBe('KS01')
expect(lineRows[0].project).toBe('P001')
// PR9 cutover: the payload must NOT name the generated mirror columns —
// an explicit value would make Postgres reject the insert.
expect('cost_center' in lineRows[0]).toBe(false)
expect('project' in lineRows[0]).toBe(false)
})
})
+8 -20
View File
@@ -1,13 +1,12 @@
/**
* Dimension resolver — the single place line dimensions are normalized and
* mirrored (dev_docs/dimensions_implementation_plan.md).
* Dimension resolver — the single place line dimensions are normalized
* (dev_docs/dimensions_implementation_plan.md).
*
* Storage model: journal_entry_lines.dimensions is a JSONB map keyed by SIE
* dimension number ({"1":"KS01","6":"P001"}) and is the single source of
* truth. The legacy cost_center/project TEXT columns are deterministic mirrors
* of keys '1'/'6' during the dual-write window (they become GENERATED columns
* in a later migration). Every journal_entry_lines writer MUST derive the
* mirror columns via lineDimensionColumns() — never set them independently.
* truth. Since the PR9 cutover (20260702230000) the cost_center/project
* columns are GENERATED ALWAYS from keys '1'/'6' — writers set only the
* bag; writing the mirror columns explicitly errors at the database.
*/
import { z } from 'zod'
@@ -134,20 +133,9 @@ export function dimensionsBagKey(dimensions?: LineDimensions): string {
)
}
/**
* Derive the legacy mirror columns from the canonical map. Pure function —
* divergence between `dimensions` and cost_center/project is impossible as
* long as every writer goes through this.
*/
export function lineDimensionColumns(dimensions: LineDimensions): {
cost_center: string | null
project: string | null
} {
return {
cost_center: dimensions[DIM_COST_CENTER] ?? null,
project: dimensions[DIM_PROJECT] ?? null,
}
}
// lineDimensionColumns() was removed in the PR9 cutover: the mirror columns
// are GENERATED ALWAYS from the bag at the database, so there is nothing for
// TypeScript writers to derive — they set only `dimensions`.
/**
* Soft registry validation of the dimensions referenced by a set of entry
+4 -5
View File
@@ -16,7 +16,6 @@ import {
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import {
normalizeLineDimensions,
lineDimensionColumns,
validateEntryDimensions,
} from '@/lib/bookkeeping/dimension-resolver'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
@@ -183,7 +182,7 @@ export async function findFiscalPeriod(
/**
* Build line insert objects from input lines, resolving account IDs and
* including tax_code, cost_center, project dimensions
* including tax_code and the dimensions bag
*/
function buildLineInserts(
entryId: string,
@@ -191,8 +190,9 @@ function buildLineInserts(
accountIdMap: Map<string, string>
) {
return lines.map((line, index) => {
// dimensions JSONB is the source of truth; cost_center/project are
// derived mirrors (dual-write window — see lib/bookkeeping/dimension-resolver.ts)
// dimensions JSONB is the single source of truth; cost_center/project
// are GENERATED columns derived from keys '1'/'6' since the PR9 cutover
// (20260702230000) — writing them explicitly would error.
const dimensions = normalizeLineDimensions(line)
return {
journal_entry_id: entryId,
@@ -206,7 +206,6 @@ function buildLineInserts(
line_description: line.line_description || null,
tax_code: line.tax_code || null,
dimensions,
...lineDimensionColumns(dimensions),
sort_order: index,
}
})
+1 -3
View File
@@ -6,7 +6,7 @@ import type {
JournalEntryLine,
} from '@/types'
import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
import { normalizeLineDimensions, lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver'
import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import {
@@ -287,7 +287,6 @@ export async function correctEntry(
line_description: `Storno: ${line.line_description || ''}`,
tax_code: line.tax_code || null,
dimensions,
...lineDimensionColumns(dimensions),
sort_order: index,
}
})
@@ -372,7 +371,6 @@ export async function correctEntry(
line_description: line.line_description || null,
tax_code: line.tax_code || null,
dimensions,
...lineDimensionColumns(dimensions),
sort_order: index,
}
})
+1 -4
View File
@@ -7,7 +7,7 @@
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { normalizeLineDimensions, lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver'
import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
import { importDimensionRegistry } from './sie-dimensions'
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
import type {
@@ -1345,8 +1345,6 @@ export async function importVouchers(
line_description: string | null
sort_order: number
dimensions: Record<string, string>
cost_center: string | null
project: string | null
}[] = []
for (let i = 0; i < batch.length; i++) {
@@ -1372,7 +1370,6 @@ export async function importVouchers(
line_description: line.line_description,
sort_order: lineIndex,
dimensions: dims,
...lineDimensionColumns(dims),
})
})