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:
co-authored by
Claude Fable 5
parent
163fbd8222
commit
fb3f0a9cee
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user