feat: smart file upload with TUS resumable and .redirect.yaml pointers

- Supabase Storage auto-selects upload method by file size:
  < 100 MB standard POST, >= 100 MB TUS resumable (6 MB chunks + retry)
- Signed URL generation for private bucket access (1-hour expiry)
- New `upload-raw` command with size routing: small text stays in git,
  large/media files go to cloud with .redirect.yaml pointer
- New `signed-url` command for generating access links
- File resolver supports both .redirect.yaml (v0.9+) and .redirect (legacy)
- Redirect format upgraded: 10 fields with full metadata
- All migration commands (mirror, redirect, restore, clean) handle both formats
This commit is contained in:
Garry Tan
2026-04-11 17:44:08 -10:00
parent 80d00e7ff2
commit c2a14c9a44
3 changed files with 328 additions and 29 deletions

View File

@@ -1,8 +1,12 @@
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, unlinkSync } from 'fs'; import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
import { join, relative, extname, basename } from 'path'; import { join, relative, extname, basename, dirname } from 'path';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import type { BrainEngine } from '../core/engine.ts'; import type { BrainEngine } from '../core/engine.ts';
import * as db from '../core/db.ts'; import * as db from '../core/db.ts';
import { humanSize } from '../core/file-resolver.ts';
/** Size threshold: files >= 100 MB use TUS resumable upload */
const SIZE_THRESHOLD = 100 * 1024 * 1024;
interface FileRecord { interface FileRecord {
id: number; id: number;
@@ -67,20 +71,28 @@ export async function runFiles(engine: BrainEngine, args: string[]) {
case 'clean': case 'clean':
await cleanFiles(args.slice(1)); await cleanFiles(args.slice(1));
break; break;
case 'upload-raw':
await uploadRaw(args.slice(1));
break;
case 'signed-url':
await signedUrl(args.slice(1));
break;
case 'status': case 'status':
await filesStatus(args.slice(1)); await filesStatus(args.slice(1));
break; break;
default: default:
console.error(`Usage: gbrain files <list|upload|sync|verify|mirror|unmirror|redirect|restore|clean|status> [args]`); console.error(`Usage: gbrain files <command> [args]`);
console.error(` list [slug] List files for a page (or all)`); console.error(` list [slug] List files for a page (or all)`);
console.error(` upload <file> --page <slug> Upload file linked to page`); console.error(` upload <file> --page <slug> Upload file linked to page`);
console.error(` upload-raw <file> --page <slug> [--type <type>] Smart upload with .redirect.yaml pointer`);
console.error(` signed-url <path> Generate signed URL for stored file`);
console.error(` sync <dir> Upload directory to storage`); console.error(` sync <dir> Upload directory to storage`);
console.error(` verify Verify all uploads match local`); console.error(` verify Verify all uploads match local`);
console.error(` mirror <dir> [--dry-run] Mirror files to cloud storage`); console.error(` mirror <dir> [--dry-run] Mirror files to cloud storage`);
console.error(` unmirror <dir> Remove mirror marker (files stay in storage)`); console.error(` unmirror <dir> Remove mirror marker (files stay in storage)`);
console.error(` redirect <dir> [--dry-run] Replace files with .redirect breadcrumbs`); console.error(` redirect <dir> [--dry-run] Replace files with .redirect.yaml pointers`);
console.error(` restore <dir> Download from storage, recreate local files`); console.error(` restore <dir> Download from storage, recreate local files`);
console.error(` clean <dir> [--yes] Delete .redirect breadcrumbs (irreversible)`); console.error(` clean <dir> [--yes] Delete redirect pointers (irreversible)`);
console.error(` status Show migration status of directories`); console.error(` status Show migration status of directories`);
process.exit(1); process.exit(1);
} }
@@ -138,6 +150,8 @@ async function uploadFile(args: string[]) {
const { createStorage } = await import('../core/storage.ts'); const { createStorage } = await import('../core/storage.ts');
const storage = await createStorage(config.storage as any); const storage = await createStorage(config.storage as any);
const content = readFileSync(filePath); const content = readFileSync(filePath);
const method = content.length >= SIZE_THRESHOLD ? 'TUS resumable' : 'standard';
console.log(`Uploading ${humanSize(stat.size)} via ${method}...`);
await storage.upload(storagePath, content, mimeType || undefined); await storage.upload(storagePath, content, mimeType || undefined);
} }
@@ -150,7 +164,135 @@ async function uploadFile(args: string[]) {
mime_type = EXCLUDED.mime_type mime_type = EXCLUDED.mime_type
`; `;
console.log(`Uploaded: ${storagePath} (${Math.round(stat.size / 1024)}KB)`); console.log(`Uploaded: ${storagePath} (${humanSize(stat.size)})`);
}
/**
* Smart upload with size routing and .redirect.yaml pointer creation.
*
* Size routing:
* < 100 MB text/PDF → stays in git (brain repo), no cloud upload
* >= 100 MB OR media → upload to cloud storage, create .redirect.yaml pointer
*
* The .redirect.yaml pointer stays in the brain repo so git tracks what was stored.
*/
async function uploadRaw(args: string[]) {
const filePath = args.find(a => !a.startsWith('--'));
const pageSlug = args.find((a, i) => args[i - 1] === '--page') || null;
const fileType = args.find((a, i) => args[i - 1] === '--type') || null;
const noPointer = args.includes('--no-pointer');
if (!filePath || !existsSync(filePath)) {
console.error('Usage: gbrain files upload-raw <file> --page <slug> [--type <type>] [--no-pointer]');
process.exit(1);
}
const stat = statSync(filePath);
const filename = basename(filePath);
const mimeType = getMimeType(filePath);
const isMedia = mimeType?.startsWith('video/') || mimeType?.startsWith('audio/') || mimeType?.startsWith('image/');
const needsCloud = stat.size >= SIZE_THRESHOLD || isMedia;
if (!needsCloud) {
// Small text/PDF files stay in git
console.log(JSON.stringify({
success: true,
storage: 'git',
path: filePath,
size: stat.size,
size_human: humanSize(stat.size),
}));
return;
}
// Upload to cloud storage
const { loadConfig } = await import('../core/config.ts');
const config = loadConfig();
if (!config?.storage) {
console.error('No storage backend configured. Run gbrain init with storage settings.');
console.error('Or use gbrain files upload for manual uploads.');
process.exit(1);
}
const { createStorage } = await import('../core/storage.ts');
const storage = await createStorage(config.storage as any);
const content = readFileSync(filePath);
const hash = createHash('sha256').update(content).digest('hex');
const storagePath = pageSlug ? `${pageSlug}/${filename}` : `unsorted/${hash.slice(0, 8)}-${filename}`;
const bucket = (config.storage as any).bucket || 'brain-files';
const method = content.length >= SIZE_THRESHOLD ? 'TUS resumable' : 'standard';
console.error(`Uploading ${humanSize(stat.size)} via ${method}...`);
await storage.upload(storagePath, content, mimeType || undefined);
// Create .redirect.yaml pointer in the brain repo
let pointerPath: string | null = null;
if (!noPointer && pageSlug) {
const { stringify } = await import('../core/yaml-lite.ts');
const pointer = stringify({
target: `supabase://${bucket}/${storagePath}`,
bucket,
storage_path: storagePath,
size: stat.size,
size_human: humanSize(stat.size),
hash: `sha256:${hash}`,
mime: mimeType || 'application/octet-stream',
uploaded: new Date().toISOString(),
...(fileType ? { type: fileType } : {}),
});
// Write pointer next to the page that references it
pointerPath = `${pageSlug}/${filename}.redirect.yaml`;
console.error(`Pointer: ${pointerPath}`);
// Note: the caller is responsible for writing the pointer file
// to the brain repo at the correct location
}
// Record in DB
const sql = db.getConnection();
await sql`
INSERT INTO files (page_slug, filename, storage_path, mime_type, size_bytes, content_hash, metadata)
VALUES (${pageSlug}, ${filename}, ${storagePath}, ${mimeType}, ${stat.size}, ${'sha256:' + hash},
${JSON.stringify({ type: fileType, upload_method: method })}::jsonb)
ON CONFLICT (storage_path) DO UPDATE SET
content_hash = EXCLUDED.content_hash,
size_bytes = EXCLUDED.size_bytes,
mime_type = EXCLUDED.mime_type
`;
// Output JSON for scripting
console.log(JSON.stringify({
success: true,
storage: 'supabase',
storagePath,
bucket,
reference: `supabase://${bucket}/${storagePath}`,
pointerPath,
size: stat.size,
size_human: humanSize(stat.size),
hash: `sha256:${hash}`,
upload_method: method,
}));
}
/** Generate a signed URL for a stored file */
async function signedUrl(args: string[]) {
const storagePath = args.find(a => !a.startsWith('--'));
if (!storagePath) {
console.error('Usage: gbrain files signed-url <storage-path>');
process.exit(1);
}
const { loadConfig } = await import('../core/config.ts');
const config = loadConfig();
if (!config?.storage) {
console.error('No storage backend configured.');
process.exit(1);
}
const { createStorage } = await import('../core/storage.ts');
const storage = await createStorage(config.storage as any);
const url = await storage.getUrl(storagePath);
console.log(url);
} }
async function syncFiles(dir?: string) { async function syncFiles(dir?: string) {
@@ -343,14 +485,20 @@ async function redirectFiles(args: string[]) {
} }
} }
const breadcrumb = stringify({ const stat = statSync(filePath);
moved_to: 'storage', const mimeType = getMimeType(filePath);
bucket: marker.bucket || 'brain-files', const bucket = marker.bucket || 'brain-files';
path: relPath, const pointer = stringify({
moved_at: new Date().toISOString().split('T')[0], target: `supabase://${bucket}/${relPath}`,
original_hash: `sha256:${hash}`, bucket,
storage_path: relPath,
size: stat.size,
size_human: humanSize(stat.size),
hash: `sha256:${hash}`,
mime: mimeType || 'application/octet-stream',
uploaded: new Date().toISOString(),
}); });
writeFileSync(filePath + '.redirect', breadcrumb); writeFileSync(filePath + '.redirect.yaml', pointer);
unlinkSync(filePath); unlinkSync(filePath);
redirected++; redirected++;
} }
@@ -380,7 +528,7 @@ async function restoreFiles(args: string[]) {
if (entry.startsWith('.')) continue; if (entry.startsWith('.')) continue;
const full = join(d, entry); const full = join(d, entry);
if (statSync(full).isDirectory()) findRedirects(full); if (statSync(full).isDirectory()) findRedirects(full);
else if (entry.endsWith('.redirect')) redirectFiles.push(full); else if (entry.endsWith('.redirect.yaml') || entry.endsWith('.redirect')) redirectFiles.push(full);
} }
} }
findRedirects(dir); findRedirects(dir);
@@ -389,9 +537,10 @@ async function restoreFiles(args: string[]) {
let failed = 0; let failed = 0;
for (const redirectPath of redirectFiles) { for (const redirectPath of redirectFiles) {
const info = parseYaml(readFileSync(redirectPath, 'utf-8')); const info = parseYaml(readFileSync(redirectPath, 'utf-8'));
const originalPath = redirectPath.replace(/\.redirect$/, ''); const originalPath = redirectPath.replace(/\.redirect(\.yaml)?$/, '');
try { try {
const data = await storage.download(info.path); const storagePath = info.storage_path || info.path; // v0.9 or legacy format
const data = await storage.download(storagePath);
writeFileSync(originalPath, data); writeFileSync(originalPath, data);
unlinkSync(redirectPath); unlinkSync(redirectPath);
restored++; restored++;
@@ -411,7 +560,7 @@ async function cleanFiles(args: string[]) {
if (!dir || !existsSync(dir)) { console.error('Usage: gbrain files clean <dir> [--yes]'); process.exit(1); } if (!dir || !existsSync(dir)) { console.error('Usage: gbrain files clean <dir> [--yes]'); process.exit(1); }
if (!confirmed) { if (!confirmed) {
console.error('WARNING: This permanently removes .redirect breadcrumbs.'); console.error('WARNING: This permanently removes redirect pointers.');
console.error('After this, files are only accessible from cloud storage.'); console.error('After this, files are only accessible from cloud storage.');
console.error('Git history still has the originals if you need them.'); console.error('Git history still has the originals if you need them.');
console.error('Run with --yes to confirm.'); console.error('Run with --yes to confirm.');
@@ -424,7 +573,7 @@ async function cleanFiles(args: string[]) {
if (entry.startsWith('.')) continue; if (entry.startsWith('.')) continue;
const full = join(d, entry); const full = join(d, entry);
if (statSync(full).isDirectory()) findAndClean(full); if (statSync(full).isDirectory()) findAndClean(full);
else if (entry.endsWith('.redirect')) { unlinkSync(full); cleaned++; } else if (entry.endsWith('.redirect.yaml') || entry.endsWith('.redirect')) { unlinkSync(full); cleaned++; }
} }
} }
findAndClean(dir); findAndClean(dir);
@@ -443,7 +592,7 @@ async function filesStatus(args: string[]) {
const full = join(d, entry); const full = join(d, entry);
if (entry === '.supabase') { mirrored++; continue; } if (entry === '.supabase') { mirrored++; continue; }
if (statSync(full).isDirectory()) scan(full); if (statSync(full).isDirectory()) scan(full);
else if (entry.endsWith('.redirect')) redirected++; else if (entry.endsWith('.redirect.yaml') || entry.endsWith('.redirect')) redirected++;
else if (!entry.endsWith('.md')) local++; else if (!entry.endsWith('.md')) local++;
} }
} }

View File

@@ -6,9 +6,10 @@ import type { StorageBackend } from './storage.ts';
/** /**
* Universal file reader with fallback chain: * Universal file reader with fallback chain:
* 1. Local file exists → return it * 1. Local file exists → return it
* 2. .redirect breadcrumb exists → fetch from storage * 2. .redirect.yaml pointer exists → fetch from storage (v0.9+ format)
* 3. .supabase marker in parent dir → prefer storage, fall back to local * 3. .redirect breadcrumb exists → fetch from storage (legacy v0.8 format)
* 4. None → throw * 4. .supabase marker in parent dir → prefer storage, fall back to local
* 5. None → throw
*/ */
export interface ResolvedFile { export interface ResolvedFile {
@@ -16,6 +17,21 @@ export interface ResolvedFile {
source: 'local' | 'storage' | 'redirect'; source: 'local' | 'storage' | 'redirect';
} }
/** v0.9+ redirect format (.redirect.yaml) — richer metadata */
export interface RedirectYaml {
target: string; // supabase://brain-files/{storagePath}
bucket: string;
storage_path: string;
size: number;
size_human: string;
hash: string; // sha256:...
mime: string;
uploaded: string; // ISO timestamp
source_url?: string;
type?: string; // transcript, article, image, etc.
}
/** Legacy v0.8 redirect format (.redirect) */
export interface RedirectInfo { export interface RedirectInfo {
moved_to: string; moved_to: string;
bucket: string; bucket: string;
@@ -43,16 +59,25 @@ export async function resolveFile(
return { data: readFileSync(fullPath), source: 'local' }; return { data: readFileSync(fullPath), source: 'local' };
} }
// 2. .redirect breadcrumb // 2. .redirect.yaml pointer (v0.9+ format)
const redirectPath = fullPath + '.redirect'; const yamlRedirectPath = fullPath + '.redirect.yaml';
if (existsSync(redirectPath)) { if (existsSync(yamlRedirectPath)) {
if (!storage) throw new Error(`File redirected to storage but no storage backend configured: ${filePath}`); if (!storage) throw new Error(`File redirected to storage but no storage backend configured: ${filePath}`);
const info = parseRedirect(redirectPath); const info = parseRedirectYaml(yamlRedirectPath);
const data = await storage.download(info.storage_path);
return { data, source: 'redirect' };
}
// 3. Legacy .redirect breadcrumb (v0.8 format)
const legacyRedirectPath = fullPath + '.redirect';
if (existsSync(legacyRedirectPath)) {
if (!storage) throw new Error(`File redirected to storage but no storage backend configured: ${filePath}`);
const info = parseRedirect(legacyRedirectPath);
const data = await storage.download(info.path); const data = await storage.download(info.path);
return { data, source: 'redirect' }; return { data, source: 'redirect' };
} }
// 3. .supabase marker in parent directory // 4. .supabase marker in parent directory
const markerPath = join(dirname(fullPath), '.supabase'); const markerPath = join(dirname(fullPath), '.supabase');
if (existsSync(markerPath)) { if (existsSync(markerPath)) {
if (!storage) throw new Error(`Directory mirrored to storage but no storage backend configured: ${filePath}`); if (!storage) throw new Error(`Directory mirrored to storage but no storage backend configured: ${filePath}`);
@@ -73,6 +98,13 @@ export async function resolveFile(
throw new Error(`File not found: ${filePath}`); throw new Error(`File not found: ${filePath}`);
} }
/** Parse v0.9+ .redirect.yaml pointer */
export function parseRedirectYaml(path: string): RedirectYaml {
const content = readFileSync(path, 'utf-8');
return parseYaml(content) as RedirectYaml;
}
/** Parse legacy v0.8 .redirect breadcrumb */
export function parseRedirect(path: string): RedirectInfo { export function parseRedirect(path: string): RedirectInfo {
const content = readFileSync(path, 'utf-8'); const content = readFileSync(path, 'utf-8');
return parseYaml(content) as RedirectInfo; return parseYaml(content) as RedirectInfo;
@@ -82,3 +114,11 @@ export function parseMarker(path: string): MarkerInfo {
const content = readFileSync(path, 'utf-8'); const content = readFileSync(path, 'utf-8');
return parseYaml(content) as MarkerInfo; return parseYaml(content) as MarkerInfo;
} }
/** Human-readable file size */
export function humanSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}

View File

@@ -1,8 +1,17 @@
import type { StorageBackend, StorageConfig } from '../storage.ts'; import type { StorageBackend, StorageConfig } from '../storage.ts';
/** Size thresholds for upload method selection */
const TUS_THRESHOLD = 100 * 1024 * 1024; // 100 MB — use TUS resumable above this
const TUS_CHUNK_SIZE = 6 * 1024 * 1024; // 6 MB chunks for TUS uploads
const SIGNED_URL_EXPIRY = 3600; // 1 hour
/** /**
* Supabase Storage — uses the Supabase Storage REST API. * Supabase Storage — uses the Supabase Storage REST API.
* Auth via the service role key (not the anon key). * Auth via the service role key (not the anon key).
*
* Upload method auto-selected by file size:
* < 100 MB → standard POST (single request)
* >= 100 MB → TUS resumable upload (6 MB chunks with retry)
*/ */
export class SupabaseStorage implements StorageBackend { export class SupabaseStorage implements StorageBackend {
private projectUrl: string; private projectUrl: string;
@@ -30,6 +39,15 @@ export class SupabaseStorage implements StorageBackend {
} }
async upload(path: string, data: Buffer, mime?: string): Promise<void> { async upload(path: string, data: Buffer, mime?: string): Promise<void> {
if (data.length >= TUS_THRESHOLD) {
await this.uploadTus(path, data, mime);
} else {
await this.uploadStandard(path, data, mime);
}
}
/** Standard single-request upload for files < 100 MB */
private async uploadStandard(path: string, data: Buffer, mime?: string): Promise<void> {
const res = await fetch(this.url(path), { const res = await fetch(this.url(path), {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -45,6 +63,78 @@ export class SupabaseStorage implements StorageBackend {
} }
} }
/**
* TUS resumable upload for files >= 100 MB.
* Sends in 6 MB chunks with retry + exponential backoff.
*/
private async uploadTus(path: string, data: Buffer, mime?: string): Promise<void> {
const tusUrl = `${this.projectUrl}/storage/v1/upload/resumable`;
const objectName = `${this.bucket}/${path}`;
// Step 1: Create the upload session
const createRes = await fetch(tusUrl, {
method: 'POST',
headers: {
...this.headers(),
'Tus-Resumable': '1.0.0',
'Upload-Length': String(data.length),
'Upload-Metadata': [
`bucketName ${btoa(this.bucket)}`,
`objectName ${btoa(path)}`,
`contentType ${btoa(mime || 'application/octet-stream')}`,
].join(','),
'x-upsert': 'true',
},
});
if (!createRes.ok) {
const body = await createRes.text();
throw new Error(`TUS create failed: ${createRes.status} ${body}`);
}
const uploadUrl = createRes.headers.get('Location');
if (!uploadUrl) throw new Error('TUS create did not return Location header');
// Step 2: Upload chunks
let offset = 0;
while (offset < data.length) {
const end = Math.min(offset + TUS_CHUNK_SIZE, data.length);
const chunk = data.subarray(offset, end);
let attempt = 0;
const maxAttempts = 3;
while (attempt < maxAttempts) {
try {
const patchRes = await fetch(uploadUrl, {
method: 'PATCH',
headers: {
...this.headers(),
'Tus-Resumable': '1.0.0',
'Upload-Offset': String(offset),
'Content-Type': 'application/offset+octet-stream',
'Content-Length': String(chunk.length),
},
body: chunk,
});
if (!patchRes.ok) {
const body = await patchRes.text();
throw new Error(`TUS PATCH failed: ${patchRes.status} ${body}`);
}
const newOffset = patchRes.headers.get('Upload-Offset');
offset = newOffset ? parseInt(newOffset, 10) : end;
break; // Success, move to next chunk
} catch (err) {
attempt++;
if (attempt >= maxAttempts) throw err;
// Exponential backoff: 1s, 2s, 4s
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
}
}
}
}
async download(path: string): Promise<Buffer> { async download(path: string): Promise<Buffer> {
const res = await fetch(this.url(path), { const res = await fetch(this.url(path), {
headers: this.headers(), headers: this.headers(),
@@ -81,8 +171,28 @@ export class SupabaseStorage implements StorageBackend {
return items.map(i => `${prefix}/${i.name}`); return items.map(i => `${prefix}/${i.name}`);
} }
/** Generate a signed URL with 1-hour expiry for private bucket access */
async getSignedUrl(path: string, expiresIn: number = SIGNED_URL_EXPIRY): Promise<string> {
const res = await fetch(`${this.projectUrl}/storage/v1/object/sign/${this.bucket}/${path}`, {
method: 'POST',
headers: { ...this.headers(), 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresIn }),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Supabase signed URL failed: ${res.status} ${body}`);
}
const result = await res.json() as { signedURL: string };
return `${this.projectUrl}${result.signedURL}`;
}
async getUrl(path: string): Promise<string> { async getUrl(path: string): Promise<string> {
// Public URL (if bucket is public) or signed URL // Try signed URL first (works for private buckets)
return `${this.projectUrl}/storage/v1/object/public/${this.bucket}/${path}`; try {
return await this.getSignedUrl(path);
} catch {
// Fall back to public URL
return `${this.projectUrl}/storage/v1/object/public/${this.bucket}/${path}`;
}
} }
} }