scripts/migrate_legacy_bare_image_paths.ts
Rama main · réplica de lectura del repositorio original.
/*
* Migrate legacy root-level image object keys (no `/`) to ownership-bearing paths.
*
* Targets:
* apps.icon_url → org/{owner_org}/{app_id}/icon
* orgs.logo → org/{id}/logo/{originalBareName}
* users.image_url → {user_id}/{originalBareName}
*
* Known placeholder bare names (capgo.png, …) are cleared to '' (no storage copy).
*
* Dry run:
* bun run admin:migrate-legacy-bare-image-paths
*
* Apply:
* bun run admin:migrate-legacy-bare-image-paths --apply
*
* Optional:
* bun run admin:migrate-legacy-bare-image-paths --apply --limit=100
* bun run admin:migrate-legacy-bare-image-paths --apply --delete-source
* bun run admin:migrate-legacy-bare-image-paths --apply --env-file=./internal/cloudflare/.env.preprod
*/
import { mkdir, writeFile } from 'node:fs/promises'
import type { ImageStore } from './admin_image_storage.ts'
import { dirname } from 'node:path'
import { createImageStore } from './admin_image_storage.ts'
import {
asyncPool,
createSupabaseServiceClient,
DEFAULT_ENV_FILE,
getArgValue,
loadEnv,
parsePositiveInteger,
} from './admin_stripe_backfill_utils.ts'
const DEFAULT_CONCURRENCY = 4
const DEFAULT_PAGE_SIZE = 1000
const FAILURE_OUTPUT = './tmp/legacy_bare_image_path_failures.json'
const RESULT_OUTPUT = './tmp/legacy_bare_image_path_results.json'
/** Shared default icons historically stored as bare names — clear, do not copy. */
const PLACEHOLDER_BARE_NAMES = new Set([
'capgo.png',
'/capgo.png',
])
type SupabaseClient = ReturnType<typeof createSupabaseServiceClient>
// Images are stored in R2 (S3_* env vars), not Supabase Storage.
let images: ImageStore
type Kind = 'app_icon' | 'org_logo' | 'user_avatar'
interface LegacyRow {
kind: Kind
id: string
ownerKey: string
/** Exact DB column value for compare-and-set updates. */
rawValue: string
/** Normalized storage object key (no leading slash / images/ prefix). */
sourcePath: string
targetPath: string | null
action: 'copy' | 'clear'
}
interface MigrateResult {
kind: Kind
id: string
sourcePath: string
targetPath: string | null
status: 'dry_run' | 'updated' | 'cleared' | 'already_migrated'
deletedSource: boolean
}
interface MigrateFailure {
kind: Kind
id: string
sourcePath: string
error: string
}
function printHelp() {
console.log(`Migrate legacy bare image paths to ownership-bearing keys.
Usage:
bun run admin:migrate-legacy-bare-image-paths [options]
Options:
--apply Copy objects / update DB. Without this, dry-run only.
--delete-source After a successful copy+update, remove the bare object
when no remaining DB row still references it.
--limit=N Process at most N legacy rows (apps+orgs+users combined).
--concurrency=N Copy/update concurrency. Default: ${DEFAULT_CONCURRENCY}.
--env-file=PATH Env file to load. Default: ${DEFAULT_ENV_FILE}.
--help Show this help.
Required env:
SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY
`)
}
function normalizeStoredPath(raw: string | null | undefined) {
if (!raw)
return ''
const trimmed = raw.trim()
if (!trimmed)
return ''
if (trimmed.includes('://')) {
// Absolute storage URLs may still point at a bare object key.
try {
const url = new URL(trimmed)
const match = url.pathname.match(/\/storage\/v1\/object(?:\/(?:public|sign))?\/images\/([^/?#]+)$/i)
if (match?.[1] && !match[1].includes('%2F') && !decodeURIComponent(match[1]).includes('/'))
return decodeURIComponent(match[1])
}
catch {
// ignore
}
return ''
}
// Strip leading slashes before images/ so `/images/foo` → `foo`.
return trimmed.replace(/^\/+/, '').replace(/^images\//, '').replace(/^\/+/, '')
}
/** Root-level object key with no folder segment. */
function isLegacyBarePath(normalized: string) {
return !!normalized && !normalized.includes('/')
}
function isPlaceholderBare(rawValue: string, sourcePath: string) {
const trimmed = rawValue.trim()
return PLACEHOLDER_BARE_NAMES.has(sourcePath)
|| PLACEHOLDER_BARE_NAMES.has(trimmed)
|| PLACEHOLDER_BARE_NAMES.has(`/${sourcePath}`)
}
/**
* PostgREST prefilter: bare names, leading-slash bare names, or images/<bare>.
* Client still applies normalizeStoredPath + isLegacyBarePath.
*/
function applyLegacyImageColumnFilter(
query: { not: (...args: any[]) => any, neq: (...args: any[]) => any, or: (...args: any[]) => any },
column: string,
) {
return query
.not(column, 'is', null)
.neq(column, '')
.not(column, 'like', '%://%')
.or([
`${column}.not.like.*/%*`,
`and(${column}.like./%,${column}.not.like./%/%)`,
`and(${column}.like.images/%,${column}.not.like.images/%/%)`,
`and(${column}.like./images/%,${column}.not.like./images/%/%)`,
].join(','))
}
function targetForApp(ownerOrg: string, appId: string) {
return `org/${ownerOrg}/${appId}/icon`
}
function targetForOrgLogo(orgId: string, bareName: string) {
const safe = bareName.replace(/[^\w.\-]+/g, '_')
return `org/${orgId}/logo/${safe || 'logo'}`
}
function targetForUser(userId: string, bareName: string) {
const safe = bareName.replace(/[^\w.\-]+/g, '_')
return `${userId}/${safe || 'avatar'}`
}
async function writeJson(path: string, value: unknown) {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
}
async function listLegacyApps(supabase: SupabaseClient, limit: number | null) {
const rows: LegacyRow[] = []
let from = 0
while (limit === null || rows.length < limit) {
const pageSize = limit === null
? DEFAULT_PAGE_SIZE
: Math.min(DEFAULT_PAGE_SIZE, limit - rows.length)
const { data, error } = await applyLegacyImageColumnFilter(
supabase.from('apps').select('app_id, owner_org, icon_url'),
'icon_url',
)
.range(from, from + pageSize - 1)
.order('app_id', { ascending: true })
if (error)
throw error
if (!data?.length)
break
for (const app of data) {
const rawValue = app.icon_url ?? ''
const sourcePath = normalizeStoredPath(rawValue)
if (!isLegacyBarePath(sourcePath))
continue
const clear = isPlaceholderBare(rawValue, sourcePath)
rows.push({
kind: 'app_icon',
id: app.app_id,
ownerKey: app.owner_org,
rawValue,
sourcePath,
targetPath: clear ? null : targetForApp(app.owner_org, app.app_id),
action: clear ? 'clear' : 'copy',
})
if (limit !== null && rows.length >= limit)
break
}
if (data.length < pageSize)
break
from += pageSize
}
return rows
}
async function listLegacyOrgs(supabase: SupabaseClient, remaining: number | null) {
const rows: LegacyRow[] = []
if (remaining !== null && remaining <= 0)
return rows
let from = 0
while (remaining === null || rows.length < remaining) {
const pageSize = remaining === null
? DEFAULT_PAGE_SIZE
: Math.min(DEFAULT_PAGE_SIZE, remaining - rows.length)
const { data, error } = await applyLegacyImageColumnFilter(
supabase.from('orgs').select('id, logo'),
'logo',
)
.range(from, from + pageSize - 1)
.order('id', { ascending: true })
if (error)
throw error
if (!data?.length)
break
for (const org of data) {
const rawValue = org.logo ?? ''
const sourcePath = normalizeStoredPath(rawValue)
if (!isLegacyBarePath(sourcePath))
continue
const clear = isPlaceholderBare(rawValue, sourcePath)
rows.push({
kind: 'org_logo',
id: org.id,
ownerKey: org.id,
rawValue,
sourcePath,
targetPath: clear ? null : targetForOrgLogo(org.id, sourcePath),
action: clear ? 'clear' : 'copy',
})
if (remaining !== null && rows.length >= remaining)
break
}
if (data.length < pageSize)
break
from += pageSize
}
return rows
}
async function listLegacyUsers(supabase: SupabaseClient, remaining: number | null) {
const rows: LegacyRow[] = []
if (remaining !== null && remaining <= 0)
return rows
let from = 0
while (remaining === null || rows.length < remaining) {
const pageSize = remaining === null
? DEFAULT_PAGE_SIZE
: Math.min(DEFAULT_PAGE_SIZE, remaining - rows.length)
const { data, error } = await applyLegacyImageColumnFilter(
supabase.from('users').select('id, image_url'),
'image_url',
)
.range(from, from + pageSize - 1)
.order('id', { ascending: true })
if (error)
throw error
if (!data?.length)
break
for (const user of data) {
const rawValue = user.image_url ?? ''
const sourcePath = normalizeStoredPath(rawValue)
if (!isLegacyBarePath(sourcePath))
continue
const clear = isPlaceholderBare(rawValue, sourcePath)
rows.push({
kind: 'user_avatar',
id: user.id,
ownerKey: user.id,
rawValue,
sourcePath,
targetPath: clear ? null : targetForUser(user.id, sourcePath),
action: clear ? 'clear' : 'copy',
})
if (remaining !== null && rows.length >= remaining)
break
}
if (data.length < pageSize)
break
from += pageSize
}
return rows
}
async function storageObjectExists(supabase: SupabaseClient, path: string) {
if (await images.exists(path))
return true
// Images live in R2 now. One still only in Supabase was not migrated yet:
// fail the row instead of letting the caller clear its database pointer.
const { data, error } = await supabase.storage.from('images').createSignedUrl(path, 60)
if (error) {
if (/not found|404|Object not found|does not exist/i.test(error.message))
return false
throw error
}
if (data?.signedUrl)
throw new Error(`"${path}" exists only in Supabase Storage. Run admin:migrate-supabase-images-to-r2 first.`)
return false
}
async function copyObject(
_supabase: SupabaseClient,
fromPath: string,
toPath: string,
options?: { force?: boolean },
) {
if (fromPath === toPath)
return
const existsTarget = await images.exists(toPath)
if (existsTarget && !options?.force)
return
// Copy overwrites the target in place; never remove-then-copy so a failed copy cannot destroy it.
await images.copy(fromPath, toPath)
}
/**
* One-shot scan: normalized bare path → how many DB rows still reference it.
* Keyset pagination (ordered by PK) so concurrent writes cannot skip pages.
* Includes padded/alias spellings and absolute storage URLs that normalize to a bare key.
*/
async function buildBareReferenceIndex(supabase: SupabaseClient): Promise<Map<string, number>> {
const counts = new Map<string, number>()
async function scanTable(
table: 'apps' | 'orgs' | 'users',
column: string,
idColumn: string,
) {
let lastId: string | null = null
while (true) {
let query: any = supabase
.from(table)
.select(`${idColumn}, ${column}`)
.order(idColumn, { ascending: true })
.limit(DEFAULT_PAGE_SIZE)
query = applyLegacyImageColumnFilter(query, column)
if (lastId)
query = query.gt(idColumn, lastId)
const { data, error } = await query
if (error)
throw error
if (!data?.length)
break
for (const row of data as Record<string, unknown>[]) {
lastId = String(row[idColumn] ?? '')
const path = normalizeStoredPath(String(row[column] ?? ''))
if (!isLegacyBarePath(path))
continue
counts.set(path, (counts.get(path) ?? 0) + 1)
}
if (data.length < DEFAULT_PAGE_SIZE)
break
}
}
// Absolute storage URLs are excluded by the legacy filter; scan them separately.
async function scanStorageUrlBareRefs(
table: 'apps' | 'orgs' | 'users',
column: string,
idColumn: string,
) {
let lastId: string | null = null
while (true) {
let query = supabase
.from(table)
.select(`${idColumn}, ${column}`)
.not(column, 'is', null)
.like(column, '%://%')
.like(column, '%/images/%')
.order(idColumn, { ascending: true })
.limit(DEFAULT_PAGE_SIZE)
if (lastId)
query = query.gt(idColumn, lastId)
const { data, error } = await query
if (error)
throw error
if (!data?.length)
break
for (const row of data as Record<string, unknown>[]) {
lastId = String(row[idColumn] ?? '')
const path = normalizeStoredPath(String(row[column] ?? ''))
if (!isLegacyBarePath(path))
continue
counts.set(path, (counts.get(path) ?? 0) + 1)
}
if (data.length < DEFAULT_PAGE_SIZE)
break
}
}
await scanTable('apps', 'icon_url', 'app_id')
await scanTable('orgs', 'logo', 'id')
await scanTable('users', 'image_url', 'id')
await scanStorageUrlBareRefs('apps', 'icon_url', 'app_id')
await scanStorageUrlBareRefs('orgs', 'logo', 'id')
await scanStorageUrlBareRefs('users', 'image_url', 'id')
return counts
}
/** Synchronously consume one DB reference after a successful migrate/clear. */
function consumeBareReference(index: Map<string, number>, normalizedBarePath: string) {
const next = Math.max(0, (index.get(normalizedBarePath) ?? 0) - 1)
index.set(normalizedBarePath, next)
return next
}
async function updateRow(supabase: SupabaseClient, row: LegacyRow, newValue: string) {
if (row.kind === 'app_icon') {
const { data, error } = await supabase
.from('apps')
.update({ icon_url: newValue })
.eq('app_id', row.id)
.eq('owner_org', row.ownerKey)
.eq('icon_url', row.rawValue)
.select('app_id')
if (error)
throw error
return (data?.length ?? 0) > 0
}
if (row.kind === 'org_logo') {
const { data, error } = await supabase
.from('orgs')
.update({ logo: newValue })
.eq('id', row.id)
.eq('logo', row.rawValue)
.select('id')
if (error)
throw error
return (data?.length ?? 0) > 0
}
const { data, error } = await supabase
.from('users')
.update({ image_url: newValue })
.eq('id', row.id)
.eq('image_url', row.rawValue)
.select('id')
if (error)
throw error
return (data?.length ?? 0) > 0
}
/**
* Refresh destination from the current bare source before CAS retry.
* Missing source → clear the DB pointer instead of writing a broken path.
*/
async function refreshOwnedPathFromBareSource(
supabase: SupabaseClient,
currentPath: string,
retryTarget: string,
clear: boolean,
): Promise<{ action: 'clear' | 'copy', target: string }> {
if (clear)
return { action: 'clear', target: '' }
const sourceExists = await storageObjectExists(supabase, currentPath)
if (!sourceExists)
return { action: 'clear', target: '' }
// Always force-refresh so a same-owner icon_url change doesn't keep stale bytes.
await copyObject(supabase, currentPath, retryTarget, { force: true })
return { action: 'copy', target: retryTarget }
}
/**
* When CAS misses (org transfer / raw value drift), re-read and retry once
* with the current owner + exact DB value.
*/
async function updateRowWithRetry(
supabase: SupabaseClient,
row: LegacyRow,
newValue: string,
): Promise<{ updated: boolean, targetPath: string | null }> {
if (await updateRow(supabase, row, newValue))
return { updated: true, targetPath: newValue || null }
if (row.kind === 'app_icon') {
const { data: current, error } = await supabase
.from('apps')
.select('app_id, owner_org, icon_url')
.eq('app_id', row.id)
.maybeSingle()
if (error)
throw error
if (!current)
return { updated: false, targetPath: newValue || null }
const currentRaw = current.icon_url ?? ''
const currentPath = normalizeStoredPath(currentRaw)
if (!isLegacyBarePath(currentPath))
return { updated: false, targetPath: newValue || null }
const clear = isPlaceholderBare(currentRaw, currentPath)
const ownedTarget = clear ? '' : targetForApp(current.owner_org, row.id)
const refreshed = await refreshOwnedPathFromBareSource(
supabase,
currentPath,
ownedTarget,
clear,
)
const retryRow: LegacyRow = {
...row,
ownerKey: current.owner_org,
rawValue: currentRaw,
sourcePath: currentPath,
targetPath: refreshed.action === 'clear' ? null : refreshed.target,
action: refreshed.action === 'clear' ? 'clear' : 'copy',
}
const updated = await updateRow(supabase, retryRow, refreshed.target)
return {
updated,
targetPath: refreshed.action === 'clear' ? null : refreshed.target,
}
}
if (row.kind === 'org_logo') {
const { data: current, error } = await supabase
.from('orgs')
.select('id, logo')
.eq('id', row.id)
.maybeSingle()
if (error)
throw error
if (!current)
return { updated: false, targetPath: newValue || null }
const currentRaw = current.logo ?? ''
const currentPath = normalizeStoredPath(currentRaw)
if (!isLegacyBarePath(currentPath))
return { updated: false, targetPath: newValue || null }
const clear = isPlaceholderBare(currentRaw, currentPath)
const ownedTarget = clear ? '' : targetForOrgLogo(row.id, currentPath)
const refreshed = await refreshOwnedPathFromBareSource(
supabase,
currentPath,
ownedTarget,
clear,
)
const retryRow: LegacyRow = {
...row,
rawValue: currentRaw,
sourcePath: currentPath,
targetPath: refreshed.action === 'clear' ? null : refreshed.target,
action: refreshed.action === 'clear' ? 'clear' : 'copy',
}
const updated = await updateRow(supabase, retryRow, refreshed.target)
return {
updated,
targetPath: refreshed.action === 'clear' ? null : refreshed.target,
}
}
const { data: current, error } = await supabase
.from('users')
.select('id, image_url')
.eq('id', row.id)
.maybeSingle()
if (error)
throw error
if (!current)
return { updated: false, targetPath: newValue || null }
const currentRaw = current.image_url ?? ''
const currentPath = normalizeStoredPath(currentRaw)
if (!isLegacyBarePath(currentPath))
return { updated: false, targetPath: newValue || null }
const clear = isPlaceholderBare(currentRaw, currentPath)
const ownedTarget = clear ? '' : targetForUser(row.id, currentPath)
const refreshed = await refreshOwnedPathFromBareSource(
supabase,
currentPath,
ownedTarget,
clear,
)
const retryRow: LegacyRow = {
...row,
rawValue: currentRaw,
sourcePath: currentPath,
targetPath: refreshed.action === 'clear' ? null : refreshed.target,
action: refreshed.action === 'clear' ? 'clear' : 'copy',
}
const updated = await updateRow(supabase, retryRow, refreshed.target)
return {
updated,
targetPath: refreshed.action === 'clear' ? null : refreshed.target,
}
}
async function processRow(
supabase: SupabaseClient,
row: LegacyRow,
apply: boolean,
deleteSource: boolean,
bareRefIndex: Map<string, number> | null,
): Promise<MigrateResult> {
if (row.action === 'clear') {
if (!apply) {
return {
kind: row.kind,
id: row.id,
sourcePath: row.sourcePath,
targetPath: null,
status: 'dry_run',
deletedSource: false,
}
}
const { updated, targetPath } = await updateRowWithRetry(supabase, row, '')
let deletedSource = false
if (updated && deleteSource && bareRefIndex) {
const remaining = consumeBareReference(bareRefIndex, row.sourcePath)
if (remaining === 0 && !isPlaceholderBare(row.rawValue, row.sourcePath)) {
await images.remove([row.sourcePath])
deletedSource = true
}
}
return {
kind: row.kind,
id: row.id,
sourcePath: row.sourcePath,
targetPath,
status: updated ? 'cleared' : 'already_migrated',
deletedSource,
}
}
const targetPath = row.targetPath
if (!targetPath)
throw new Error('Missing target path for copy action')
if (!apply) {
return {
kind: row.kind,
id: row.id,
sourcePath: row.sourcePath,
targetPath,
status: 'dry_run',
deletedSource: false,
}
}
const sourceExists = await storageObjectExists(supabase, row.sourcePath)
if (!sourceExists) {
// Object already gone — clear broken pointer so it is not a bare legacy ref.
const { updated, targetPath: clearedTarget } = await updateRowWithRetry(supabase, row, '')
if (updated && bareRefIndex)
consumeBareReference(bareRefIndex, row.sourcePath)
return {
kind: row.kind,
id: row.id,
sourcePath: row.sourcePath,
targetPath: clearedTarget,
status: updated ? 'cleared' : 'already_migrated',
deletedSource: false,
}
}
await copyObject(supabase, row.sourcePath, targetPath)
const { updated, targetPath: finalTarget } = await updateRowWithRetry(supabase, row, targetPath)
if (!updated) {
return {
kind: row.kind,
id: row.id,
sourcePath: row.sourcePath,
targetPath: finalTarget ?? targetPath,
status: 'already_migrated',
deletedSource: false,
}
}
let deletedSource = false
if (deleteSource && bareRefIndex) {
const remaining = consumeBareReference(bareRefIndex, row.sourcePath)
if (remaining === 0) {
await images.remove([row.sourcePath])
deletedSource = true
}
}
return {
kind: row.kind,
id: row.id,
sourcePath: row.sourcePath,
targetPath: finalTarget ?? targetPath,
status: 'updated',
deletedSource,
}
}
async function main() {
const args = process.argv.slice(2)
if (args.includes('--help') || args.includes('-h')) {
printHelp()
return
}
const apply = args.includes('--apply')
const deleteSource = args.includes('--delete-source')
const envFile = getArgValue(args, '--env-file') ?? DEFAULT_ENV_FILE
const concurrency = parsePositiveInteger(getArgValue(args, '--concurrency'), 'concurrency', DEFAULT_CONCURRENCY)
const limitRaw = getArgValue(args, '--limit')
const limit = limitRaw ? parsePositiveInteger(limitRaw, 'limit', 0) : null
if (limit === 0)
throw new Error('--limit must be > 0')
const fileEnv = await loadEnv(envFile)
const env = { ...fileEnv, ...process.env }
const supabase = createSupabaseServiceClient(env)
images = createImageStore(env)
console.log(apply ? 'Mode: APPLY' : 'Mode: DRY RUN')
console.log(`Env file: ${envFile}`)
console.log(`Concurrency: ${concurrency}`)
if (limit !== null)
console.log(`Limit: ${limit}`)
if (deleteSource)
console.log('Delete source: yes (when unreferenced)')
const appRows = await listLegacyApps(supabase, limit)
const remainingAfterApps = limit === null ? null : Math.max(0, limit - appRows.length)
const orgRows = await listLegacyOrgs(supabase, remainingAfterApps)
const remainingAfterOrgs = remainingAfterApps === null
? null
: Math.max(0, remainingAfterApps - orgRows.length)
const userRows = await listLegacyUsers(supabase, remainingAfterOrgs)
const allRows = [...appRows, ...orgRows, ...userRows]
console.log(`Found legacy bare paths: apps=${appRows.length} orgs=${orgRows.length} users=${userRows.length} total=${allRows.length}`)
let bareRefIndex: Map<string, number> | null = null
if (apply && deleteSource) {
console.log('Building bare-path reference index (one scan)…')
bareRefIndex = await buildBareReferenceIndex(supabase)
console.log(`Indexed ${bareRefIndex.size} distinct bare object keys`)
}
const results: MigrateResult[] = []
const failures: MigrateFailure[] = []
await asyncPool(concurrency, allRows, async (row) => {
try {
const result = await processRow(supabase, row, apply, deleteSource, bareRefIndex)
results.push(result)
console.log(`[${result.status}] ${row.kind} ${row.id}: ${row.sourcePath} → ${result.targetPath ?? '(cleared)'}`)
}
catch (error) {
const message = error instanceof Error ? error.message : String(error)
failures.push({
kind: row.kind,
id: row.id,
sourcePath: row.sourcePath,
error: message,
})
console.error(`[fail] ${row.kind} ${row.id}: ${message}`)
}
})
await writeJson(RESULT_OUTPUT, results)
await writeJson(FAILURE_OUTPUT, failures)
console.log(`Done. results=${results.length} failures=${failures.length}`)
console.log(`Wrote ${RESULT_OUTPUT}`)
console.log(`Wrote ${FAILURE_OUTPUT}`)
if (failures.length > 0)
process.exitCode = 1
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})