a49d75db77
* fix(providers): paginate Visma eAccounting with $page/$pagesize eAccounting silently ignores OData $top/$skip, so every request returned page 1 and getPaginated appended the first page TotalNumberOfPages times: customers were imported in triplicate and invoice chunks hit unique violations. Also stop on an empty page so a stale Meta can never loop or duplicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migration): survive bad rows in entity imports instead of failing whole chunks One PostgREST insert per 500-row chunk is all-or-nothing, so a single duplicate reported every row as failed ('300 misslyckades') with no cause shown. Now: dedupe repeats within the fetched data (paging faults, source duplicates), fall back to per-row inserts when a chunk is rejected, store empty invoice numbers as NULL instead of colliding '', surface the first DB error in the result UI, and mark all-failed steps with an error icon. Sales invoices also carry remaining_amount so open invoices no longer land as settled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migration): never per-row retry after a successful bulk insert with short read-back A succeeded statement whose .select() returns fewer rows than sent means the rows ARE in the table; retrying them one by one would duplicate every unreturned row. Pair what came back and report the tail instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migration): count stub-insert casualties as failed and sample enrichment errors Review follow-ups: invoices dropped because their customer/supplier stub insert errored are DB failures, not matching misses; classifying them as noMatch rendered a green result row with the database error hidden. Enrichment failures now also feed errorSample. 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>
78 lines
3.0 KiB
TypeScript
78 lines
3.0 KiB
TypeScript
/**
|
|
* Chunked inserts during migration are one PostgREST statement per chunk,
|
|
* which Postgres treats as all-or-nothing: a single bad row (usually a
|
|
* 23505 unique violation) rejects every row in the chunk. Historically that
|
|
* surfaced as "300 misslyckades" with zero explanation of which row broke.
|
|
*
|
|
* This helper keeps the fast path (one bulk insert) and, only when the bulk
|
|
* statement fails, retries the same rows one at a time so healthy rows still
|
|
* land and the real per-row error becomes visible.
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
export interface PerRowInsertOutcome {
|
|
/**
|
|
* Selected columns of each inserted row, index-aligned with the input.
|
|
* null = that row failed to insert.
|
|
*/
|
|
returned: (Record<string, unknown> | null)[]
|
|
failedCount: number
|
|
/** First database error message, for result summaries and logs. */
|
|
firstError: string | null
|
|
}
|
|
|
|
export async function insertWithPerRowFallback(
|
|
supabase: SupabaseClient,
|
|
table: string,
|
|
rows: Record<string, unknown>[],
|
|
select: string,
|
|
): Promise<PerRowInsertOutcome> {
|
|
if (rows.length === 0) {
|
|
return { returned: [], failedCount: 0, firstError: null }
|
|
}
|
|
|
|
const bulk = await supabase.from(table).insert(rows).select(select)
|
|
if (!bulk.error) {
|
|
const data = (bulk.data ?? []) as unknown as Record<string, unknown>[]
|
|
// PostgREST returns inserted rows in input order, so a full-length result
|
|
// pairs 1:1 with the input.
|
|
if (data.length === rows.length) {
|
|
return { returned: data, failedCount: 0, firstError: null }
|
|
}
|
|
// The statement SUCCEEDED but returned fewer rows than sent (an RLS
|
|
// read-back gap). The rows ARE in the table, so retrying per row would
|
|
// duplicate them: pair what came back and report the tail unpaired.
|
|
const returned: (Record<string, unknown> | null)[] = new Array(rows.length).fill(null)
|
|
for (let i = 0; i < data.length; i++) returned[i] = data[i]
|
|
return {
|
|
returned,
|
|
failedCount: rows.length - data.length,
|
|
firstError: `insert returned ${data.length} of ${rows.length} rows; unreturned rows were inserted but could not be paired`,
|
|
}
|
|
}
|
|
|
|
const returned: (Record<string, unknown> | null)[] = new Array(rows.length).fill(null)
|
|
let failedCount = 0
|
|
let firstError: string | null = bulk.error?.message ?? null
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const single = await supabase.from(table).insert(rows[i]).select(select)
|
|
if (single.error) {
|
|
failedCount++
|
|
firstError ??= single.error.message
|
|
// Keep the first PER-ROW error too: the bulk message is often the same,
|
|
// but when it isn't, the row-level one names the actual offender.
|
|
if (failedCount === 1 && single.error.message) {
|
|
firstError = single.error.message
|
|
}
|
|
continue
|
|
}
|
|
const data = (single.data ?? []) as unknown as Record<string, unknown>[]
|
|
returned[i] = data[0] ?? null
|
|
if (!returned[i]) failedCount++
|
|
}
|
|
|
|
return { returned, failedCount, firstError }
|
|
}
|