feat(perf): session-cached reference data layer, server-seeded, with a raw-fetch ratchet (#1932)

* feat(perf): session-cached reference data layer, server-seeded, with a raw-fetch ratchet

Customer report (2026-08-26): "it takes time before all fields load when
clicking around". The cause is on the client: fiscal periods, settings,
accounts, cash accounts, dimensions and templates are fetched raw from 47 /
27 / 14 / 8 / 12 / 5 independent call sites, uncached, on every mount and
every dialog open, each request paying the auth proxy and route wrapper
before its own query. SWR was adopted for exactly this on 2026-07-13 but
reached only three files.

This PR adds the layer; consumers migrate in the follow-ups.

- lib/reference-data/keys.ts: one key builder per data set, company id in
  position 1, null without a company; company_settings keeps the shape
  useCompanySettings already uses so that hook is seeded without a change.
- lib/reference-data/fetchers.ts: browser Supabase for fiscal periods and
  cash accounts (mirroring period.list and listForCompany ordering, pinned
  by tests), /api for the lists whose routes do real work (accounts RPC,
  dimensions ensure, template scoping, customer masking).
- lib/reference-data/hooks.ts: useFiscalPeriods, useCashAccounts,
  useAccounts, useDimensions, useBookingTemplates, useCustomers,
  useSuppliers, useArticles (+ re-exported useCompanySettings); one-minute
  dedupe, keepPreviousData, background revalidation kept on so writes from
  MCP/agents/other tabs surface.
- lib/reference-data/invalidate.ts: invalidateReferenceData(kind) for the
  success path of every client write.
- lib/reference-data/seed.ts + components/providers/ReferenceDataSeed.tsx:
  the dashboard layout fetches fiscal periods and cash accounts in its
  existing batch and hands them, with the settings row it already had, to
  SWR as fallback, so the first form of a session renders its period, bank
  account and settings-driven fields on first paint. getDashboardSettings
  now selects the full row for that (its other consumers read a subset).
  The chart of accounts is not seeded (hundreds of KB for large charts).
- scripts/checks/raw-reference-fetch.mjs, wired into check:guards as a
  per-file ratchet: GET-shaped fetch('/api/<reference path>') anywhere in
  client-facing code and .from('<reference table>').select( in 'use client'
  files. Baselined at 55 files; new sites fail CI; at 0 the entry goes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(checks): anchor every optional whitespace run in the raw-reference-fetch regex

CodeQL js/redos flagged the `\s*,?\s*\)` tail: two adjacent optional
whitespace runs around an optional comma backtrack polynomially on a long
near-miss. The URL and init-object pieces are now named fragments and
every whitespace run is followed by a literal, so there is one way to
match. Behaviour unchanged (same 7 fixtures + baseline count of 55);
a worst-case timing test pins the linear scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(checks): make the use-client directive regex unambiguous (CodeQL js/redos)

An unclosed /* let the lazy comment body be re-split at every later /*.
The body is now (?:[^*]|\*(?!\/))* which cannot cross a */, so the outer
repetition has one parse. Pinned with a 3000-comment worst-case test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: re-trigger checks for the rebased head

No workflow ran for dd560a7af (nor after close/reopen); an empty commit
gives the pull_request event a fresh head. No code change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(checks): single-character whitespace alternative in the use-client detector (CodeQL js/redos)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 14:14:54 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent a8980a3a41
commit 47fe193c48
16 changed files with 1032 additions and 3 deletions
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const supabaseState = vi.hoisted(() => ({
result: { data: null as unknown, error: null as unknown },
calls: [] as Array<{ method: string; args: unknown[] }>,
}))
vi.mock('@/lib/supabase/client', () => ({
createClient: () => {
const chain: Record<string, unknown> = {}
const proxy: unknown = new Proxy(chain, {
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(supabaseState.result)
}
return (...args: unknown[]) => {
supabaseState.calls.push({ method: String(prop), args })
return proxy
}
},
})
return { from: (table: string) => (supabaseState.calls.push({ method: 'from', args: [table] }), proxy) }
},
}))
import {
ReferenceFetchError,
fetchAccounts,
fetchArticles,
fetchBookingTemplates,
fetchCashAccounts,
fetchCustomers,
fetchDimensions,
fetchFiscalPeriods,
fetchSuppliers,
} from '../fetchers'
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
}
describe('browser Supabase fetchers', () => {
beforeEach(() => {
supabaseState.calls = []
supabaseState.result = { data: [{ id: 'row-1' }], error: null }
})
it('fetchFiscalPeriods mirrors period.list: company filter, newest period first', async () => {
const rows = await fetchFiscalPeriods('c1')
expect(rows).toEqual([{ id: 'row-1' }])
expect(supabaseState.calls).toEqual([
{ method: 'from', args: ['fiscal_periods'] },
{ method: 'select', args: ['*'] },
{ method: 'eq', args: ['company_id', 'c1'] },
{ method: 'order', args: ['period_start', { ascending: false }] },
])
})
it('fetchCashAccounts mirrors listForCompany: primary first, then ledger account', async () => {
await fetchCashAccounts('c1')
expect(supabaseState.calls).toEqual([
{ method: 'from', args: ['cash_accounts'] },
{ method: 'select', args: ['*'] },
{ method: 'eq', args: ['company_id', 'c1'] },
{ method: 'order', args: ['is_primary', { ascending: false }] },
{ method: 'order', args: ['ledger_account', { ascending: true }] },
])
})
it('throws the Supabase error so SWR surfaces it instead of caching an empty list', async () => {
supabaseState.result = { data: null, error: { message: 'boom' } }
await expect(fetchFiscalPeriods('c1')).rejects.toEqual({ message: 'boom' })
})
it('resolves to an empty list when the query returns no rows', async () => {
supabaseState.result = { data: null, error: null }
expect(await fetchCashAccounts('c1')).toEqual([])
})
})
describe('API fetchers', () => {
const fetchMock = vi.fn()
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('unwraps { data } and hits the documented URLs', async () => {
fetchMock.mockImplementation(async () => jsonResponse({ data: [{ id: 'x' }] }))
expect(await fetchAccounts()).toEqual([{ id: 'x' }])
expect(await fetchAccounts(false)).toEqual([{ id: 'x' }])
expect(await fetchBookingTemplates()).toEqual([{ id: 'x' }])
expect(await fetchCustomers()).toEqual([{ id: 'x' }])
expect(await fetchSuppliers()).toEqual([{ id: 'x' }])
expect(await fetchArticles()).toEqual([{ id: 'x' }])
expect(await fetchArticles(true)).toEqual([{ id: 'x' }])
expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([
'/api/bookkeeping/accounts',
'/api/bookkeeping/accounts?active=false',
'/api/settings/booking-templates',
'/api/customers',
'/api/suppliers',
'/api/articles',
'/api/articles?include_inactive=1',
])
})
it('reads the dimensions route from its `dimensions` field', async () => {
fetchMock.mockImplementation(async () => jsonResponse({ dimensions: [{ id: 'd' }] }))
expect(await fetchDimensions()).toEqual([{ id: 'd' }])
expect(fetchMock).toHaveBeenCalledWith('/api/dimensions')
})
it('returns an empty list when the payload field is missing', async () => {
fetchMock.mockImplementation(async () => jsonResponse({}))
expect(await fetchCustomers()).toEqual([])
})
it('throws a ReferenceFetchError carrying status and body on a non-2xx response', async () => {
fetchMock.mockImplementation(async () => jsonResponse({ error: 'nope' }, 403))
const err = await fetchSuppliers().catch((e) => e)
expect(err).toBeInstanceOf(ReferenceFetchError)
expect(err.status).toBe(403)
expect(err.body).toEqual({ error: 'nope' })
})
it('still throws with a null body when the error response is not JSON', async () => {
fetchMock.mockImplementation(async () => new Response('gateway', { status: 502 }))
const err = await fetchArticles().catch((e) => e)
expect(err).toBeInstanceOf(ReferenceFetchError)
expect(err.status).toBe(502)
expect(err.body).toBeNull()
})
})
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { REFERENCE_KINDS, isReferenceKey, refKeys } from '../keys'
describe('refKeys', () => {
it('returns null for every builder when there is no active company', () => {
expect(refKeys.companySettings(null)).toBeNull()
expect(refKeys.fiscalPeriods(null)).toBeNull()
expect(refKeys.cashAccounts(null)).toBeNull()
expect(refKeys.accounts(null)).toBeNull()
expect(refKeys.dimensions(null)).toBeNull()
expect(refKeys.bookingTemplates(null)).toBeNull()
expect(refKeys.customers(null)).toBeNull()
expect(refKeys.suppliers(null)).toBeNull()
expect(refKeys.articles(null)).toBeNull()
})
it('puts the kind first and the company id second on every key', () => {
const keys = [
refKeys.companySettings('c1'),
refKeys.fiscalPeriods('c1'),
refKeys.cashAccounts('c1'),
refKeys.accounts('c1'),
refKeys.dimensions('c1'),
refKeys.bookingTemplates('c1'),
refKeys.customers('c1'),
refKeys.suppliers('c1'),
refKeys.articles('c1'),
]
for (const key of keys) {
expect(key).not.toBeNull()
expect(REFERENCE_KINDS).toContain(key![0])
expect(key![1]).toBe('c1')
}
expect(new Set(keys.map((k) => k![0])).size).toBe(keys.length)
})
it('keeps the legacy company_settings key shape used by useCompanySettings', () => {
expect(refKeys.companySettings('c1')).toEqual(['company_settings', 'c1'])
})
it('varies the accounts and articles keys on their filter flag', () => {
expect(refKeys.accounts('c1')).toEqual(['ref:accounts', 'c1', true])
expect(refKeys.accounts('c1', false)).toEqual(['ref:accounts', 'c1', false])
expect(refKeys.articles('c1')).toEqual(['ref:articles', 'c1', false])
expect(refKeys.articles('c1', true)).toEqual(['ref:articles', 'c1', true])
})
})
describe('isReferenceKey', () => {
it('accepts keys from the builders and rejects everything else', () => {
expect(isReferenceKey(refKeys.fiscalPeriods('c1'))).toBe(true)
expect(isReferenceKey(['company_settings', 'c1'])).toBe(true)
expect(isReferenceKey(['worklist-badges', 'c1'])).toBe(false)
expect(isReferenceKey('/api/settings')).toBe(false)
expect(isReferenceKey(null)).toBe(false)
})
})
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { unstable_serialize } from 'swr'
import { buildReferenceFallback } from '../seed'
import { refKeys } from '../keys'
import type { CashAccount, CompanySettings, FiscalPeriod } from '@/types'
const periods = [{ id: 'p1' }] as unknown as FiscalPeriod[]
const cash = [{ id: 'a1' }] as unknown as CashAccount[]
const settings = { company_id: 'c1', company_name: 'Bolaget AB' } as unknown as CompanySettings
describe('buildReferenceFallback', () => {
it('keys the seed exactly as the hooks key their cache entries', () => {
const fallback = buildReferenceFallback('c1', { fiscalPeriods: periods, cashAccounts: cash, settings })
expect(Object.keys(fallback).sort()).toEqual(
[
unstable_serialize(refKeys.fiscalPeriods('c1')),
unstable_serialize(refKeys.cashAccounts('c1')),
unstable_serialize(refKeys.companySettings('c1')),
].sort(),
)
expect(fallback[unstable_serialize(refKeys.fiscalPeriods('c1'))]).toBe(periods)
expect(fallback[unstable_serialize(refKeys.cashAccounts('c1'))]).toBe(cash)
expect(fallback[unstable_serialize(refKeys.companySettings('c1'))]).toBe(settings)
})
it('seeds a null settings row (no row yet) but omits settings when not fetched', () => {
const withNull = buildReferenceFallback('c1', { fiscalPeriods: [], cashAccounts: [], settings: null })
expect(withNull[unstable_serialize(refKeys.companySettings('c1'))]).toBeNull()
const notFetched = buildReferenceFallback('c1', { fiscalPeriods: [], cashAccounts: [] })
expect(unstable_serialize(refKeys.companySettings('c1')) in notFetched).toBe(false)
})
it('seeds nothing without an active company', () => {
expect(buildReferenceFallback(null, { fiscalPeriods: periods, cashAccounts: cash, settings })).toEqual({})
})
})
+109
View File
@@ -0,0 +1,109 @@
/**
* Fetchers behind the reference-data hooks. Pure async functions so they can
* be unit-tested without React and reused by `preload()` warm-ups.
*
* Two transports, chosen per data set:
* - Browser Supabase for the trivial RLS-scoped selects (fiscal periods,
* cash accounts). These mirror the corresponding API routes exactly
* (period.list ordering, listForCompany ordering) and save the proxy +
* route-wrapper round trips the API path pays.
* - `/api/...` for lists whose route does real work the client must not
* reimplement: accounts (list_company_accounts RPC with the paged
* fallback), dimensions (ensure_company_dimensions + pagination),
* booking templates (team scoping + last-used ordering), customers
* (personal-number masking), suppliers and articles.
*
* Do not import lib/cash-accounts/service.ts here: it pulls lib/logger and
* the account-sync module into the client bundle. The two order() clauses
* are mirrored instead and pinned by a test.
*/
import { createClient } from '@/lib/supabase/client'
import type {
Article,
BASAccount,
CashAccount,
Customer,
FiscalPeriod,
Supplier,
} from '@/types'
import type { DimensionDto } from '@/components/dimensions/types'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
export class ReferenceFetchError extends Error {
readonly status: number
readonly body: unknown
constructor(url: string, status: number, body: unknown) {
super(`Reference data request failed: ${status} ${url}`)
this.name = 'ReferenceFetchError'
this.status = status
this.body = body
}
}
export type BookingTemplateWithUsage = BookingTemplate & { last_used_at: string | null }
export async function fetchFiscalPeriods(companyId: string): Promise<FiscalPeriod[]> {
const supabase = createClient()
const { data, error } = await supabase
.from('fiscal_periods')
.select('*')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
if (error) throw error
return (data ?? []) as FiscalPeriod[]
}
export async function fetchCashAccounts(companyId: string): Promise<CashAccount[]> {
const supabase = createClient()
const { data, error } = await supabase
.from('cash_accounts')
.select('*')
.eq('company_id', companyId)
.order('is_primary', { ascending: false })
.order('ledger_account', { ascending: true })
if (error) throw error
return (data ?? []) as CashAccount[]
}
async function getJson<T>(url: string, pick: (body: Record<string, unknown>) => unknown): Promise<T> {
const res = await fetch(url)
let body: unknown = null
try {
body = await res.json()
} catch {
body = null
}
if (!res.ok) throw new ReferenceFetchError(url, res.status, body)
const picked = pick((body ?? {}) as Record<string, unknown>)
return (picked ?? []) as T
}
export function fetchAccounts(activeOnly = true): Promise<BASAccount[]> {
const url = activeOnly
? '/api/bookkeeping/accounts'
: '/api/bookkeeping/accounts?active=false'
return getJson<BASAccount[]>(url, (b) => b.data)
}
export function fetchDimensions(): Promise<DimensionDto[]> {
return getJson<DimensionDto[]>('/api/dimensions', (b) => b.dimensions)
}
export function fetchBookingTemplates(): Promise<BookingTemplateWithUsage[]> {
return getJson<BookingTemplateWithUsage[]>('/api/settings/booking-templates', (b) => b.data)
}
export function fetchCustomers(): Promise<Customer[]> {
return getJson<Customer[]>('/api/customers', (b) => b.data)
}
export function fetchSuppliers(): Promise<Supplier[]> {
return getJson<Supplier[]>('/api/suppliers', (b) => b.data)
}
export function fetchArticles(includeInactive = false): Promise<Article[]> {
const url = includeInactive ? '/api/articles?include_inactive=1' : '/api/articles'
return getJson<Article[]>(url, (b) => b.data)
}
+161
View File
@@ -0,0 +1,161 @@
'use client'
/**
* Company-scoped reference data, cached for the session.
*
* Why: forms and pickers used to fetch the same fiscal periods, accounts,
* cash accounts, settings, dimensions and templates on every mount and every
* dialog open (fiscal periods from 47 call sites, settings from 27), each
* request paying the auth proxy + route wrapper before its own query. That
* is the customer-visible "fields load late when clicking around". These
* hooks share one SWR cache entry per company and data set, render from the
* cache (or the server seed, see components/providers/ReferenceDataSeed.tsx)
* on first paint, and revalidate in the background.
*
* Rules:
* - Keys come from lib/reference-data/keys.ts only.
* - After a client write, call invalidateReferenceData() from
* lib/reference-data/invalidate.ts; the dedupe window is bypassed.
* - `revalidateIfStale` stays on so writes made elsewhere (MCP, agent,
* another tab, SIE import) surface within a minute of the next mount.
* - Consumers needing `entity_type` read useCompany().company.entity_type:
* /api/settings falls back to companies.entity_type, this hook does not.
*/
import { useMemo } from 'react'
import useSWR, { type SWRConfiguration } from 'swr'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type {
Article,
BASAccount,
CashAccount,
Customer,
FiscalPeriod,
Supplier,
} from '@/types'
import type { DimensionDto } from '@/components/dimensions/types'
import { refKeys } from './keys'
import {
fetchAccounts,
fetchArticles,
fetchBookingTemplates,
fetchCashAccounts,
fetchCustomers,
fetchDimensions,
fetchFiscalPeriods,
fetchSuppliers,
type BookingTemplateWithUsage,
} from './fetchers'
export { useCompanySettings } from '@/components/settings/useSettings'
export const REFERENCE_SWR_OPTIONS = {
revalidateOnFocus: false,
revalidateOnReconnect: false,
// At most one background refresh per key per minute; mutate() bypasses it.
dedupingInterval: 60_000,
// Never blank a picker while a refresh is in flight.
keepPreviousData: true,
} satisfies SWRConfiguration
const EMPTY: never[] = []
export interface ReferenceListState {
/** True only while the FIRST load for this key is in flight (no cache, no seed). */
isLoading: boolean
error: unknown
/** Re-run the fetch for this key now (bypasses the dedupe window). */
refresh: () => Promise<unknown>
}
function useActiveCompanyId(): string | null {
return useCompanyOptional()?.company?.id ?? null
}
function useReferenceList<T, K extends readonly unknown[] | null>(
key: K,
fetcher: (key: NonNullable<K>) => Promise<T[]>,
): ReferenceListState & { items: T[] } {
const { data, error, isLoading, mutate } = useSWR<T[]>(
key,
fetcher as (key: unknown) => Promise<T[]>,
REFERENCE_SWR_OPTIONS,
)
return {
items: data ?? (EMPTY as T[]),
isLoading: key !== null && isLoading,
error,
refresh: mutate,
}
}
export function useFiscalPeriods(): ReferenceListState & { periods: FiscalPeriod[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.fiscalPeriods(companyId), ([, id]) =>
fetchFiscalPeriods(id),
)
return { periods: items, ...state }
}
export function useCashAccounts(
options: { enabledOnly?: boolean } = {},
): ReferenceListState & { cashAccounts: CashAccount[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.cashAccounts(companyId), ([, id]) =>
fetchCashAccounts(id),
)
const enabledOnly = options.enabledOnly ?? false
// One cache entry serves both variants: the enabled filter is cheap and
// keeping a single key means one seed and one invalidation.
const cashAccounts = useMemo(
() => (enabledOnly ? items.filter((a) => a.enabled) : items),
[items, enabledOnly],
)
return { cashAccounts, ...state }
}
export function useAccounts(activeOnly = true): ReferenceListState & { accounts: BASAccount[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.accounts(companyId, activeOnly), ([, , active]) =>
fetchAccounts(active),
)
return { accounts: items, ...state }
}
export function useDimensions(): ReferenceListState & { dimensions: DimensionDto[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.dimensions(companyId), () => fetchDimensions())
return { dimensions: items, ...state }
}
export function useBookingTemplates(): ReferenceListState & { templates: BookingTemplateWithUsage[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.bookingTemplates(companyId), () =>
fetchBookingTemplates(),
)
return { templates: items, ...state }
}
export function useCustomers(): ReferenceListState & { customers: Customer[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.customers(companyId), () => fetchCustomers())
return { customers: items, ...state }
}
export function useSuppliers(): ReferenceListState & { suppliers: Supplier[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.suppliers(companyId), () => fetchSuppliers())
return { suppliers: items, ...state }
}
export function useArticles(
options: { includeInactive?: boolean } = {},
): ReferenceListState & { articles: Article[] } {
const companyId = useActiveCompanyId()
const includeInactive = options.includeInactive ?? false
const { items, ...state } = useReferenceList(
refKeys.articles(companyId, includeInactive),
([, , inactive]) => fetchArticles(inactive),
)
return { articles: items, ...state }
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Invalidate cached reference data after a client-side write.
*
* Call this from the success path of any dialog or page that creates,
* updates or deletes a period, account, cash account, setting, dimension,
* booking template, customer, supplier or article, so every picker mounted
* anywhere refetches immediately (SWR's global mutate bypasses the dedupe
* window). Client-only: it imports the SWR cache.
*/
import { mutate } from 'swr'
import { isReferenceKey, type ReferenceKind } from './keys'
export function invalidateReferenceData(kind: ReferenceKind | ReferenceKind[]): Promise<unknown> {
const kinds = new Set(Array.isArray(kind) ? kind : [kind])
return mutate((key) => isReferenceKey(key) && kinds.has(key[0]))
}
+66
View File
@@ -0,0 +1,66 @@
/**
* SWR cache keys for company-scoped reference data.
*
* One builder per data set is the single source of truth for the hooks
* (lib/reference-data/hooks.ts), the server seed (seed.ts) and the
* invalidation helper (invalidate.ts): a key spelled out anywhere else can
* drift from the seed and silently reintroduce the refetch this layer exists
* to remove. Every key carries the company id in position 1 so a company
* switch can never serve another company's list, and resolves to `null`
* (SWR: do not fetch) when there is no active company.
*
* `company_settings` keeps the shape components/settings/useSettings.ts has
* used since 2026-07-13 so that hook needs no change to be seeded.
*/
export const REFERENCE_KINDS = [
'company_settings',
'ref:fiscal-periods',
'ref:cash-accounts',
'ref:accounts',
'ref:dimensions',
'ref:booking-templates',
'ref:customers',
'ref:suppliers',
'ref:articles',
] as const
export type ReferenceKind = (typeof REFERENCE_KINDS)[number]
type Key<K extends ReferenceKind, Rest extends readonly unknown[] = []> =
| readonly [K, string, ...Rest]
| null
export const refKeys = {
companySettings: (companyId: string | null): Key<'company_settings'> =>
companyId ? (['company_settings', companyId] as const) : null,
fiscalPeriods: (companyId: string | null): Key<'ref:fiscal-periods'> =>
companyId ? (['ref:fiscal-periods', companyId] as const) : null,
cashAccounts: (companyId: string | null): Key<'ref:cash-accounts'> =>
companyId ? (['ref:cash-accounts', companyId] as const) : null,
accounts: (
companyId: string | null,
activeOnly = true,
): Key<'ref:accounts', [boolean]> =>
companyId ? (['ref:accounts', companyId, activeOnly] as const) : null,
dimensions: (companyId: string | null): Key<'ref:dimensions'> =>
companyId ? (['ref:dimensions', companyId] as const) : null,
bookingTemplates: (companyId: string | null): Key<'ref:booking-templates'> =>
companyId ? (['ref:booking-templates', companyId] as const) : null,
customers: (companyId: string | null): Key<'ref:customers'> =>
companyId ? (['ref:customers', companyId] as const) : null,
suppliers: (companyId: string | null): Key<'ref:suppliers'> =>
companyId ? (['ref:suppliers', companyId] as const) : null,
articles: (
companyId: string | null,
includeInactive = false,
): Key<'ref:articles', [boolean]> =>
companyId ? (['ref:articles', companyId, includeInactive] as const) : null,
}
const KIND_SET: ReadonlySet<string> = new Set(REFERENCE_KINDS)
/** True for any key produced by `refKeys` (used by the invalidation filter). */
export function isReferenceKey(key: unknown): key is readonly [ReferenceKind, string, ...unknown[]] {
return Array.isArray(key) && typeof key[0] === 'string' && KIND_SET.has(key[0])
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Server-to-client seed for the reference-data cache.
*
* The dashboard layout already fetches company settings for its own nav;
* adding the (small) fiscal-period and cash-account lists to that batch and
* handing all three to SWR as `fallback` means the first form a user opens
* renders its period, bank-account and settings-driven fields on the first
* paint with zero client round trips. SWR consults `fallback` only while
* the cache has no entry for the key, so after the first background
* revalidation or any mutate() the live cache wins; a hard reload re-seeds.
*
* Keys are serialized with SWR's own `unstable_serialize` and built from
* `refKeys`, so the seed can never drift from the hooks (pinned by a test).
*/
import { unstable_serialize } from 'swr'
import type { CashAccount, CompanySettings, FiscalPeriod } from '@/types'
import { refKeys } from './keys'
export interface ReferenceSeed {
fiscalPeriods: FiscalPeriod[]
cashAccounts: CashAccount[]
/** `undefined` = not fetched (do not seed); `null` = no settings row yet. */
settings?: CompanySettings | null
}
export function buildReferenceFallback(
companyId: string | null,
seed: ReferenceSeed,
): Record<string, unknown> {
if (!companyId) return {}
const fallback: Record<string, unknown> = {
[unstable_serialize(refKeys.fiscalPeriods(companyId))]: seed.fiscalPeriods,
[unstable_serialize(refKeys.cashAccounts(companyId))]: seed.cashAccounts,
}
if (seed.settings !== undefined) {
fallback[unstable_serialize(refKeys.companySettings(companyId))] = seed.settings
}
return fallback
}