Files
accounted/extensions/general/cloud-backup/lib/sync.ts
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

546 lines
18 KiB
TypeScript

import { createHash } from 'node:crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
generateFullArchive,
generateBaseDataArchive,
ARCHIVE_OVERHEAD_BYTES,
} from '@/lib/reports/full-archive-export'
import { buildDriveFolderReadme } from '@/lib/reports/archive-readme'
import { getBranding } from '@/lib/branding/service'
import { createServiceClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { CloudTokenRefreshError, type CloudStorageProvider } from './cloud-provider'
import { googleDriveProvider } from './google-provider'
import { decryptToken } from './crypto'
import type {
CloudConnection,
CloudFileState,
CloudLastSync,
} from '../types'
/**
* Google Drive's storage keys, re-exported for the call sites that predate
* multi-provider support. Provider-aware code reads `provider.keys` instead:
* Dropbox owns `dropbox_*` records alongside these.
*/
export const CONNECTION_KEY = 'google_drive_connection'
export const LAST_SYNC_KEY = 'google_drive_last_sync'
/**
* Per-FILE ceiling, not per-backup: the backup splits into one archive per
* räkenskapsår plus Grunddata.zip, and uploads are resumable/chunked. The
* bound left is JSZip building each archive in memory on a serverless
* function, hence 300 MB rather than "unlimited".
*
* Memory budget: worst-case transient usage is ~3x this limit (~900 MB):
* JSZip holds the input blobs while generateAsync accumulates output chunks
* and then concatenates them into the final ArrayBuffer. The upload path
* adds no further copies (md5/sha256 hash via zero-copy Buffer views,
* resumable 8 MB chunk views, no multipart concatenation), and files are
* generated and uploaded one at a time. That fits the Vercel default
* function memory (2048 MB) with ~2x headroom; raise this limit only
* together with an explicit memory bump in vercel.json.
*/
export const SIZE_LIMIT_BYTES = 300 * 1024 * 1024
/** Bump to force a re-upload of every file when the archive format changes. */
export const ARCHIVE_FORMAT_VERSION = 3
export type SyncFailureReason =
| 'not_connected'
| 'needs_reauth'
| 'archive_too_large'
| 'upload_failed'
| 'internal'
export type PerformSyncResult =
| {
ok: true
lastSync: CloudLastSync
/** Link to the company's backup folder at the provider. */
webViewLink: string
uploadedCount: number
skippedCount: number
}
| {
ok: false
reason: SyncFailureReason
message: string
size_bytes?: number
size_limit_bytes?: number
}
interface PerformSyncParams {
supabase: SupabaseClient
companyId: string
userId: string
origin: string
includeDocuments: boolean
/**
* When a single archive file with documents exceeds the size limit, fall
* back to building THAT file without document blobs instead of failing.
* The cron and the post-connect sync set this; the manual flow leaves it
* off and lets the user choose via a dialog.
*/
allowDocumentFallback?: boolean
/**
* Destination. Defaults to Google Drive, which is what every caller meant
* before Dropbox existed. Each provider keeps its own connection, last-sync
* and schedule records, so syncing one never disturbs the other.
*/
provider?: CloudStorageProvider
}
interface PlannedFile {
key: string
kind: CloudFileState['kind']
periodId?: string
name: string
contentType: string
fingerprint: string
includeDocuments: boolean
generate: () => Promise<ArrayBuffer>
}
interface PeriodRow {
id: string
period_start: string
period_end: string
}
/**
* Run the end-to-end cloud backup sync against the per-fiscal-year layout:
*
* <company>/Arkiv <år>.zip one per räkenskapsår
* <company>/Grunddata.zip registers, SIE originals, audit trail
* <company>/LÄSMIG.txt folder map
*
* (Google Drive nests that company folder under a `gnubok/` root; Dropbox's
* app folder already provides one.)
*
* Every file carries a fingerprint (entry/document counts + latest
* timestamps); only files whose fingerprint changed are regenerated and
* uploaded, in place (both providers keep prior versions for ~30 days). State
* is persisted after every upload, so an interrupted run resumes where it
* stopped instead of re-uploading finished years.
*
* Settings ops use raw `extension_data` queries (not the extension context
* wrapper) because the cron runs under the service role with no user session.
*/
export async function performSync(params: PerformSyncParams): Promise<PerformSyncResult> {
const { supabase, companyId, userId, origin } = params
const provider = params.provider ?? googleDriveProvider
const keys = provider.keys
// Archive generation reads the private `documents` bucket, whose SELECT
// policy only covers the uploader's own folder. User-triggered syncs pass
// a user-bound client, which would silently drop every colleague-uploaded
// document from the backup (manifest rows flip to 'error'). The service
// client is used for archive generation only; authorization happened at
// the extension dispatcher (or the cron), and every archive query filters
// by the explicit companyId.
const archiveClient = createServiceClient()
const connection = await loadExtensionData<CloudConnection>(
supabase,
companyId,
keys.connection
)
if (!connection) {
return {
ok: false,
reason: 'not_connected',
message: `${provider.label} not connected`,
}
}
const refreshToken = decryptToken(connection.refresh_token_encrypted)
let accessToken: string
try {
accessToken = await provider.refreshAccessToken(refreshToken, origin)
} catch (err) {
if (err instanceof CloudTokenRefreshError && err.isInvalidGrant) {
// The refresh token is permanently dead (revoked or expired). Flag the
// connection so the cron stops retrying it and the UI can ask the user
// to reconnect. Other failures (network, 5xx) stay throwing: they are
// transient and worth retrying.
const flagged: CloudConnection = {
...connection,
status: 'needs_reauth',
needs_reauth_at: new Date().toISOString(),
}
await saveExtensionData(supabase, companyId, userId, keys.connection, flagged)
return {
ok: false,
reason: 'needs_reauth',
message: `${provider.label} authorization expired; reconnect required`,
}
}
throw err
}
const company = await fetchCompanyInfo(supabase, companyId)
const { target, connectionPatch } = await provider.prepareTarget({
accessToken,
connection,
companyLabel: company.label,
})
if (connectionPatch || connection.status === 'needs_reauth') {
// A successful refresh also clears a stale needs_reauth flag.
await saveExtensionData(supabase, companyId, userId, keys.connection, {
...connection,
...connectionPatch,
status: 'active',
needs_reauth_at: undefined,
})
}
// ---- Fingerprint basis: three paged reads + one point read. ----
const periods = await fetchAllRows<PeriodRow>(({ from, to }) =>
supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('company_id', companyId)
.order('period_start', { ascending: true })
.range(from, to)
)
const entries = await fetchAllRows<{
id: string
fiscal_period_id: string
updated_at: string | null
}>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id, fiscal_period_id, updated_at')
.eq('company_id', companyId)
.in('status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to)
)
const docs = await fetchAllRows<{
id: string
journal_entry_id: string | null
file_size_bytes: number | null
created_at: string | null
}>(({ from, to }) =>
supabase
.from('document_attachments')
.select('id, journal_entry_id, file_size_bytes, created_at')
.eq('company_id', companyId)
.order('id', { ascending: true })
.range(from, to)
)
const latestAuditAt = await fetchLatestAuditAt(supabase, companyId)
const entryToPeriod = new Map(entries.map((e) => [e.id, e.fiscal_period_id]))
interface Stats {
entryCount: number
maxEntryUpdated: string
docCount: number
docBytes: number
maxDocCreated: string
}
const emptyStats = (): Stats => ({
entryCount: 0,
maxEntryUpdated: '',
docCount: 0,
docBytes: 0,
maxDocCreated: '',
})
const statsByPeriod = new Map<string, Stats>()
for (const period of periods) statsByPeriod.set(period.id, emptyStats())
for (const entry of entries) {
const stats = statsByPeriod.get(entry.fiscal_period_id)
if (!stats) continue
stats.entryCount++
if (entry.updated_at && entry.updated_at > stats.maxEntryUpdated) {
stats.maxEntryUpdated = entry.updated_at
}
}
const baseStats = emptyStats()
for (const doc of docs) {
const periodId = doc.journal_entry_id ? entryToPeriod.get(doc.journal_entry_id) : undefined
const stats = (periodId && statsByPeriod.get(periodId)) || baseStats
stats.docCount++
stats.docBytes += Number(doc.file_size_bytes) || 0
if (doc.created_at && doc.created_at > stats.maxDocCreated) {
stats.maxDocCreated = doc.created_at
}
}
// ---- Plan the file set. ----
const planned: PlannedFile[] = []
const decideDocuments = (
docBytes: number
): { includeDocuments: boolean } | { tooLargeBytes: number } => {
let includeDocuments = params.includeDocuments
if (includeDocuments && ARCHIVE_OVERHEAD_BYTES + docBytes > SIZE_LIMIT_BYTES) {
if (params.allowDocumentFallback) {
includeDocuments = false
} else {
return { tooLargeBytes: ARCHIVE_OVERHEAD_BYTES + docBytes }
}
}
return { includeDocuments }
}
for (const period of periods) {
const stats = statsByPeriod.get(period.id)!
const decision = decideDocuments(stats.docBytes)
if ('tooLargeBytes' in decision) {
return {
ok: false,
reason: 'archive_too_large',
message: `Archive for ${period.period_start}..${period.period_end} exceeds size limit`,
size_bytes: decision.tooLargeBytes,
size_limit_bytes: SIZE_LIMIT_BYTES,
}
}
const includeDocuments = decision.includeDocuments
planned.push({
key: `period:${period.id}`,
kind: 'period',
periodId: period.id,
name: arkivFileName(period),
contentType: 'application/zip',
fingerprint: [
`v${ARCHIVE_FORMAT_VERSION}`,
stats.entryCount,
stats.maxEntryUpdated,
stats.docCount,
stats.maxDocCreated,
`docs:${includeDocuments ? 1 : 0}`,
].join('|'),
includeDocuments,
generate: () =>
generateFullArchive(archiveClient, companyId, {
scope: 'period',
period_id: period.id,
include_documents: includeDocuments,
}),
})
}
const baseDecision = decideDocuments(baseStats.docBytes)
if ('tooLargeBytes' in baseDecision) {
return {
ok: false,
reason: 'archive_too_large',
message: 'Grunddata archive exceeds size limit',
size_bytes: baseDecision.tooLargeBytes,
size_limit_bytes: SIZE_LIMIT_BYTES,
}
}
planned.push({
key: 'base',
kind: 'base',
name: 'Grunddata.zip',
contentType: 'application/zip',
fingerprint: [
`v${ARCHIVE_FORMAT_VERSION}`,
// Any data change writes the audit log, so this covers master data.
latestAuditAt,
baseStats.docCount,
baseStats.maxDocCreated,
`docs:${baseDecision.includeDocuments ? 1 : 0}`,
].join('|'),
includeDocuments: baseDecision.includeDocuments,
generate: () =>
generateBaseDataArchive(archiveClient, companyId, {
include_documents: baseDecision.includeDocuments,
}),
})
// Folder README: fingerprinted on its content (no timestamp inside), so it
// uploads once and again only when the text or company name changes.
const readmeText = buildDriveFolderReadme({
companyName: company.name,
orgNumber: company.orgNumber,
generatedAt: '',
appName: getBranding().appName,
})
planned.push({
key: 'readme',
kind: 'readme',
name: 'LÄSMIG.txt',
contentType: 'text/plain',
fingerprint: `v${ARCHIVE_FORMAT_VERSION}|${sha256Hex(textToArrayBuffer(readmeText)).slice(0, 16)}`,
includeDocuments: true,
generate: async () => textToArrayBuffer(readmeText),
})
// ---- Execute: regenerate + upload only what changed. ----
const previous = await loadExtensionData<CloudLastSync>(
supabase,
companyId,
keys.lastSync
)
const stateByKey = new Map<string, CloudFileState>()
for (const file of previous?.files ?? []) {
stateByKey.set(fileKey(file), file)
}
const persistSnapshot = async (): Promise<CloudLastSync> => {
const files = [...stateByKey.values()]
const lastSync: CloudLastSync = {
at: new Date().toISOString(),
folder_id: target.folderId,
web_view_link: target.webViewLink,
files,
total_size_bytes: files.reduce((sum, f) => sum + f.size_bytes, 0),
}
await saveExtensionData(supabase, companyId, userId, keys.lastSync, lastSync)
return lastSync
}
let uploadedCount = 0
let skippedCount = 0
for (const plan of planned) {
const prev = stateByKey.get(plan.key)
if (prev && prev.fingerprint === plan.fingerprint && prev.file_name === plan.name) {
skippedCount++
continue
}
const bytes = await plan.generate()
const uploaded = await provider.putFile({
accessToken,
target,
name: plan.name,
// Only reuse the remote handle when it still belongs to this file name:
// a renamed archive (a fiscal year that changed shape) must not
// overwrite the file the old name points at.
previousId:
prev?.file_id && prev.file_name === plan.name ? prev.file_id : undefined,
data: bytes,
contentType: plan.contentType,
})
stateByKey.set(plan.key, {
kind: plan.kind,
period_id: plan.periodId,
file_id: uploaded.id,
file_name: uploaded.name,
size_bytes: uploaded.size_bytes,
fingerprint: plan.fingerprint,
sha256: sha256Hex(bytes),
included_documents: plan.includeDocuments,
uploaded_at: new Date().toISOString(),
})
uploadedCount++
// Persist progressively: an interrupted run (time budget, crash) resumes
// from the finished files instead of re-uploading them.
await persistSnapshot()
}
// Drop state for files no longer planned (e.g. a deleted fiscal period).
const plannedKeys = new Set(planned.map((p) => p.key))
for (const key of [...stateByKey.keys()]) {
if (!plannedKeys.has(key)) stateByKey.delete(key)
}
const lastSync = await persistSnapshot()
return {
ok: true,
lastSync,
webViewLink: target.webViewLink,
uploadedCount,
skippedCount,
}
}
function fileKey(file: CloudFileState): string {
return file.kind === 'period' ? `period:${file.period_id}` : file.kind
}
/** `Arkiv 2024.zip` for calendar years, full dates for broken years. */
export function arkivFileName(period: PeriodRow): string {
const year = period.period_start.slice(0, 4)
const isCalendarYear =
period.period_start === `${year}-01-01` && period.period_end === `${year}-12-31`
return isCalendarYear
? `Arkiv ${year}.zip`
: `Arkiv ${period.period_start}_${period.period_end}.zip`
}
function sha256Hex(data: ArrayBuffer): string {
return createHash('sha256').update(Buffer.from(data)).digest('hex')
}
function textToArrayBuffer(text: string): ArrayBuffer {
const bytes = new TextEncoder().encode(text)
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
async function fetchLatestAuditAt(
supabase: SupabaseClient,
companyId: string
): Promise<string> {
const { data, error } = await supabase
.from('audit_log')
.select('created_at')
.eq('company_id', companyId)
// extension_data is non-portable runtime state and includes this sync's
// own progress snapshots. Letting those rows advance the watermark makes
// every completed backup invalidate Grunddata for the next run.
.neq('table_name', 'extension_data')
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(1)
if (error) throw new Error(`Failed to fetch backup audit watermark: ${error.message}`)
const rows = (data as { created_at: string }[] | null) ?? []
return rows[0]?.created_at ?? ''
}
export async function loadExtensionData<T>(
supabase: SupabaseClient,
companyId: string,
key: string
): Promise<T | null> {
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'cloud-backup')
.eq('key', key)
.maybeSingle()
return (data?.value as T) ?? null
}
export async function saveExtensionData<T>(
supabase: SupabaseClient,
companyId: string,
userId: string,
key: string,
value: T
): Promise<void> {
const { error } = await supabase.from('extension_data').upsert(
{
user_id: userId,
company_id: companyId,
extension_id: 'cloud-backup',
key,
value,
},
{ onConflict: 'company_id,extension_id,key' }
)
if (error) throw new Error(`Failed to save extension data: ${error.message}`)
}
async function fetchCompanyInfo(
supabase: SupabaseClient,
companyId: string
): Promise<{ name: string; orgNumber: string | null; label: string }> {
const { data } = await supabase
.from('company_settings')
.select('company_name, org_number')
.eq('company_id', companyId)
.maybeSingle()
const name = (data?.company_name as string) || 'företag'
const orgNumber = (data?.org_number as string) || null
const label = `${name} (${orgNumber || companyId.slice(0, 8)})`.replace(/[\\/]/g, '-')
return { name, orgNumber, label }
}