Files
accounted/lib/reference-data/fetchers.ts
T
47fe193c48 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>
2026-08-26 14:14:54 +02:00

110 lines
3.8 KiB
TypeScript

/**
* 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)
}