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:
@@ -1254,6 +1254,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-26] No ratchet on direct requireAuth() calls in app/api: requireAuth() is the MFA (AAL2) guard withRouteContext itself calls, and .claude/rules/api-routes.md sanctions it for routes without a company context (onboarding, account, user prefs). The 20 remaining direct callers skip request ids and the canonical envelope, not MFA; migrating them is a consistency campaign, not a security fix, so it was not folded into the bypass PR.
|
||||
[2026-08-26] defer_invoice_booking (#967) now gates booking on every door, not just the dashboard: MCP send_invoice / mark_invoice_sent / create_supplier_invoice_from_inbox, v1 invoices send / mark-sent and supplier-invoices create, and the inbox convert route all checked accounting_method === 'accrual' and posted a verifikat at issue for deferred companies. All six now call booksInvoicesOnIssue() (lib/bookkeeping/booking-mode.ts), the same helper the dashboard routes use, so the setting has one meaning. No data repair attempted: vouchers already posted for deferred companies through these doors are legitimate entries and stay.
|
||||
[2026-08-20] The swedish-e-invoicing skill now names Upphandlingsmyndigheten as Sweden Peppol Authority across all eight files, not just the one that was flagged: the handover completed 1 July 2026 (regeringsbeslut Fi2025/01826) and the skill was written in future tense, so a partial fix would have left the atom internally contradictory and still pointed agents at peppol@digg.se. Four digg.se URLs were repointed to their verified 301 targets on upphandlingsmyndigheten.se; the fifth, DIGG Peppol testbadd, is a hard 404 with no redirect and no successor page at the new authority, so it was replaced with the SFTI Validex verification service (https://sfti.validex.net/) rather than left dead or guessed at. Historical attributions (Q4 2025 traffic statistics, the 0007:2021006883 Peppol-ID example) deliberately still say DIGG because they were accurate when published.
|
||||
[2026-08-26] Reference data (fiscal periods, cash accounts, settings, accounts, dimensions, templates, customers, suppliers, articles) moves behind SWR hooks in lib/reference-data with company-scoped keys, a server seed from the dashboard layout (periods, cash accounts, settings only; the chart of accounts can be hundreds of KB and is warmed lazily instead), explicit invalidateReferenceData() after writes, and a raw-reference-fetch ratchet in check:guards. Chosen over Cache-Control on the API routes (a browser HTTP cache would keep serving stale bodies after a mutate) and over Next 16 cacheComponents/partialPrefetching (38 of 81 dashboard pages are client components whose data lives in client fetches, so an app shell prefetch cannot carry it). Expected side effect: period.list and settings.get volume in the op-completed logs drops toward zero because those reads become browser-side Supabase selects; that is by design, not a broken route.
|
||||
[2026-08-26] AGENTS.md now defers to CLAUDE.md for every shared rule instead of duplicating it: the copy had drifted within weeks (no inline-rättelse path, cookie-first tenancy order, 100+ MCP tools). The four Codex-only constraints Emil added 2026-07-21 (erp-base staging-only migrations, prod writes and main pushes need his explicit approval, no local Docker) were kept in a labelled section rather than removed, because the erp-base Supabase project exists (ref pwxtzglxptnnvjrpixpg) and they describe his environment, not stale product facts; only the project-name spelling was corrected from erpbase.
|
||||
[2026-08-26] MCP tool counts in docs say "150+" (connect-claude, READMEs, rules, registry entry) instead of deriving the number from the tools array: lib/docs/content/connect-claude.ts is core code and core must never import from @/extensions/ (CI builds core with zero extensions), and an exact hand-written number (90+, 100+, 120) had drifted three times already. Regenerate by counting `name: 'gnubok_` in server.ts when the order of magnitude changes.
|
||||
[2026-08-26] API-key scope pickers (settings panel + OAuth consent) render from one SCOPE_GROUPS list in lib/auth/scope-catalog.ts, with MCP tool counts derived from TOOL_SCOPE_MAP at module load: the panel's hand-copied group list had drifted to 24 of 30 scopes (no articles, companies:write or reconciliation, so dashboard-minted keys could not call those tools) and every per-scope count was stale. The catalogue is a separate pure module rather than api-keys.ts itself because the panel is a client component and api-keys.ts imports crypto and the service-role client; a unit test enforces one-group-per-scope so the next scope cannot silently vanish from the pickers.
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
||||
import TrialExpiredDialog from '@/components/billing/TrialExpiredDialog'
|
||||
import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
||||
import { CompanyProvider } from '@/contexts/CompanyContext'
|
||||
import { ReferenceDataSeed } from '@/components/providers/ReferenceDataSeed'
|
||||
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import type { AccountingFramework, EntityType, CompanyRole, Team } from '@/types'
|
||||
@@ -148,7 +149,7 @@ export default async function DashboardLayout({
|
||||
{ data: companyRow },
|
||||
{ data: memberRow },
|
||||
{ data: allMemberships },
|
||||
{ data: settings },
|
||||
{ data: settings, error: settingsError },
|
||||
agentProfileIdentity,
|
||||
{ data: userProfile },
|
||||
entitlements,
|
||||
@@ -156,6 +157,8 @@ export default async function DashboardLayout({
|
||||
{ data: userPrefs },
|
||||
hasWebshop,
|
||||
hasMileageTrips,
|
||||
{ data: seedFiscalPeriods },
|
||||
{ data: seedCashAccounts },
|
||||
] = await Promise.all([
|
||||
supabase.from('companies').select('*').eq('id', companyId).single(),
|
||||
supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(),
|
||||
@@ -209,6 +212,23 @@ export default async function DashboardLayout({
|
||||
.eq('company_id', companyId)
|
||||
.limit(1)
|
||||
.then((trips) => (trips.data?.length ?? 0) > 0),
|
||||
// Reference-data seed (lib/reference-data/seed.ts): the two small lists
|
||||
// that gate almost every form, fetched once here so the first picker a
|
||||
// user opens renders populated with zero client round trips. Same
|
||||
// ordering as period.list and listForCompany so the seed and the client
|
||||
// refetch agree. The chart of accounts is deliberately NOT seeded: it
|
||||
// can be hundreds of KB for large companies.
|
||||
supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: false }),
|
||||
supabase
|
||||
.from('cash_accounts')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.order('is_primary', { ascending: false })
|
||||
.order('ledger_account', { ascending: true }),
|
||||
])
|
||||
|
||||
// company_id -> current display name for every company the user belongs to.
|
||||
@@ -325,6 +345,12 @@ export default async function DashboardLayout({
|
||||
|
||||
return (
|
||||
<CompanyProvider value={companyContextValue}>
|
||||
<ReferenceDataSeed
|
||||
companyId={companyId}
|
||||
fiscalPeriods={seedFiscalPeriods ?? []}
|
||||
cashAccounts={seedCashAccounts ?? []}
|
||||
settings={settingsError ? undefined : settings}
|
||||
>
|
||||
<SessionTimeoutController />
|
||||
<AgentSheetProvider
|
||||
identity={{
|
||||
@@ -406,6 +432,7 @@ export default async function DashboardLayout({
|
||||
/>
|
||||
)}
|
||||
</AgentSheetProvider>
|
||||
</ReferenceDataSeed>
|
||||
</CompanyProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,9 +35,13 @@ export const getDashboardSettings = cache(async () => {
|
||||
])
|
||||
if (!companyId) return { data: null, error: null }
|
||||
|
||||
// Full row: the layout hands it to the client reference-data cache as the
|
||||
// seed for useCompanySettings (which reads select('*') itself), so the
|
||||
// narrow column list this once carried would have been refetched on the
|
||||
// first mount anyway. The other consumers read a subset of the row.
|
||||
return supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled, mileage_enabled, ore_rounding, initial_setup_path, initial_setup_completed_at, initial_setup_dismissed_at, vat_registered, moms_period')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, type ReactNode } from 'react'
|
||||
import { SWRConfig } from 'swr'
|
||||
import { buildReferenceFallback, type ReferenceSeed } from '@/lib/reference-data/seed'
|
||||
|
||||
/**
|
||||
* Nested SWR config carrying the server-fetched reference data as
|
||||
* `fallback`. Mounted by app/(dashboard)/layout.tsx inside CompanyProvider;
|
||||
* SWR merges this with the root SWRProvider so the shared cache, fetcher
|
||||
* and dedupe stay global while the seed is per company.
|
||||
*/
|
||||
export function ReferenceDataSeed({
|
||||
companyId,
|
||||
fiscalPeriods,
|
||||
cashAccounts,
|
||||
settings,
|
||||
children,
|
||||
}: ReferenceSeed & { companyId: string | null; children: ReactNode }) {
|
||||
const fallback = useMemo(
|
||||
() => buildReferenceFallback(companyId, { fiscalPeriods, cashAccounts, settings }),
|
||||
[companyId, fiscalPeriods, cashAccounts, settings],
|
||||
)
|
||||
return <SWRConfig value={{ fallback }}>{children}</SWRConfig>
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Proof that the raw-reference-fetch ratchet catches the thing and leaves the
|
||||
* legitimate shapes alone. Offending fixtures live only in these strings and
|
||||
* in an OS temp directory the end-to-end case creates and deletes.
|
||||
*/
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
findRawReferenceFetches,
|
||||
findRawReferenceFetchesInSource as scan,
|
||||
isClientSource,
|
||||
} from '../raw-reference-fetch.mjs'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
afterAll(() => {
|
||||
for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const kinds = (source: string) => scan(source).map((f: { kind: string }) => f.kind)
|
||||
|
||||
describe('raw-reference-fetch: GET-shaped API calls', () => {
|
||||
it('flags a bare GET of each reference path, with or without a query string', () => {
|
||||
expect(kinds(`const res = await fetch('/api/settings')`)).toEqual(['api'])
|
||||
expect(kinds('fetch(`/api/bookkeeping/fiscal-periods`)')).toEqual(['api'])
|
||||
expect(kinds(`fetch('/api/bookkeeping/accounts?active=false')`)).toEqual(['api'])
|
||||
expect(kinds(`fetch('/api/settings/booking-templates', { signal })`)).toEqual(['api'])
|
||||
expect(kinds(`fetch('/api/cash-accounts?enabled_only=true', { headers: { a: '1' } })`)).toEqual(['api'])
|
||||
expect(kinds(`fetch(\n '/api/customers',\n { signal: controller.signal },\n)`)).toEqual(['api'])
|
||||
})
|
||||
|
||||
it('ignores writes: they go through the API and then invalidate the cache', () => {
|
||||
expect(kinds(`fetch('/api/settings', { method: 'PUT', body })`)).toEqual([])
|
||||
expect(kinds(`fetch('/api/customers', { method: 'POST', headers: { 'content-type': 'x' }, body })`)).toEqual([])
|
||||
expect(kinds("fetch(`/api/settings/booking-templates/${id}/touch`, { method: 'POST' })")).toEqual([])
|
||||
expect(kinds(`fetch('/api/articles/' + id, { method: 'DELETE' })`)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not confuse sub-resources or lookalike paths with the reference lists', () => {
|
||||
expect(kinds("fetch(`/api/settings/booking-templates/${id}`)")).toEqual([])
|
||||
expect(kinds(`fetch('/api/settings/banking')`)).toEqual([])
|
||||
expect(kinds(`fetch('/api/customers/abc')`)).toEqual([])
|
||||
expect(kinds(`fetch('/api/bookkeeping/accounts/bas-catalog')`)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('raw-reference-fetch: regex safety', () => {
|
||||
it('scans a pathological near-miss in linear time (no catastrophic backtracking)', () => {
|
||||
// A fetch call that never closes, padded with the whitespace/comma mix
|
||||
// the old `\s*,?\s*\)` tail was ambiguous on.
|
||||
const source = `fetch('/api/settings'${' ,'.repeat(5000)}${' '.repeat(5000)}X`
|
||||
const start = performance.now()
|
||||
expect(scan(source)).toEqual([])
|
||||
expect(performance.now() - start).toBeLessThan(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('raw-reference-fetch: use-client detection is linear too', () => {
|
||||
it('handles a long run of unclosed block comments without backtracking', () => {
|
||||
const source = `${'/* '.repeat(3000)}'use client'\n`
|
||||
const start = performance.now()
|
||||
expect(isClientSource(source)).toBe(false)
|
||||
expect(performance.now() - start).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('still sees the directive behind closed comments', () => {
|
||||
expect(isClientSource(`/* a */ /* b */\n// c\n'use client'\n`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('raw-reference-fetch: browser-side table reads', () => {
|
||||
const clientRead = `'use client'\nimport x from 'y'\nconst { data } = await supabase.from('fiscal_periods').select('*').eq('company_id', id)`
|
||||
|
||||
it('flags a select on a reference table only in a use-client file', () => {
|
||||
expect(kinds(clientRead)).toEqual(['table'])
|
||||
expect(kinds(clientRead.replace(`'use client'\n`, ''))).toEqual([])
|
||||
})
|
||||
|
||||
it('recognises the directive behind leading comments and double quotes', () => {
|
||||
expect(isClientSource(`// header\n/* block */\n"use client"\n`)).toBe(true)
|
||||
expect(isClientSource(`import a from 'b'\n'use client'`)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves inserts and updates alone', () => {
|
||||
expect(kinds(`'use client'\nawait supabase.from('fiscal_periods').insert(row)`)).toEqual([])
|
||||
expect(kinds(`'use client'\nawait supabase.from('company_settings').update(patch).eq('company_id', id)`)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('raw-reference-fetch: file scan', () => {
|
||||
it('reports offending files relative to the root and skips sanctioned, api and test files', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'raw-ref-'))
|
||||
tempDirs.push(root)
|
||||
const write = (rel: string, content: string) => {
|
||||
const full = path.join(root, rel)
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true })
|
||||
fs.writeFileSync(full, content)
|
||||
}
|
||||
write('components/Bad.tsx', `'use client'\nfetch('/api/settings')`)
|
||||
write('components/Fine.tsx', `'use client'\nfetch('/api/settings', { method: 'PUT' })`)
|
||||
write('components/__tests__/Bad.test.tsx', `fetch('/api/settings')`)
|
||||
write('components/Bad.test.ts', `fetch('/api/settings')`)
|
||||
write('lib/reference-data/fetchers.ts', `fetch('/api/settings')`)
|
||||
write('app/api/x/route.ts', `fetch('/api/settings')`)
|
||||
write('app/(dashboard)/reports/page.tsx', `'use client'\nconst r = await fetch('/api/bookkeeping/fiscal-periods')`)
|
||||
write('lib/server-thing.ts', `await supabase.from('company_settings').select('*')`)
|
||||
|
||||
expect(findRawReferenceFetches(root)).toEqual([
|
||||
'app/(dashboard)/reports/page.tsx',
|
||||
'components/Bad.tsx',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -32,5 +32,65 @@
|
||||
"components/salary/NewEmployeeDialog.tsx",
|
||||
"components/transactions/InvoiceMatchDialog.tsx"
|
||||
]
|
||||
},
|
||||
"rawReferenceFetch": {
|
||||
"count": 55,
|
||||
"files": [
|
||||
"app/(dashboard)/assets/[id]/dispose/page.tsx",
|
||||
"app/(dashboard)/bookkeeping/year-end/page.tsx",
|
||||
"app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx",
|
||||
"app/(dashboard)/customers/page.tsx",
|
||||
"app/(dashboard)/import/page.tsx",
|
||||
"app/(dashboard)/invoices/[id]/page.tsx",
|
||||
"app/(dashboard)/invoices/page.tsx",
|
||||
"app/(dashboard)/pending/page.tsx",
|
||||
"app/(dashboard)/salary/employees/[id]/page.tsx",
|
||||
"app/(dashboard)/salary/runs/[id]/page.tsx",
|
||||
"app/(dashboard)/supplier-invoices/[id]/page.tsx",
|
||||
"app/(dashboard)/transactions/page.tsx",
|
||||
"components/articles/ArticleForm.tsx",
|
||||
"components/bookkeeping/BookingTemplatePicker.tsx",
|
||||
"components/bookkeeping/ChartOfAccounts.tsx",
|
||||
"components/bookkeeping/ChartOfAccountsManager.tsx",
|
||||
"components/bookkeeping/CorrectionEntryDialog.tsx",
|
||||
"components/bookkeeping/EditAccountDialog.tsx",
|
||||
"components/bookkeeping/JournalEntryForm.tsx",
|
||||
"components/bookkeeping/JournalEntryList.tsx",
|
||||
"components/bookkeeping/StrikeLinesDialog.tsx",
|
||||
"components/bookkeeping/TemplateBookDialog.tsx",
|
||||
"components/common/CashAccountSelector.tsx",
|
||||
"components/common/FiscalYearSelector.tsx",
|
||||
"components/common/FyPicker.tsx",
|
||||
"components/dimensions/types.ts",
|
||||
"components/extensions/general/ArcimMigrationWorkspace.tsx",
|
||||
"components/extensions/general/BookDirectlyDialog.tsx",
|
||||
"components/extensions/general/InvoiceInboxWorkspace.tsx",
|
||||
"components/extensions/general/TicWorkspace.tsx",
|
||||
"components/import/BankFileConfirmStep.tsx",
|
||||
"components/import/FiscalYearGapNotice.tsx",
|
||||
"components/import/ImportReviewStep.tsx",
|
||||
"components/import/OpeningBalancePeriodStep.tsx",
|
||||
"components/invoices/InvoiceEditor.tsx",
|
||||
"components/invoices/PaymentBookingDialog.tsx",
|
||||
"components/invoices/SendInvoiceDialog.tsx",
|
||||
"components/pending-operations/use-account-names.ts",
|
||||
"components/reports/SkatteverketPanel.tsx",
|
||||
"components/reports/views/index.tsx",
|
||||
"components/salary/NewEmployeeDialog.tsx",
|
||||
"components/settings/BookingTemplatesPanel.tsx",
|
||||
"components/settings/FiscalPeriodEditor.tsx",
|
||||
"components/settings/FiscalYearsManager.tsx",
|
||||
"components/settings/InvoicePaymentAccountsSettings.tsx",
|
||||
"components/supplier-invoices/use-supplier-invoice-data.ts",
|
||||
"components/suppliers/SupplierForm.tsx",
|
||||
"components/transactions/BulkBookDialog.tsx",
|
||||
"components/transactions/DuplicateBookingDialog.tsx",
|
||||
"components/transactions/InvoiceMatchDialog.tsx",
|
||||
"components/transactions/MatchVoucherDialog.tsx",
|
||||
"components/transactions/QuickReviewDialog.tsx",
|
||||
"components/transactions/TemplatePicker.tsx",
|
||||
"components/transactions/TransactionBookingDialog.tsx",
|
||||
"extensions/general/enable-banking/components/AccountPickerDialog.tsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
import { findSekLabelledFxAmounts } from './format-currency-sek-label.mjs'
|
||||
import { findRawReferenceFetches } from './raw-reference-fetch.mjs'
|
||||
import {
|
||||
findExtensionRouteFindings,
|
||||
UNGATED_EXTENSION_ROUTES,
|
||||
@@ -1016,6 +1017,7 @@ const current = {
|
||||
foldedPublicFlags: findFoldedPublicFlags(),
|
||||
dialogOverflowRisk: findDialogOverflowRisks(),
|
||||
directAiClients: findDirectAiClients(),
|
||||
rawReferenceFetch: findRawReferenceFetches(ROOT),
|
||||
}
|
||||
|
||||
const dialogOverflowFiles = [...new Set(current.dialogOverflowRisk.map((f) => f.file))].sort()
|
||||
@@ -1037,6 +1039,10 @@ if (isUpdate) {
|
||||
count: dialogOverflowFiles.length,
|
||||
files: dialogOverflowFiles,
|
||||
},
|
||||
rawReferenceFetch: {
|
||||
count: current.rawReferenceFetch.length,
|
||||
files: current.rawReferenceFetch,
|
||||
},
|
||||
}
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n')
|
||||
console.log(
|
||||
@@ -1275,6 +1281,31 @@ if (newLedgerScans.length) {
|
||||
)
|
||||
}
|
||||
|
||||
// 1d. raw-reference-fetch: per-file ratchet. A file outside the baseline set
|
||||
// that fetches reference data raw (see raw-reference-fetch.mjs) is a NEW
|
||||
// violation; grandfathered files stay until they move to the hooks. Once the
|
||||
// baseline reaches 0, delete the entry so any new site is a hard failure.
|
||||
const rawRefBaseline = new Set(baseline.rawReferenceFetch?.files ?? [])
|
||||
const newRawRefs = current.rawReferenceFetch.filter((f) => !rawRefBaseline.has(f))
|
||||
const fixedRawRefs = (baseline.rawReferenceFetch?.files ?? []).filter(
|
||||
(f) => !current.rawReferenceFetch.includes(f),
|
||||
)
|
||||
if (newRawRefs.length) {
|
||||
failed = true
|
||||
console.error(
|
||||
`\n✗ raw-reference-fetch: ${newRawRefs.length} file(s) fetch reference data raw ` +
|
||||
`(fiscal periods, settings, accounts, cash accounts, dimensions, templates, customers, suppliers, articles):`,
|
||||
)
|
||||
newRawRefs.forEach((f) => console.error(` ${f}`))
|
||||
console.error(
|
||||
' → read it through the hooks in lib/reference-data/hooks.ts (useFiscalPeriods, useAccounts,\n' +
|
||||
' useCashAccounts, useCompanySettings, useDimensions, useBookingTemplates, useCustomers,\n' +
|
||||
' useSuppliers, useArticles) and call invalidateReferenceData() after writes. Those hooks\n' +
|
||||
' share one session cache and are seeded by the dashboard layout, so the fields render\n' +
|
||||
' on first paint instead of after another round trip.',
|
||||
)
|
||||
}
|
||||
|
||||
// 1e3. dialog-overflow-risk: per-file ratchet, a finding in a file outside
|
||||
// the baseline set is a NEW violation. Grandfathered files stay until fixed.
|
||||
const dialogOverflowBaseline = new Set(baseline.dialogOverflowRisk?.files ?? [])
|
||||
@@ -1315,6 +1346,7 @@ if (
|
||||
fixedAuthFiles.length ||
|
||||
fixedLedgerScans.length ||
|
||||
fixedDialogOverflow.length ||
|
||||
fixedRawRefs.length ||
|
||||
current.naiveOreRound < baseline.naiveOreRound.count
|
||||
) {
|
||||
console.log('\n✓ Progress since baseline:')
|
||||
@@ -1323,6 +1355,8 @@ if (
|
||||
console.log(` ledger-scanning-report: -${fixedLedgerScans.length} file(s)`)
|
||||
if (fixedDialogOverflow.length)
|
||||
console.log(` dialog-overflow-risk: -${fixedDialogOverflow.length} file(s)`)
|
||||
if (fixedRawRefs.length)
|
||||
console.log(` raw-reference-fetch: -${fixedRawRefs.length} file(s)`)
|
||||
if (current.naiveOreRound < baseline.naiveOreRound.count)
|
||||
console.log(` naive-ore-round: -${baseline.naiveOreRound.count - current.naiveOreRound} occurrence(s)`)
|
||||
console.log(' Run with --update to ratchet the baseline down and lock in the gains.')
|
||||
@@ -1347,5 +1381,5 @@ if (failed) {
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(
|
||||
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`,
|
||||
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), raw-reference-fetch: ${current.rawReferenceFetch.length} file(s), direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/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()
|
||||
}
|
||||
Reference in New Issue
Block a user