Ctrl K
ComunidadGuías…

scripts/backfill-document-storage-paths.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env npx tsx
/**
 * ============================================================================
 * !! DESTRUCTIVE. READ THIS BEFORE YOU RUN ANYTHING. !!
 * ============================================================================
 *
 * This script MOVES räkenskapsinformation. The objects it touches are kvitton,
 * leverantorsfakturor and kontoutdrag held under the Bokforingslagen 7 kap 2 §
 * SEVEN-YEAR RETENTION requirement. Losing one is a legal incident, not a bug.
 *
 *   * ALWAYS run against STAGING first and confirm the counts there.
 *   * NEVER point this at .env.local. That file targets the REAL customer
 *     database. Pass --env <file> explicitly and read the banner it prints.
 *   * The default mode is --dry-run. Moving data requires the explicit
 *     --apply flag. Removing the source copies requires --apply AND
 *     --delete-source, which should be a SEPARATE run, days after --apply,
 *     once the app has been observed serving the new keys.
 *
 * ============================================================================
 * WHAT IT DOES (Phase B of the 3-phase rollout)
 * ============================================================================
 *
 * Phase A (migration 20260726092000_documents_bucket_company_scope.sql) added
 * company-scoped storage policies for the key layout
 *
 *     documents/{companyId}/{userId}/{timestamp}_{filename}
 *
 * and switched lib/core/documents/document-service.ts to write that layout.
 * Legacy objects still sit at
 *
 *     documents/{userId}/{timestamp}_{filename}
 *
 * where the RLS policy can only scope on auth.uid(), so a removed company
 * member keeps read access to everything they ever uploaded.
 *
 * This script re-homes those legacy objects. Rows are grouped by storage key
 * first: document_attachments.storage_path has NO unique constraint, so
 * several rows can point at one object, and the object may only be released
 * once every one of those rows has been repointed. Per source key, in order:
 *
 *   1. resolve the owning company_id(s) from document_attachments
 *   2. skip rows whose storage_path is already company-scoped (idempotent /
 *      resumable)
 *   3. COPY the object to the company-scoped key(s) (never move, never
 *      rename; one copy per owning company)
 *   4. VERIFY each new key is readable and byte-identical (SHA-256 compared
 *      against document_attachments.sha256_hash when present, otherwise
 *      against the source bytes)
 *   5. only then UPDATE storage_path for EVERY row on that key, checking the
 *      row count of each UPDATE: a row deleted between fetch and migrate
 *      must not count as migrated, or the fresh scoped copy would resurrect
 *      an erased document (a copy no row ended up pointing at is rolled
 *      back; the source is never touched by that rollback)
 *   6. only with --delete-source, and only after 3-5 succeeded for ALL rows
 *      on the key AND the live table confirms zero rows still reference it,
 *      remove the legacy object
 *
 * A --delete-source run additionally SWEEPS rows that an earlier --apply run
 * already repointed: their legacy key is derived from the scoped one (the
 * forward mapping is a pure prefix insertion, so the inverse is exact), the
 * scoped copy is re-verified (readable + hash), the live table is checked
 * for remaining references, and only then is the leftover legacy object
 * removed. Without this sweep the documented two-step workflow (--apply
 * first, --delete-source days later) would be a no-op: after --apply no row
 * carries a legacy pointer any more, yet every legacy object still sits in
 * storage, readable by its uploader (including ex-members: the exact hole
 * Phase A exists to close).
 *
 * The source is NEVER deleted before the new key has been confirmed readable
 * and every referencing row repointed. A failure at any step logs the
 * document id and the script continues to the next object; it never aborts
 * the run on a single bad document.
 *
 * ============================================================================
 * PHASE C GATE
 * ============================================================================
 *
 * The final summary prints two counters:
 *
 *   legacy_prefix_remaining  rows still pointing at a legacy key
 *   legacy_objects_remaining legacy objects still present in storage for
 *                            rows that are already company-scoped (found by
 *                            the storage-level sweep, which runs read-only
 *                            in every mode)
 *
 * Phase C (the migration that drops `documents_select_own` /
 * `documents_insert_own`) may only be applied when a --dry-run of this
 * script reports BOTH counters at 0. Dropping those policies earlier makes
 * every un-migrated document unreadable to everyone except the service role,
 * and leftover legacy objects would stay uploader-readable forever.
 *
 * ============================================================================
 * USAGE
 * ============================================================================
 *
 *   # 1. Inspect. Changes nothing. This is the default.
 *   npx tsx scripts/backfill-document-storage-paths.ts --env .env.staging.local
 *
 *   # 2. Copy + verify + repoint. Leaves the legacy objects in place.
 *   npx tsx scripts/backfill-document-storage-paths.ts --env .env.staging.local --apply
 *
 *   # 3. Days later, after the app has been observed serving new keys:
 *   #    migrates any stragglers, then sweeps the already-migrated rows and
 *   #    removes their verified leftover legacy objects.
 *   npx tsx scripts/backfill-document-storage-paths.ts --env .env.staging.local --apply --delete-source
 *
 * Flags:
 *   --env <file>       dotenv file to load. REQUIRED. No default: an implicit
 *                      .env.local would point at production.
 *   --apply            actually copy/verify/repoint. Without it: dry run.
 *   --delete-source    additionally remove legacy objects whose every row has
 *                      a verified company-scoped copy. Requires --apply.
 *   --company <uuid>   restrict the run to one company. Use this for the first
 *                      production batch.
 *   --limit <n>        process at most n documents this run (resumable: run
 *                      again to continue). A shared-key group is never split,
 *                      so the last group may overshoot the limit. Also caps
 *                      how many leftover legacy objects a --delete-source
 *                      sweep removes.
 *   --yes              skip the interactive confirmation prompt.
 */

import { createHash } from 'node:crypto'
import { basename, resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
import { config } from 'dotenv'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'

const BUCKET = 'documents'
const PATH_ROOT = 'documents'
const PAGE_SIZE = 500

// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------

function flagValue(name: string): string | undefined {
  const idx = process.argv.indexOf(`--${name}`)
  if (idx === -1) return undefined
  const value = process.argv[idx + 1]
  if (!value || value.startsWith('--')) {
    console.error(`--${name} requires a value`)
    process.exit(1)
  }
  return value
}

const envFile = flagValue('env')
const apply = process.argv.includes('--apply')
const deleteSource = process.argv.includes('--delete-source')
const skipPrompt = process.argv.includes('--yes')
const onlyCompany = flagValue('company')
const limitRaw = flagValue('limit')
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.POSITIVE_INFINITY

if (!envFile) {
  console.error(
    'Refusing to run without an explicit --env <file>. An implicit .env.local ' +
      'points at the PRODUCTION database. Example: --env .env.staging.local',
  )
  process.exit(1)
}

// Refuse .env.local no matter how the path is spelled ('./.env.local', an
// absolute path, backslashes on Windows): normalize before comparing. An
// exact string compare here was trivially bypassed by './.env.local'. The
// check runs BEFORE config() so the production env file is never even read.
if (basename(resolve(envFile)).toLowerCase() === '.env.local') {
  console.error(
    'REFUSING: .env.local points at the production database. If you really ' +
      'intend to run against production, copy the credentials into an ' +
      'explicitly named file (e.g. .env.production.backfill) so the intent ' +
      'is recorded in the command line.',
  )
  process.exit(1)
}

if (deleteSource && !apply) {
  console.error('--delete-source requires --apply.')
  process.exit(1)
}

if (limitRaw && (!Number.isFinite(limit) || limit <= 0)) {
  console.error('--limit must be a positive integer.')
  process.exit(1)
}

config({ path: envFile })

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY

if (!supabaseUrl || !serviceRoleKey) {
  console.error(`Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in ${envFile}`)
  process.exit(1)
}

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

interface DocumentRow {
  id: string
  company_id: string | null
  storage_path: string | null
  file_name: string | null
  mime_type: string | null
  sha256_hash: string | null
}

interface Failure {
  documentId: string
  storagePath: string | null
  step: string
  reason: string
}

/** All rows (possibly from several companies) that share one legacy key. */
interface PathGroup {
  sourcePath: string
  rows: DocumentRow[]
}

// ---------------------------------------------------------------------------
// Path helpers.
//
// Deliberately duplicated from lib/core/documents/document-service.ts instead
// of imported: this script runs standalone under tsx and must not drag the
// service module's Next.js/event-bus imports into a plain node process. Keep
// the two in sync if the layout ever changes again.
// ---------------------------------------------------------------------------

function isCompanyScoped(storagePath: string, companyId: string): boolean {
  return storagePath.startsWith(`${PATH_ROOT}/${companyId}/`)
}

function isLegacyDocumentPath(storagePath: string): boolean {
  return storagePath.startsWith(`${PATH_ROOT}/`)
}

function companyScopedPath(storagePath: string, companyId: string): string | null {
  if (isCompanyScoped(storagePath, companyId)) return null
  if (!isLegacyDocumentPath(storagePath)) return null
  return `${PATH_ROOT}/${companyId}/${storagePath.slice(`${PATH_ROOT}/`.length)}`
}

/**
 * Inverse of companyScopedPath: derive the legacy key a company-scoped key
 * was (or would have been) migrated from. The forward mapping is a pure
 * prefix insertion, so the inverse is exact. Returns null when the key is
 * not company-scoped for the given company.
 */
function legacyPathFor(storagePath: string, companyId: string): string | null {
  const prefix = `${PATH_ROOT}/${companyId}/`
  if (!storagePath.startsWith(prefix)) return null
  return `${PATH_ROOT}/${storagePath.slice(prefix.length)}`
}

function sha256Hex(buffer: ArrayBuffer): string {
  return createHash('sha256').update(Buffer.from(buffer)).digest('hex')
}

// ---------------------------------------------------------------------------
// Safety banner + confirmation
// ---------------------------------------------------------------------------

function projectRef(url: string): string {
  try {
    return new URL(url).hostname.split('.')[0] ?? url
  } catch {
    return url
  }
}

async function confirmOrExit(): Promise<void> {
  const ref = projectRef(supabaseUrl!)

  console.log('')
  console.log('='.repeat(78))
  console.log('  documents bucket backfill: legacy uploader-scoped keys -> company-scoped')
  console.log('='.repeat(78))
  console.log(`  env file        : ${envFile}`)
  console.log(`  supabase project: ${ref}`)
  console.log(`  mode            : ${apply ? 'APPLY (writes data)' : 'DRY RUN (no changes)'}`)
  console.log(`  delete source   : ${deleteSource ? 'YES (legacy objects removed)' : 'no'}`)
  console.log(`  company filter  : ${onlyCompany ?? '(all companies)'}`)
  console.log(`  limit           : ${Number.isFinite(limit) ? limit : '(none)'}`)
  console.log('='.repeat(78))
  console.log('')

  if (!apply || skipPrompt) return

  const rl = createInterface({ input: process.stdin, output: process.stdout })
  const answer = await rl.question(
    `Type the project ref (${ref}) to proceed, anything else to abort: `,
  )
  rl.close()
  if (answer.trim() !== ref) {
    console.error('Aborted.')
    process.exit(1)
  }
}

// ---------------------------------------------------------------------------
// Data access
// ---------------------------------------------------------------------------

/**
 * Page through document_attachments. PostgREST caps a single response at
 * 1000 rows, so never rely on an unbounded select here.
 */
async function fetchAllDocuments(supabase: SupabaseClient): Promise<DocumentRow[]> {
  const rows: DocumentRow[] = []
  let from = 0

  for (;;) {
    let query = supabase
      .from('document_attachments')
      .select('id, company_id, storage_path, file_name, mime_type, sha256_hash')
      .not('storage_path', 'is', null)
      // created_at alone is not unique: without the id tiebreaker, rows
      // sharing a timestamp can be skipped or duplicated across .range()
      // pages, and a skipped row silently stays on the legacy key.
      .order('created_at', { ascending: true })
      .order('id', { ascending: true })
      .range(from, from + PAGE_SIZE - 1)

    if (onlyCompany) query = query.eq('company_id', onlyCompany)

    const { data, error } = await query
    if (error) throw new Error(`Failed to page document_attachments: ${error.message}`)
    if (!data || data.length === 0) break

    rows.push(...(data as DocumentRow[]))
    if (data.length < PAGE_SIZE) break
    from += PAGE_SIZE
  }

  return rows
}

async function objectExists(supabase: SupabaseClient, path: string): Promise<boolean> {
  const segments = path.split('/')
  const name = segments.pop()!
  const folder = segments.join('/')
  const { data, error } = await supabase.storage.from(BUCKET).list(folder, { search: name })
  // Surface list failures instead of treating them as "missing": a transient
  // error must never make a leftover object look already-swept.
  if (error) throw new Error(`Failed to list ${folder}: ${error.message}`)
  return !!data?.some((entry) => entry.name === name)
}

/**
 * Authoritative check that at least one document_attachments row still points
 * at the given storage key. Deliberately queries the LIVE table rather than
 * this run's in-memory snapshot: a --company or --limit window, a
 * NULL-company row, or a concurrent writer must never be able to hide a row
 * whose only object the key is.
 */
async function pathStillReferenced(supabase: SupabaseClient, path: string): Promise<boolean> {
  const { data, error } = await supabase
    .from('document_attachments')
    .select('id')
    .eq('storage_path', path)
    .limit(1)
  if (error) throw new Error(`Failed to check references for ${path}: ${error.message}`)
  return (data?.length ?? 0) > 0
}

/**
 * Remove a legacy object, but only when the live table confirms zero rows
 * still reference it. Every caller has already verified a company-scoped
 * copy for every row that pointed here; this function is the ONLY place the
 * script deletes a legacy source object. Returns true when the object was
 * actually removed.
 */
async function removeLegacySourceIfUnreferenced(
  supabase: SupabaseClient,
  legacyPath: string,
): Promise<boolean> {
  try {
    if (await pathStillReferenced(supabase, legacyPath)) {
      console.warn(`  [${legacyPath}] source kept: still referenced by at least one row`)
      return false
    }
  } catch (err) {
    console.warn(
      `  [${legacyPath}] source kept: reference check failed: ${
        err instanceof Error ? err.message : String(err)
      }`,
    )
    return false
  }

  const { error: removeError } = await supabase.storage.from(BUCKET).remove([legacyPath])
  if (removeError) {
    console.warn(`  [${legacyPath}] source not removed: ${removeError.message}`)
    return false
  }
  return true
}

// ---------------------------------------------------------------------------
// Per-object migration (all rows sharing one legacy key, together)
// ---------------------------------------------------------------------------

interface TargetState {
  targetPath: string
  copyHash: string
  uploadedThisRun: boolean
  repointed: number
}

async function migrateGroup(
  supabase: SupabaseClient,
  group: PathGroup,
  failures: Failure[],
): Promise<{ migratedRows: number; failedRows: number; sourceRemoved: boolean }> {
  const { sourcePath, rows } = group

  const fail = (row: DocumentRow, step: string, reason: string) => {
    failures.push({ documentId: row.id, storagePath: sourcePath, step, reason })
  }

  // ---- 3. COPY (never move: the source must survive until step 6).
  // Downloaded once per key: several rows can share the object. ------------
  const { data: sourceBlob, error: downloadError } = await supabase.storage
    .from(BUCKET)
    .download(sourcePath)

  if (downloadError || !sourceBlob) {
    const reason = downloadError?.message ?? 'no data returned'
    for (const row of rows) fail(row, 'download-source', reason)
    return { migratedRows: 0, failedRows: rows.length, sourceRemoved: false }
  }

  const sourceBytes = await sourceBlob.arrayBuffer()
  const sourceHash = sha256Hex(sourceBytes)

  // One scoped target per company referencing this source key: rows from
  // different companies sharing one object each get their own copy.
  const companies = [...new Set(rows.map((row) => row.company_id!))]
  const targets = new Map<string, TargetState>()

  for (const companyId of companies) {
    const companyRows = rows.filter((row) => row.company_id === companyId)
    const targetPath = companyScopedPath(sourcePath, companyId)!
    let uploadedThisRun = false

    try {
      // An already-present target means a previous run got this far: fall
      // through to verification rather than failing on the upsert:false
      // conflict.
      if (!(await objectExists(supabase, targetPath))) {
        const { error: uploadError } = await supabase.storage
          .from(BUCKET)
          .upload(targetPath, sourceBytes, {
            contentType: companyRows[0]!.mime_type ?? 'application/octet-stream',
            upsert: false,
          })

        if (uploadError) {
          for (const row of companyRows) fail(row, 'upload-copy', uploadError.message)
          continue
        }
        uploadedThisRun = true
      }
    } catch (err) {
      const reason = err instanceof Error ? err.message : String(err)
      for (const row of companyRows) fail(row, 'upload-copy', reason)
      continue
    }

    // ---- 4. VERIFY the new key is readable, once per target --------------
    const { data: copyBlob, error: verifyError } = await supabase.storage
      .from(BUCKET)
      .download(targetPath)

    if (verifyError || !copyBlob) {
      const reason = verifyError?.message ?? 'copy not readable at the new key'
      for (const row of companyRows) fail(row, 'verify-readable', reason)
      continue
    }

    targets.set(companyId, {
      targetPath,
      copyHash: sha256Hex(await copyBlob.arrayBuffer()),
      uploadedThisRun,
      repointed: 0,
    })
  }

  let migratedRows = 0

  for (const row of rows) {
    const target = targets.get(row.company_id!)
    if (!target) continue // upload/readability failure already recorded above

    // The stored hash is the strongest reference; fall back to the source
    // bytes for rows written before sha256_hash was populated.
    const expectedHash = row.sha256_hash ?? sourceHash
    if (target.copyHash !== expectedHash) {
      fail(row, 'verify-hash', `copy hash ${target.copyHash} does not match expected ${expectedHash}`)
      continue
    }

    // ---- 5. Repoint the DB, only now that the copy is proven. The row
    // count matters: a row deleted between fetch and migrate makes this a
    // 0-row UPDATE, which must NOT count as migrated (the scoped copy would
    // resurrect an erased document). ---------------------------------------
    const { data: updated, error: updateError } = await supabase
      .from('document_attachments')
      .update({ storage_path: target.targetPath })
      .eq('id', row.id)
      .select('id')

    if (updateError) {
      fail(row, 'update-pointer', updateError.message)
      continue
    }
    if (!updated || updated.length === 0) {
      fail(row, 'update-pointer', 'no row matched: deleted between fetch and migrate')
      continue
    }

    target.repointed++
    migratedRows++
  }

  // Roll back any scoped copy created THIS RUN that no row ended up pointing
  // at, so it cannot linger as an unreferenced resurrected duplicate. Safe:
  // the copy is seconds old, the live table confirms nothing references it,
  // and the SOURCE object is never touched here.
  for (const target of targets.values()) {
    if (!target.uploadedThisRun || target.repointed > 0) continue
    try {
      if (await pathStillReferenced(supabase, target.targetPath)) continue
      const { error: cleanupError } = await supabase.storage
        .from(BUCKET)
        .remove([target.targetPath])
      if (cleanupError) {
        console.warn(
          `  [${target.targetPath}] unreferenced fresh copy not rolled back: ${cleanupError.message}`,
        )
      }
    } catch (err) {
      console.warn(
        `  [${target.targetPath}] unreferenced fresh copy not rolled back: ${
          err instanceof Error ? err.message : String(err)
        }`,
      )
    }
  }

  const failedRows = rows.length - migratedRows

  // ---- 6. Optional source removal, strictly last: only when EVERY row on
  // this key is repointed, and the live table double-checks that. ----------
  let sourceRemoved = false
  if (deleteSource && failedRows === 0) {
    sourceRemoved = await removeLegacySourceIfUnreferenced(supabase, sourcePath)
  }

  return { migratedRows, failedRows, sourceRemoved }
}

// ---------------------------------------------------------------------------
// Storage-level sweep over rows that are ALREADY company-scoped
// ---------------------------------------------------------------------------

interface SweepResult {
  /** Distinct derived legacy keys whose object still exists in storage. */
  keysWithLeftovers: number
  objectsRemoved: number
}

/**
 * An --apply run repoints rows and leaves the legacy objects behind, so a
 * later --delete-source run can no longer find them through the pointers:
 * it has to derive each legacy key from the scoped one (legacyPathFor) and
 * check storage directly. For every derived key whose object still exists:
 *
 *   - without --delete-source: count it (input to the Phase C gate)
 *   - with --delete-source: verify the scoped copy is readable and matches
 *     the expected hash for EVERY row mapping to the key, confirm via the
 *     live table that no row still references the key, and only then remove
 *     it (capped by --limit).
 */
async function sweepLegacyObjects(
  supabase: SupabaseClient,
  scopedRows: DocumentRow[],
  skipKeys: Set<string>,
  failures: Failure[],
  deleteBudget: number,
): Promise<SweepResult> {
  // Group scoped rows by derived legacy key: several rows (even from
  // different companies) can map to one key, and the key may only be removed
  // when every one of them has a verified scoped copy.
  const byLegacyKey = new Map<string, DocumentRow[]>()
  for (const row of scopedRows) {
    const legacyKey = legacyPathFor(row.storage_path!, row.company_id!)
    if (!legacyKey) continue
    if (skipKeys.has(legacyKey)) continue // handled by this run's migration phase
    const list = byLegacyKey.get(legacyKey)
    if (list) list.push(row)
    else byLegacyKey.set(legacyKey, [row])
  }

  const result: SweepResult = { keysWithLeftovers: 0, objectsRemoved: 0 }

  for (const [legacyKey, rows] of byLegacyKey) {
    let counted = false
    try {
      // Most keys belong to uploads born company-scoped: no legacy object
      // ever existed for them, and this check is all they cost.
      if (!(await objectExists(supabase, legacyKey))) continue
      result.keysWithLeftovers++
      counted = true

      if (!deleteSource) continue // report-only: feeds legacy_objects_remaining
      if (result.objectsRemoved >= deleteBudget) continue

      // VERIFY: every row mapping to this key must have a readable scoped
      // copy with the expected hash before the legacy object may go.
      let allVerified = true
      let legacyHash: string | null = null
      for (const row of rows) {
        const { data: copyBlob, error: copyError } = await supabase.storage
          .from(BUCKET)
          .download(row.storage_path!)

        if (copyError || !copyBlob) {
          failures.push({
            documentId: row.id,
            storagePath: legacyKey,
            step: 'sweep-verify-readable',
            reason: copyError?.message ?? 'scoped copy not readable',
          })
          allVerified = false
          break
        }
        const copyHash = sha256Hex(await copyBlob.arrayBuffer())

        let expectedHash = row.sha256_hash
        if (!expectedHash) {
          // No stored hash: the legacy bytes themselves are the reference.
          if (legacyHash === null) {
            const { data: legacyBlob, error: legacyError } = await supabase.storage
              .from(BUCKET)
              .download(legacyKey)
            if (legacyError || !legacyBlob) {
              failures.push({
                documentId: row.id,
                storagePath: legacyKey,
                step: 'sweep-download-legacy',
                reason: legacyError?.message ?? 'legacy object listed but not readable',
              })
              allVerified = false
              break
            }
            legacyHash = sha256Hex(await legacyBlob.arrayBuffer())
          }
          expectedHash = legacyHash
        }

        if (copyHash !== expectedHash) {
          failures.push({
            documentId: row.id,
            storagePath: legacyKey,
            step: 'sweep-verify-hash',
            reason: `scoped copy hash ${copyHash} does not match expected ${expectedHash}`,
          })
          allVerified = false
          break
        }
      }

      if (!allVerified) continue // stays counted as a leftover; gate stays closed

      if (await removeLegacySourceIfUnreferenced(supabase, legacyKey)) {
        console.log(
          `  [sweep] removed ${legacyKey} (${rows.length} row(s) verified at scoped keys)`,
        )
        result.objectsRemoved++
      }
    } catch (err) {
      // Unknown state: count the key as remaining so the gate stays closed.
      if (!counted) result.keysWithLeftovers++
      failures.push({
        documentId: rows[0]!.id,
        storagePath: legacyKey,
        step: 'sweep-unexpected',
        reason: err instanceof Error ? err.message : String(err),
      })
    }
  }

  return result
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

async function main(): Promise<void> {
  await confirmOrExit()

  const supabase = createClient(supabaseUrl!, serviceRoleKey!, {
    auth: { persistSession: false, autoRefreshToken: false },
  })

  const rows = await fetchAllDocuments(supabase)

  const alreadyScoped: DocumentRow[] = []
  const candidates: DocumentRow[] = []
  const unmapped: DocumentRow[] = []

  for (const row of rows) {
    if (!row.storage_path) continue
    if (!row.company_id) {
      unmapped.push(row)
      continue
    }
    if (isCompanyScoped(row.storage_path, row.company_id)) {
      alreadyScoped.push(row)
    } else if (isLegacyDocumentPath(row.storage_path)) {
      candidates.push(row)
    } else {
      // Shapes this backfill deliberately does not touch, e.g. the MCP
      // audit-package keys `{userId}/audit-packages/...` and the demo-seed
      // `{userId}/{companyId}/inbox/...` keys. They are not covered by the
      // documents_*_company policies and are not document_attachments
      // underlag in the BFL sense.
      unmapped.push(row)
    }
  }

  // Group the legacy rows by source key: storage_path has no unique
  // constraint, so several rows can share one object. The object is copied
  // once, ALL its rows are repointed, and only then may the source go;
  // migrating rows one by one deleted the source after the first row and
  // stranded every later row on a dead pointer.
  const groupsByPath = new Map<string, DocumentRow[]>()
  for (const row of candidates) {
    const list = groupsByPath.get(row.storage_path!)
    if (list) list.push(row)
    else groupsByPath.set(row.storage_path!, [row])
  }
  const groups: PathGroup[] = [...groupsByPath.entries()].map(([sourcePath, groupRows]) => ({
    sourcePath,
    rows: groupRows,
  }))

  console.log(`document_attachments rows with a storage_path : ${rows.length}`)
  console.log(`  already company-scoped                      : ${alreadyScoped.length}`)
  console.log(
    `  legacy prefix, need migration               : ${candidates.length} (${groups.length} distinct objects)`,
  )
  console.log(`  outside the documents/ layout (skipped)     : ${unmapped.length}`)
  console.log('')

  if (unmapped.length > 0) {
    console.log('Skipped rows (first 20):')
    for (const row of unmapped.slice(0, 20)) {
      console.log(`  ${row.id}  company=${row.company_id ?? 'NULL'}  path=${row.storage_path}`)
    }
    console.log('')
  }

  // --limit caps processed rows, but a shared-key group is never split:
  // repointing only some of a key's rows would let a later --delete-source
  // remove an object the remaining rows still need.
  const batch: PathGroup[] = []
  let batchRows = 0
  for (const group of groups) {
    if (batchRows >= limit) break
    batch.push(group)
    batchRows += group.rows.length
  }

  const failures: Failure[] = []
  let migratedRows = 0
  let sourcesRemoved = 0
  // Groups fully migrated this run whose legacy object was (deliberately or
  // not) left in storage: they count as leftover legacy objects below.
  let sourcesKept = 0

  if (!apply) {
    console.log('DRY RUN: nothing was changed. Planned moves (first 20):')
    const preview = batch.flatMap((group) => group.rows).slice(0, 20)
    for (const row of preview) {
      console.log(`  ${row.id}`)
      console.log(`    from ${row.storage_path}`)
      console.log(`    to   ${companyScopedPath(row.storage_path!, row.company_id!)}`)
    }
    console.log('')
  } else {
    for (const [index, group] of batch.entries()) {
      const progress = `${index + 1}/${batch.length}`
      const rowCount = group.rows.length
      console.log(
        `[${progress}] ${group.sourcePath}  (${rowCount} row${rowCount === 1 ? '' : 's'})`,
      )
      try {
        const outcome = await migrateGroup(supabase, group, failures)
        migratedRows += outcome.migratedRows
        if (outcome.failedRows === 0) {
          if (outcome.sourceRemoved) sourcesRemoved++
          else sourcesKept++
        }
      } catch (err) {
        // Never abort the run on one bad object.
        const reason = err instanceof Error ? err.message : String(err)
        for (const row of group.rows) {
          failures.push({
            documentId: row.id,
            storagePath: group.sourcePath,
            step: 'unexpected',
            reason,
          })
        }
      }
    }
  }

  // Storage-level sweep over already-migrated rows. Without it, an --apply
  // run followed by a later --delete-source run finds zero legacy POINTERS
  // and deletes nothing, while every legacy OBJECT still sits in the bucket
  // readable by its uploader. Read-only unless --delete-source; the keys
  // this run already handled as migration sources are skipped.
  console.log('')
  console.log(
    `Sweeping ${alreadyScoped.length} already-scoped row(s) for leftover legacy objects` +
      (deleteSource ? ' (verify + delete)...' : ' (read-only)...'),
  )
  const batchSourcePaths = new Set(batch.map((group) => group.sourcePath))
  const sweep = await sweepLegacyObjects(
    supabase,
    alreadyScoped,
    batchSourcePaths,
    failures,
    limit,
  )

  const legacyRowsRemaining = candidates.length - migratedRows
  const legacyObjectsRemaining =
    sweep.keysWithLeftovers - sweep.objectsRemoved + sourcesKept

  console.log('')
  console.log('='.repeat(78))
  console.log(`  rows migrated            : ${migratedRows}`)
  console.log(`  failures                 : ${failures.length}`)
  console.log(`  legacy objects removed   : ${sourcesRemoved + sweep.objectsRemoved}`)
  console.log(`  legacy_prefix_remaining  : ${legacyRowsRemaining}`)
  console.log(`  legacy_objects_remaining : ${legacyObjectsRemaining}`)
  console.log('='.repeat(78))

  if (failures.length > 0) {
    console.log('')
    console.log('Failures (document id / step / reason):')
    for (const failure of failures) {
      console.log(`  ${failure.documentId}  [${failure.step}]  ${failure.reason}`)
      console.log(`    path: ${failure.storagePath}`)
    }
  }

  // The gate needs BOTH counters at zero: rows still on legacy pointers make
  // documents unreadable after Phase C, and leftover legacy objects keep the
  // uploader-scoped read hole open.
  const gateOpen =
    legacyRowsRemaining === 0 && legacyObjectsRemaining === 0 && failures.length === 0

  console.log('')
  if (gateOpen) {
    console.log(
      apply
        ? 'PHASE C GATE: OPEN. Re-run with --dry-run to confirm, then apply the Phase C migration.'
        : 'PHASE C GATE: OPEN. Zero legacy-prefix rows and zero leftover legacy objects; the Phase C migration may be applied.',
    )
  } else {
    const next =
      legacyRowsRemaining > 0
        ? 'Run with --apply until legacy_prefix_remaining reaches 0.'
        : legacyObjectsRemaining > 0
          ? 'Run with --apply --delete-source to remove the leftover legacy objects.'
          : 'Resolve the failures above and re-run.'
    console.log(
      `PHASE C GATE: CLOSED. Do NOT drop documents_select_own / documents_insert_own yet. ${next}`,
    )
  }

  // Non-zero exit on failures so a CI/ops wrapper notices, but only AFTER the
  // full report has been printed.
  if (failures.length > 0) process.exitCode = 1
}

main().catch((err) => {
  console.error(err)
  process.exit(1)
})