fix(reports): stop driving report queries from the unfiltered journal_entry_lines side (#971)
* fix(reports): drive report line queries from journal_entries, not the unfiltered lines side
Every report generator fetched journal_entry_lines with a
journal_entries!inner(...) embed and put the tenant filter on the
embedded side (.eq('journal_entries.company_id', ...)). PostgREST
compiles that to a correlated INNER JOIN LATERAL with a parameterized
LIMIT inside, which blocks join reordering: Postgres walked the ENTIRE
journal_entry_lines table (603k rows, all tenants) per report query.
Measured in production: 13.6 s vs 2.7 ms for the equivalent plain join,
against Supabase's 8 s statement_timeout; nightly cloud backups failed
for 5 of 11 companies on 2026-07-09 and a GL report 500'd.
Introduce lib/bookkeeping/entry-lines.ts with a shared two-step fetch:
1. fetch matching journal_entries (id + caller-selected columns)
filtered by company_id / fiscal_period_id / status / entry_date /
source_type, paginated via fetchAllRows;
2. fetch journal_entry_lines with .in('journal_entry_id', chunk) in
chunks of 100 ids (URL-length safety), paginated per chunk;
3. reattach the parent entry to each line under the embed's key shape
(line.journal_entries = {...}, aliasable) and sort lines by id
ascending to preserve the old .order('id') semantics.
Converted call sites (selected columns and filters preserved):
trial-balance (x2), general-ledger, journal-register, sie-export
(reuses its existing entry list via fetchLinesByEntryIds),
vat-declaration, dimension-pnl, opening-balances, monthly-breakdown,
periodisk-sammanstallning, rc-basis-gaps (sibling-line fetch now also
chunked), ar-reconciliation, supplier-reconciliation,
bank-reconciliation, asset-service (x2), bolagsskatt-calculator,
sarskild-loneskatt-calculator.
Tests: unit tests for the helper (chunk size, reattachment shape,
forced id/journal_entry_id columns, empty result, cross-chunk sort,
error propagation); existing report/reconciliation/bokslut test mocks
updated to the two-step query shape, preserving every assertion about
report output.
From the 2026-07-09 production log triage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reports): stop echoing raw error messages from the general-ledger route
The catch handler returned err.message to the client in
details.reason; internal error strings (SQL fragments, table names,
timeout messages) must not reach the browser. The error is already
logged server-side with the request id, so the client envelope keeps
only the REPORT_GENERATION_FAILED code.
From the 2026-07-09 production log triage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
da859d7236
commit
a8801430f4
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
fetchEntryLines,
|
||||
fetchLinesByEntryIds,
|
||||
type EntryLinesQuery,
|
||||
} from '../entry-lines'
|
||||
|
||||
/**
|
||||
* Recording variant of the queued Supabase mock: every `.from()` starts a
|
||||
* chain that consumes the next queued result AND records each chained method
|
||||
* call (name + args), so tests can assert the query shape the helper builds
|
||||
* (chunk sizes for `.in()`, forced columns in `.select()`, paging order).
|
||||
*/
|
||||
function createRecordingSupabase() {
|
||||
const queue: { data: unknown; error: unknown }[] = []
|
||||
const chains: { method: string; args: unknown[] }[][] = []
|
||||
|
||||
const enqueue = (result: { data?: unknown; error?: unknown }) => {
|
||||
queue.push({ data: result.data ?? null, error: result.error ?? null })
|
||||
}
|
||||
|
||||
const buildChain = (
|
||||
result: { data: unknown; error: unknown },
|
||||
chainCalls: { method: string; args: unknown[] }[],
|
||||
): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve(result)
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
chainCalls.push({ method: String(prop), args })
|
||||
return buildChain(result, chainCalls)
|
||||
}
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation((table: string) => {
|
||||
const chainCalls: { method: string; args: unknown[] }[] = [
|
||||
{ method: 'from', args: [table] },
|
||||
]
|
||||
chains.push(chainCalls)
|
||||
const result = queue.shift() || { data: null, error: null }
|
||||
return buildChain(result, chainCalls)
|
||||
}),
|
||||
}
|
||||
|
||||
const callOf = (chain: { method: string; args: unknown[] }[], method: string) =>
|
||||
chain.find((c) => c.method === method)
|
||||
|
||||
return { supabase, enqueue, chains, callOf }
|
||||
}
|
||||
|
||||
function makeEntries(count: number): { id: string; entry_date: string }[] {
|
||||
// Zero-padded ids keep lexicographic order == numeric order.
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `entry-${String(i).padStart(4, '0')}`,
|
||||
entry_date: '2024-06-15',
|
||||
}))
|
||||
}
|
||||
|
||||
describe('fetchEntryLines', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns [] without querying lines when no entries match', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [] }) // journal_entries page
|
||||
|
||||
const lines = await fetchEntryLines({
|
||||
supabase: supabase as unknown as SupabaseClient,
|
||||
lineColumns: 'id, account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) => q.eq('company_id', 'company-1'),
|
||||
})
|
||||
|
||||
expect(lines).toEqual([])
|
||||
// Only the entries query ran: no journal_entry_lines round-trip.
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
expect(supabase.from).toHaveBeenCalledWith('journal_entries')
|
||||
})
|
||||
|
||||
it('chunks line fetches at 100 entry ids per .in() query', async () => {
|
||||
const { supabase, enqueue, chains, callOf } = createRecordingSupabase()
|
||||
const entries = makeEntries(250)
|
||||
enqueue({ data: entries }) // journal_entries page
|
||||
enqueue({ data: [{ id: 'line-1', journal_entry_id: entries[0].id }] }) // chunk 1
|
||||
enqueue({ data: [] }) // chunk 2
|
||||
enqueue({ data: [] }) // chunk 3
|
||||
|
||||
const lines = await fetchEntryLines<{ id: string; journal_entry_id: string }>({
|
||||
supabase: supabase as unknown as SupabaseClient,
|
||||
lineColumns: 'id, account_number',
|
||||
filterEntries: (q: EntryLinesQuery) => q.eq('company_id', 'company-1'),
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(1)
|
||||
// 1 entries query + 3 line chunks (100 + 100 + 50).
|
||||
expect(chains).toHaveLength(4)
|
||||
expect(chains[0][0]).toEqual({ method: 'from', args: ['journal_entries'] })
|
||||
const chunkSizes = chains.slice(1).map((chain) => {
|
||||
expect(chain[0]).toEqual({ method: 'from', args: ['journal_entry_lines'] })
|
||||
const inCall = callOf(chain, 'in')
|
||||
expect(inCall?.args[0]).toBe('journal_entry_id')
|
||||
return (inCall?.args[1] as string[]).length
|
||||
})
|
||||
expect(chunkSizes).toEqual([100, 100, 50])
|
||||
})
|
||||
|
||||
it('reattaches the parent entry under journal_entries by default', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'entry-1', entry_date: '2024-01-15', voucher_number: 1 },
|
||||
{ id: 'entry-2', entry_date: '2024-02-15', voucher_number: 2 },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'line-1', journal_entry_id: 'entry-1', account_number: '1930' },
|
||||
{ id: 'line-2', journal_entry_id: 'entry-2', account_number: '3001' },
|
||||
],
|
||||
})
|
||||
|
||||
const lines = await fetchEntryLines<{
|
||||
id: string
|
||||
journal_entry_id: string
|
||||
account_number: string
|
||||
journal_entries: { id: string; entry_date: string; voucher_number: number }
|
||||
}>({
|
||||
supabase: supabase as unknown as SupabaseClient,
|
||||
entryColumns: 'id, entry_date, voucher_number',
|
||||
lineColumns: 'id, account_number',
|
||||
filterEntries: (q: EntryLinesQuery) => q.eq('company_id', 'company-1'),
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(lines[0].journal_entries).toEqual({
|
||||
id: 'entry-1',
|
||||
entry_date: '2024-01-15',
|
||||
voucher_number: 1,
|
||||
})
|
||||
expect(lines[1].journal_entries.voucher_number).toBe(2)
|
||||
})
|
||||
|
||||
it('reattaches under a custom key for aliased embeds', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'entry-1', entry_date: '2024-01-15' }] })
|
||||
enqueue({ data: [{ id: 'line-1', journal_entry_id: 'entry-1' }] })
|
||||
|
||||
const lines = await fetchEntryLines<{
|
||||
id: string
|
||||
journal_entry: { id: string; entry_date: string }
|
||||
}>({
|
||||
supabase: supabase as unknown as SupabaseClient,
|
||||
entryColumns: 'id, entry_date',
|
||||
lineColumns: 'id',
|
||||
filterEntries: (q: EntryLinesQuery) => q,
|
||||
attachEntriesAs: 'journal_entry',
|
||||
})
|
||||
|
||||
expect(lines[0].journal_entry).toEqual({ id: 'entry-1', entry_date: '2024-01-15' })
|
||||
expect((lines[0] as Record<string, unknown>).journal_entries).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips reattachment when attachEntriesAs is null', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'entry-1' }] })
|
||||
enqueue({ data: [{ id: 'line-1', journal_entry_id: 'entry-1' }] })
|
||||
|
||||
const lines = await fetchEntryLines<Record<string, unknown>>({
|
||||
supabase: supabase as unknown as SupabaseClient,
|
||||
lineColumns: 'id',
|
||||
filterEntries: (q: EntryLinesQuery) => q,
|
||||
attachEntriesAs: null,
|
||||
})
|
||||
|
||||
expect(lines[0].journal_entries).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies filterLines to every chunk query', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: makeEntries(150) })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
|
||||
const filterLines = vi.fn((q: EntryLinesQuery) => q)
|
||||
await fetchEntryLines({
|
||||
supabase: supabase as unknown as SupabaseClient,
|
||||
lineColumns: 'id',
|
||||
filterEntries: (q: EntryLinesQuery) => q,
|
||||
filterLines,
|
||||
})
|
||||
|
||||
// 150 entries -> 2 chunks -> filterLines once per chunk.
|
||||
expect(filterLines).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('forces id and journal_entry_id into the selects', async () => {
|
||||
const { supabase, enqueue, chains, callOf } = createRecordingSupabase()
|
||||
enqueue({ data: [{ id: 'entry-1', entry_date: '2024-01-15' }] })
|
||||
enqueue({ data: [] })
|
||||
|
||||
await fetchEntryLines({
|
||||
supabase: supabase as unknown as SupabaseClient,
|
||||
// Neither list names the forced columns.
|
||||
entryColumns: 'entry_date',
|
||||
lineColumns: 'account_number',
|
||||
filterEntries: (q: EntryLinesQuery) => q,
|
||||
})
|
||||
|
||||
const entrySelect = callOf(chains[0], 'select')?.args[0] as string
|
||||
expect(entrySelect.split(',').map((s) => s.trim())).toContain('id')
|
||||
const lineSelect = callOf(chains[1], 'select')?.args[0] as string
|
||||
const lineCols = lineSelect.split(',').map((s) => s.trim())
|
||||
expect(lineCols).toContain('id')
|
||||
expect(lineCols).toContain('journal_entry_id')
|
||||
// Both queries page on a stable unique order (fetch-all.ts invariant).
|
||||
expect(callOf(chains[0], 'order')?.args[0]).toBe('id')
|
||||
expect(callOf(chains[1], 'order')?.args[0]).toBe('id')
|
||||
})
|
||||
|
||||
it('propagates entry query errors', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
|
||||
await expect(
|
||||
fetchEntryLines({
|
||||
supabase: supabase as unknown as SupabaseClient,
|
||||
lineColumns: 'id',
|
||||
filterEntries: (q: EntryLinesQuery) => q,
|
||||
}),
|
||||
).rejects.toThrow('boom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchLinesByEntryIds', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns [] for an empty id list without querying', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
const lines = await fetchLinesByEntryIds(
|
||||
supabase as unknown as SupabaseClient,
|
||||
[],
|
||||
'id, account_number',
|
||||
)
|
||||
|
||||
expect(lines).toEqual([])
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sorts lines by id ascending across chunks', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const entryIds = makeEntries(150).map((e) => e.id)
|
||||
// Chunk results arrive unsorted relative to each other; ids are
|
||||
// zero-padded so lexicographic order is deterministic.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'line-0300', journal_entry_id: entryIds[0] },
|
||||
{ id: 'line-0100', journal_entry_id: entryIds[1] },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'line-0200', journal_entry_id: entryIds[100] },
|
||||
],
|
||||
})
|
||||
|
||||
const lines = await fetchLinesByEntryIds<{ id: string }>(
|
||||
supabase as unknown as SupabaseClient,
|
||||
entryIds,
|
||||
'id',
|
||||
)
|
||||
|
||||
expect(lines.map((l) => l.id)).toEqual(['line-0100', 'line-0200', 'line-0300'])
|
||||
})
|
||||
|
||||
it('propagates line query errors', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: { message: 'lines boom' } })
|
||||
|
||||
await expect(
|
||||
fetchLinesByEntryIds(supabase as unknown as SupabaseClient, ['entry-1'], 'id'),
|
||||
).rejects.toThrow('lines boom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
/**
|
||||
* Two-step fetch for journal_entry_lines scoped by journal_entries filters.
|
||||
*
|
||||
* WHY THIS EXISTS: the previous pattern selected from journal_entry_lines
|
||||
* with a `journal_entries!inner(...)` embed and put every scope filter on
|
||||
* the embedded side (`.eq('journal_entries.company_id', ...)`). PostgREST
|
||||
* compiles that embed to a correlated INNER JOIN LATERAL with a
|
||||
* parameterized LIMIT inside, which blocks Postgres from reordering the
|
||||
* join: every report query walked the ENTIRE journal_entry_lines table
|
||||
* (all tenants) instead of starting from the handful of matching entries.
|
||||
* Measured in production: 13.6 s vs 2.7 ms for the equivalent plain join,
|
||||
* against Supabase's 8 s statement_timeout, which 500'd reports and made
|
||||
* nightly backups fail.
|
||||
*
|
||||
* The fix drives the query from the journal_entries side instead:
|
||||
* 1. fetch the matching journal_entries (id + whatever entry columns the
|
||||
* caller needs), paginated via fetchAllRows;
|
||||
* 2. fetch journal_entry_lines with `.in('journal_entry_id', chunk)` in
|
||||
* chunks of {@link ENTRY_ID_CHUNK_SIZE} ids (URL-length safety),
|
||||
* paginated per chunk;
|
||||
* 3. reattach the parent entry object to each line under the same key the
|
||||
* embed produced (`line.journal_entries = {...}` by default), so call
|
||||
* sites keep their downstream code unchanged, and sort all lines by id
|
||||
* ascending to preserve the old `.order('id')` semantics.
|
||||
*/
|
||||
|
||||
/** Max journal_entry ids per `.in()` filter: keeps the request URL short. */
|
||||
const ENTRY_ID_CHUNK_SIZE = 100
|
||||
|
||||
/** How many line chunks are fetched in parallel. */
|
||||
const CHUNK_BATCH_SIZE = 5
|
||||
|
||||
/**
|
||||
* PostgREST query builders carry deep generic types that do not survive
|
||||
* being passed through a callback; the helper only needs "builder in,
|
||||
* builder out", so the filter callbacks are typed structurally.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type EntryLinesQuery = any
|
||||
|
||||
export interface FetchEntryLinesOptions {
|
||||
supabase: SupabaseClient
|
||||
/**
|
||||
* Columns to select from journal_entries. `id` is always included (it is
|
||||
* needed for chunking, paging order, and reattachment). Defaults to just
|
||||
* `id` for callers that only use the entry side as a filter.
|
||||
*/
|
||||
entryColumns?: string
|
||||
/**
|
||||
* Columns to select from journal_entry_lines. `id` and `journal_entry_id`
|
||||
* are always included (stable paging order + parent reattachment).
|
||||
*/
|
||||
lineColumns: string
|
||||
/**
|
||||
* Applies the entry-level filters (company_id, fiscal_period_id, status,
|
||||
* entry_date range, source_type, ...). MUST filter by company_id: this is
|
||||
* the tenant scope. Filters that used to target the embed
|
||||
* (`.eq('journal_entries.company_id', x)`) become plain column filters
|
||||
* here (`.eq('company_id', x)`).
|
||||
*/
|
||||
filterEntries: (query: EntryLinesQuery) => EntryLinesQuery
|
||||
/**
|
||||
* Optional line-level filters (account_number ranges, jsonb dimension
|
||||
* containment, ...), applied to every chunk query.
|
||||
*/
|
||||
filterLines?: (query: EntryLinesQuery) => EntryLinesQuery
|
||||
/**
|
||||
* Key the parent entry object is attached under on each returned line.
|
||||
* Defaults to 'journal_entries' (the un-aliased PostgREST embed key).
|
||||
* Pass e.g. 'journal_entry' for call sites that aliased the embed, or
|
||||
* null to skip reattachment entirely.
|
||||
*/
|
||||
attachEntriesAs?: string | null
|
||||
}
|
||||
|
||||
/** Ensure `required` columns are present in a comma-separated select list. */
|
||||
function ensureColumns(select: string, required: string[]): string {
|
||||
const parts = select
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
if (parts.includes('*')) return parts.join(', ')
|
||||
for (const col of required) {
|
||||
if (!parts.includes(col)) parts.push(col)
|
||||
}
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
/** Sort by id ascending; rows without an id keep their relative order. */
|
||||
function compareById(a: { id?: unknown }, b: { id?: unknown }): number {
|
||||
const aId = a.id
|
||||
const bId = b.id
|
||||
if (typeof aId !== 'string' || typeof bId !== 'string') return 0
|
||||
return aId < bId ? -1 : aId > bId ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch journal_entry_lines for an explicit list of journal_entry ids, in
|
||||
* chunks of {@link ENTRY_ID_CHUNK_SIZE}, each chunk paginated via
|
||||
* fetchAllRows. Lines are returned sorted by id ascending. Used directly by
|
||||
* callers that already hold the parent entries (e.g. SIE export); most call
|
||||
* sites want {@link fetchEntryLines} instead.
|
||||
*/
|
||||
export async function fetchLinesByEntryIds<TLine extends { id?: unknown }>(
|
||||
supabase: SupabaseClient,
|
||||
entryIds: string[],
|
||||
lineColumns: string,
|
||||
filterLines?: (query: EntryLinesQuery) => EntryLinesQuery
|
||||
): Promise<TLine[]> {
|
||||
if (entryIds.length === 0) return []
|
||||
|
||||
const select = ensureColumns(lineColumns, ['id', 'journal_entry_id'])
|
||||
|
||||
const chunks: string[][] = []
|
||||
for (let i = 0; i < entryIds.length; i += ENTRY_ID_CHUNK_SIZE) {
|
||||
chunks.push(entryIds.slice(i, i + ENTRY_ID_CHUNK_SIZE))
|
||||
}
|
||||
|
||||
const allLines: TLine[] = []
|
||||
for (let i = 0; i < chunks.length; i += CHUNK_BATCH_SIZE) {
|
||||
const batch = chunks.slice(i, i + CHUNK_BATCH_SIZE)
|
||||
const results = await Promise.all(
|
||||
batch.map((chunk) =>
|
||||
fetchAllRows<TLine>(
|
||||
({ from, to }) => {
|
||||
let query: EntryLinesQuery = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(select)
|
||||
.in('journal_entry_id', chunk)
|
||||
if (filterLines) query = filterLines(query)
|
||||
// Stable total order on the line PK for correct paging within the
|
||||
// chunk (see fetch-all.ts ordering invariant).
|
||||
return query.order('id', { ascending: true }).range(from, to)
|
||||
},
|
||||
{ dedupeBy: (r) => String((r as { id?: unknown }).id) }
|
||||
)
|
||||
)
|
||||
)
|
||||
for (const rows of results) allLines.push(...rows)
|
||||
}
|
||||
|
||||
// Chunk concatenation is not globally ordered; re-sort so callers see the
|
||||
// same id-ascending order the old single embed query produced.
|
||||
allLines.sort(compareById)
|
||||
return allLines
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch journal_entry_lines whose parent journal_entries match
|
||||
* `filterEntries`, with the parent entry reattached to each line. See the
|
||||
* module docstring for why this replaces the `journal_entries!inner` embed.
|
||||
*
|
||||
* The generic `TLine` is the caller-declared shape of a returned line,
|
||||
* INCLUDING the attached entry key (e.g. `journal_entries: {...}`).
|
||||
*/
|
||||
export async function fetchEntryLines<TLine>(
|
||||
options: FetchEntryLinesOptions
|
||||
): Promise<TLine[]> {
|
||||
const { supabase, lineColumns, filterEntries, filterLines } = options
|
||||
const entryColumns = ensureColumns(options.entryColumns ?? 'id', ['id'])
|
||||
const attachKey =
|
||||
options.attachEntriesAs === undefined
|
||||
? 'journal_entries'
|
||||
: options.attachEntriesAs
|
||||
|
||||
const entries = await fetchAllRows<{ id: string }>(
|
||||
({ from, to }) =>
|
||||
filterEntries(supabase.from('journal_entries').select(entryColumns))
|
||||
// Stable total order on the entry PK for correct paging.
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
{ dedupeBy: (e) => e.id }
|
||||
)
|
||||
|
||||
if (entries.length === 0) return []
|
||||
|
||||
const entryById = new Map(entries.map((e) => [e.id, e]))
|
||||
|
||||
const lines = await fetchLinesByEntryIds<Record<string, unknown>>(
|
||||
supabase,
|
||||
entries.map((e) => e.id),
|
||||
lineColumns,
|
||||
filterLines
|
||||
)
|
||||
|
||||
if (attachKey) {
|
||||
for (const line of lines) {
|
||||
line[attachKey] = entryById.get(line.journal_entry_id as string)
|
||||
}
|
||||
}
|
||||
|
||||
return lines as TLine[]
|
||||
}
|
||||
Reference in New Issue
Block a user