mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
273 lines
7.9 KiB
TypeScript
273 lines
7.9 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import Database from 'better-sqlite3'
|
|
|
|
type Target =
|
|
| { kind: 'sqlite'; path: string }
|
|
| { kind: 'd1'; database: string; remote: boolean; env?: string }
|
|
|
|
interface Options {
|
|
apply: boolean
|
|
target: Target
|
|
}
|
|
|
|
interface Summary {
|
|
organizations: number
|
|
projectionRows: number
|
|
projectionFiles: number
|
|
projectionBytes: number
|
|
activeFiles: number
|
|
activeFileBytes: number
|
|
activeImages: number
|
|
activeImageBytes: number
|
|
}
|
|
|
|
export function buildStorageUsageBackfillSql(now: number): string {
|
|
return `
|
|
WITH categories(category) AS (
|
|
VALUES
|
|
('photos'),
|
|
('videos'),
|
|
('music'),
|
|
('documents'),
|
|
('archives'),
|
|
('other'),
|
|
('image_hosting'),
|
|
('trash')
|
|
),
|
|
matter_totals AS (
|
|
SELECT
|
|
org_id,
|
|
CASE
|
|
WHEN trashed_at IS NOT NULL THEN 'trash'
|
|
WHEN lower(type) LIKE 'image/%' THEN 'photos'
|
|
WHEN lower(type) LIKE 'video/%' THEN 'videos'
|
|
WHEN lower(type) LIKE 'audio/%' THEN 'music'
|
|
WHEN lower(type) LIKE 'text/%'
|
|
OR lower(type) IN (
|
|
'application/epub+zip',
|
|
'application/msword',
|
|
'application/pdf',
|
|
'application/rtf',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.ms-powerpoint',
|
|
'application/vnd.oasis.opendocument.presentation',
|
|
'application/vnd.oasis.opendocument.spreadsheet',
|
|
'application/vnd.oasis.opendocument.text',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
) THEN 'documents'
|
|
WHEN lower(type) IN (
|
|
'application/gzip',
|
|
'application/vnd.rar',
|
|
'application/x-7z-compressed',
|
|
'application/x-bzip2',
|
|
'application/x-rar-compressed',
|
|
'application/x-tar',
|
|
'application/zip'
|
|
) THEN 'archives'
|
|
ELSE 'other'
|
|
END AS category,
|
|
SUM(COALESCE(size, 0)) AS bytes,
|
|
COUNT(*) AS file_count
|
|
FROM matters
|
|
WHERE status = 'active'
|
|
AND dirtype = 0
|
|
AND purged_at IS NULL
|
|
GROUP BY org_id, category
|
|
),
|
|
image_totals AS (
|
|
SELECT
|
|
org_id,
|
|
'image_hosting' AS category,
|
|
SUM(COALESCE(size, 0)) AS bytes,
|
|
COUNT(*) AS file_count
|
|
FROM image_hostings
|
|
WHERE status = 'active'
|
|
AND purged_at IS NULL
|
|
GROUP BY org_id
|
|
),
|
|
totals AS (
|
|
SELECT org_id, category, bytes, file_count FROM matter_totals
|
|
UNION ALL
|
|
SELECT org_id, category, bytes, file_count FROM image_totals
|
|
),
|
|
aggregated AS (
|
|
SELECT
|
|
org_id,
|
|
category,
|
|
SUM(bytes) AS bytes,
|
|
SUM(file_count) AS file_count
|
|
FROM totals
|
|
GROUP BY org_id, category
|
|
)
|
|
INSERT INTO storage_usage_breakdowns (org_id, category, bytes, file_count, updated_at)
|
|
SELECT
|
|
organization.id,
|
|
categories.category,
|
|
COALESCE(aggregated.bytes, 0),
|
|
COALESCE(aggregated.file_count, 0),
|
|
${now}
|
|
FROM organization
|
|
CROSS JOIN categories
|
|
LEFT JOIN aggregated
|
|
ON aggregated.org_id = organization.id
|
|
AND aggregated.category = categories.category
|
|
WHERE 1
|
|
ON CONFLICT(org_id, category) DO UPDATE SET
|
|
bytes = excluded.bytes,
|
|
file_count = excluded.file_count,
|
|
updated_at = excluded.updated_at;
|
|
`.trim()
|
|
}
|
|
|
|
const SUMMARY_SQL = `
|
|
SELECT json_object(
|
|
'organizations', (SELECT COUNT(*) FROM organization),
|
|
'projectionRows', (
|
|
SELECT COUNT(*) FROM storage_usage_breakdowns breakdown
|
|
INNER JOIN organization ON organization.id = breakdown.org_id
|
|
),
|
|
'projectionFiles', (
|
|
SELECT COALESCE(SUM(file_count), 0) FROM storage_usage_breakdowns breakdown
|
|
INNER JOIN organization ON organization.id = breakdown.org_id
|
|
),
|
|
'projectionBytes', (
|
|
SELECT COALESCE(SUM(bytes), 0) FROM storage_usage_breakdowns breakdown
|
|
INNER JOIN organization ON organization.id = breakdown.org_id
|
|
),
|
|
'activeFiles', (
|
|
SELECT COUNT(*) FROM matters
|
|
WHERE status = 'active' AND dirtype = 0 AND purged_at IS NULL
|
|
),
|
|
'activeFileBytes', (
|
|
SELECT COALESCE(SUM(size), 0) FROM matters
|
|
WHERE status = 'active' AND dirtype = 0 AND purged_at IS NULL
|
|
),
|
|
'activeImages', (
|
|
SELECT COUNT(*) FROM image_hostings
|
|
WHERE status = 'active' AND purged_at IS NULL
|
|
),
|
|
'activeImageBytes', (
|
|
SELECT COALESCE(SUM(size), 0) FROM image_hostings
|
|
WHERE status = 'active' AND purged_at IS NULL
|
|
)
|
|
) AS summary
|
|
`.trim()
|
|
|
|
function parseOptions(argv: string[]): Options {
|
|
const sqliteIndex = argv.indexOf('--sqlite')
|
|
const d1Index = argv.indexOf('--d1')
|
|
if ((sqliteIndex >= 0) === (d1Index >= 0)) usage()
|
|
if (sqliteIndex >= 0) {
|
|
const path = argv[sqliteIndex + 1]
|
|
if (!path) usage()
|
|
return { apply: argv.includes('--apply'), target: { kind: 'sqlite', path } }
|
|
}
|
|
const database = argv[d1Index + 1]
|
|
if (!database) usage()
|
|
const envIndex = argv.indexOf('--env')
|
|
return {
|
|
apply: argv.includes('--apply'),
|
|
target: {
|
|
kind: 'd1',
|
|
database,
|
|
remote: argv.includes('--remote'),
|
|
env: envIndex >= 0 ? argv[envIndex + 1] : undefined,
|
|
},
|
|
}
|
|
}
|
|
|
|
function usage(): never {
|
|
throw new Error(
|
|
'Usage: pnpm storage:backfill -- (--sqlite <path> | --d1 <database> [--remote] [--env <name>]) [--apply]',
|
|
)
|
|
}
|
|
|
|
function d1Args(target: Extract<Target, { kind: 'd1' }>): string[] {
|
|
return [
|
|
'exec',
|
|
'wrangler',
|
|
'd1',
|
|
'execute',
|
|
target.database,
|
|
target.remote ? '--remote' : '--local',
|
|
...(target.env ? ['--env', target.env] : []),
|
|
]
|
|
}
|
|
|
|
function query(target: Target): string {
|
|
if (target.kind === 'd1') {
|
|
const output = execFileSync('pnpm', [...d1Args(target), '--command', SUMMARY_SQL, '--json'], {
|
|
encoding: 'utf8',
|
|
})
|
|
const payload = JSON.parse(output) as Array<{ results?: Array<{ summary?: string }> }>
|
|
const summary = payload.flatMap((entry) => entry.results ?? []).find((row) => row.summary)?.summary
|
|
if (!summary) throw new Error('storage_usage_backfill_summary_missing')
|
|
return summary
|
|
}
|
|
const db = new Database(target.path, { readonly: true })
|
|
try {
|
|
return (db.prepare(SUMMARY_SQL).get() as { summary: string }).summary
|
|
} finally {
|
|
db.close()
|
|
}
|
|
}
|
|
|
|
function apply(target: Target, sql: string): void {
|
|
if (target.kind === 'sqlite') {
|
|
const db = new Database(target.path)
|
|
try {
|
|
db.exec(sql)
|
|
} finally {
|
|
db.close()
|
|
}
|
|
return
|
|
}
|
|
const dir = mkdtempSync(join(tmpdir(), 'zpan-storage-usage-backfill-'))
|
|
const file = join(dir, 'backfill.sql')
|
|
try {
|
|
writeFileSync(file, `${sql}\n`)
|
|
execFileSync('pnpm', [...d1Args(target), '--file', file], { stdio: 'inherit' })
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
function main(): void {
|
|
const options = parseOptions(process.argv.slice(2))
|
|
const before = JSON.parse(query(options.target)) as Summary
|
|
console.log(JSON.stringify({ mode: options.apply ? 'apply' : 'dry-run', before }, null, 2))
|
|
if (!options.apply) return
|
|
apply(options.target, buildStorageUsageBackfillSql(Date.now()))
|
|
const after = JSON.parse(query(options.target)) as Summary
|
|
assertSummary(after)
|
|
console.log(JSON.stringify({ mode: 'complete', before, after }, null, 2))
|
|
}
|
|
|
|
function assertSummary(summary: Summary): void {
|
|
const expectedFiles = summary.activeFiles + summary.activeImages
|
|
const expectedBytes = summary.activeFileBytes + summary.activeImageBytes
|
|
if (
|
|
summary.projectionRows !== summary.organizations * 8 ||
|
|
summary.projectionFiles !== expectedFiles ||
|
|
summary.projectionBytes !== expectedBytes
|
|
) {
|
|
throw new Error(
|
|
`storage_usage_backfill_validation_failed:${JSON.stringify({
|
|
expectedRows: summary.organizations * 8,
|
|
actualRows: summary.projectionRows,
|
|
expectedFiles,
|
|
actualFiles: summary.projectionFiles,
|
|
expectedBytes,
|
|
actualBytes: summary.projectionBytes,
|
|
})}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) main()
|