Files
accounted/scripts/checks/raw-reference-fetch.mjs
T
Jakob Wennberg 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

137 lines
5.3 KiB
JavaScript

#!/usr/bin/env node
/**
* Guard: reference data fetched outside lib/reference-data.
*
* Fiscal periods, company settings, the chart of accounts, cash accounts,
* dimensions, booking templates, customers, suppliers and articles are
* session-cached behind the hooks in lib/reference-data/hooks.ts (seeded
* from the dashboard layout, invalidated after writes). Before that layer
* existed the same lists were fetched raw from 47 / 27 / 14 / 8 / 12 / 5
* independent call sites, uncached, on every mount and every dialog open,
* which is what a customer described as "it takes time before all fields
* load when clicking around" (2026-08-26). This check keeps that number
* going down: an existing raw call site is grandfathered in the baseline,
* a NEW one fails CI.
*
* Two shapes are flagged:
* 1. A GET-shaped `fetch('/api/<reference path>')` anywhere under app/,
* components/, extensions/ or lib/ (a relative URL is client code by
* definition). Writes (`method: 'POST' | 'PUT' | ...`) are fine: they
* go through the API and then call invalidateReferenceData().
* 2. A browser-side `.from('<reference table>').select(` in a file that
* carries the 'use client' directive. Server code reading those tables
* is legitimate and is not scanned.
*
* Sanctioned (RAW_REFERENCE_SANCTIONED): the fetchers themselves, the
* pre-existing SWR settings hook, and the static BAS catalog loader (a
* different, module-cached data set).
*/
import fs from 'node:fs'
import path from 'node:path'
export const REFERENCE_API_PATHS = [
'bookkeeping/fiscal-periods',
'settings/booking-templates',
'settings',
'bookkeeping/accounts',
'cash-accounts',
'dimensions',
'customers',
'suppliers',
'articles',
]
export const REFERENCE_TABLES = [
'fiscal_periods',
'company_settings',
'chart_of_accounts',
'cash_accounts',
]
export const RAW_REFERENCE_SANCTIONED = new Set([
'lib/reference-data/fetchers.ts',
'components/settings/useSettings.ts',
'lib/bookkeeping/bas-catalog-client.ts',
])
const SCAN_DIRS = ['app/(dashboard)', 'components', 'extensions', 'lib']
const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage', '__tests__'])
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
// fetch(`/api/settings`), fetch('/api/settings?x=1'), fetch('/api/settings', { signal })
// The init object may nest one level ({ headers: { ... } }); its text is
// captured so a write method can be excluded. Trailing commas (prettier's
// multi-line call style) are tolerated before the closing paren. Every
// optional whitespace run is anchored by a literal (`,` or `)`) so the
// pattern has no ambiguous backtracking (CodeQL js/redos on the earlier
// `\s*,?\s*\)` shape).
const REFERENCE_URL = String.raw`[\x60'"]/api/(?:${REFERENCE_API_PATHS.map(escape).join('|')})(?:\?[^\x60'"]*)?[\x60'"]`
const INIT_OBJECT = String.raw`\{(?:[^{}]|\{[^{}]*\})*\}`
const API_FETCH_RE = new RegExp(
String.raw`fetch\(\s*${REFERENCE_URL}(?:\s*,\s*(${INIT_OBJECT}))?(?:\s*,)?\s*\)`,
'g',
)
const WRITE_METHOD_RE = /method\s*:\s*[\x60'"](?!GET\b)/i
const TABLE_SELECT_RE = new RegExp(
String.raw`\.from\(\s*['"](?:${REFERENCE_TABLES.map(escape).join('|')})['"]\s*\)\s*\.\s*select\(`,
'g',
)
// Leading whitespace and comments before the directive. A block comment body
// is `(?:[^*]|\*(?!\/))*`, which cannot cross a `*/`, so each iteration of the
// outer star has exactly one parse: the lazy `[\s\S]*?` form let an unclosed
// `/*` be re-split at every later `/*` (CodeQL js/redos).
// Single-character whitespace alternative (not \s+): a `+` inside the outer
// `*` is a nested quantifier on the same character (CodeQL js/redos).
const USE_CLIENT_RE = /^(?:\s|\/\/[^\n]*\n|\/\*(?:[^*]|\*(?!\/))*\*\/)*['"]use client['"]/
export function isClientSource(source) {
return USE_CLIENT_RE.test(source)
}
/**
* Findings for one file's source: `{ kind: 'api' | 'table', index }`.
* Exported for the unit test; the file-level scan below uses it.
*/
export function findRawReferenceFetchesInSource(source) {
const findings = []
for (const match of source.matchAll(API_FETCH_RE)) {
const init = match[1]
if (init && WRITE_METHOD_RE.test(init)) continue
findings.push({ kind: 'api', index: match.index ?? 0 })
}
if (isClientSource(source)) {
for (const match of source.matchAll(TABLE_SELECT_RE)) {
findings.push({ kind: 'table', index: match.index ?? 0 })
}
}
return findings
}
function walk(dir, out) {
if (!fs.existsSync(dir)) return
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (IGNORE_DIRS.has(entry.name)) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full, out)
else if (/\.(?:ts|tsx)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) out.push(full)
}
}
/** Sorted repo-relative paths of files with at least one raw reference fetch. */
export function findRawReferenceFetches(root) {
const files = []
for (const dir of SCAN_DIRS) walk(path.join(root, dir), files)
const offenders = []
for (const file of files) {
const rel = path.relative(root, file).split(path.sep).join('/')
if (rel.startsWith('app/api/') || RAW_REFERENCE_SANCTIONED.has(rel)) continue
const source = fs.readFileSync(file, 'utf8')
if (findRawReferenceFetchesInSource(source).length) offenders.push(rel)
}
return [...new Set(offenders)].sort()
}