This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Convex exports require the admin CLI, so this runs beside (not inside) Convex functions.
|
||||
|
||||
import { execFile } from "node:child_process"
|
||||
import { createReadStream } from "node:fs"
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Readable } from "node:stream"
|
||||
import { promisify } from "node:util"
|
||||
import { createObjectStore } from "@conusai-kit/platform"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export async function backupOnce(env = process.env, now = new Date()) {
|
||||
const url = requiredFrom(env, "CONVEX_SELF_HOSTED_URL")
|
||||
const adminKey = requiredFrom(env, "CONVEX_SELF_HOSTED_ADMIN_KEY")
|
||||
const apiUrl = requiredFrom(env, "CONUSAI_API_URL")
|
||||
const token = requiredFrom(env, "CONUSAI_API_TOKEN")
|
||||
const projectId = requiredFrom(env, "CONUSAI_PROJECT_ID")
|
||||
const retention = positiveInt(env.CONVEX_BACKUP_RETENTION ?? "30", "CONVEX_BACKUP_RETENTION")
|
||||
const directory = await mkdtemp(join(tmpdir(), "conusai-convex-backup-"))
|
||||
const archive = join(directory, "convex-export.zip")
|
||||
try {
|
||||
await execFileAsync(
|
||||
env.CONVEX_CLI ?? "node_modules/.bin/convex",
|
||||
["export", "--include-file-storage", "--path", archive],
|
||||
{
|
||||
cwd: env.CONVEX_APP_DIR ?? process.cwd(),
|
||||
env: { ...env, CONVEX_SELF_HOSTED_URL: url, CONVEX_SELF_HOSTED_ADMIN_KEY: adminKey },
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
)
|
||||
const { size: sizeBytes } = await stat(archive)
|
||||
if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 1)
|
||||
throw new Error("Convex export archive is empty or too large")
|
||||
const stamp = now.toISOString().replaceAll(/[:.]/g, "-")
|
||||
const pathname = `convex-backups/${projectId}/${stamp}.zip`
|
||||
const store = createObjectStore({
|
||||
CONUSAI_API_URL: apiUrl,
|
||||
CONUSAI_API_TOKEN: token,
|
||||
CONUSAI_PROJECT_ID: projectId,
|
||||
})
|
||||
const session = await store.createUpload({ pathname, contentType: "application/zip", sizeBytes })
|
||||
const parts = []
|
||||
const chunkSize = session.upload.mode === "multipart" ? session.upload.partSizeBytes : sizeBytes
|
||||
for (let offset = 0, part = 1; offset < sizeBytes; offset += chunkSize, part += 1) {
|
||||
const partSize = Math.min(chunkSize, sizeBytes - offset)
|
||||
const source = createReadStream(archive, { start: offset, end: offset + partSize - 1 })
|
||||
const uploaded = await store.uploadContent(session.uploadId, Readable.toWeb(source), {
|
||||
...(session.upload.mode === "multipart" ? { part } : {}),
|
||||
size: partSize,
|
||||
})
|
||||
if (session.upload.mode === "multipart") parts.push({ partNumber: part, etag: uploaded.etag })
|
||||
}
|
||||
const completed = await store.completeUpload(
|
||||
session.uploadId,
|
||||
session.upload.mode === "multipart" ? { parts } : undefined,
|
||||
)
|
||||
await prune(store, `convex-backups/${projectId}/`, retention)
|
||||
return { pathname, sizeBytes, sha256Digest: completed.sha256Digest }
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function requiredFrom(env, name) {
|
||||
const value = env[name]
|
||||
if (!value) throw new Error(`${name} is required for Convex backups`)
|
||||
return value
|
||||
}
|
||||
|
||||
function positiveInt(value, name) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${name} must be a positive integer`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function prune(store, prefix, retention) {
|
||||
const blobs = []
|
||||
let cursor
|
||||
do {
|
||||
const page = await store.list({ prefix, limit: 1000, ...(cursor ? { cursor } : {}) })
|
||||
blobs.push(...page.blobs)
|
||||
cursor = page.hasMore ? page.cursor : undefined
|
||||
} while (cursor)
|
||||
blobs.sort((a, b) => b.pathname.localeCompare(a.pathname))
|
||||
const expired = blobs.slice(retention).map((blob) => blob.url)
|
||||
if (expired.length) await store.del(expired)
|
||||
}
|
||||
|
||||
if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
|
||||
if (process.argv.includes("--once")) {
|
||||
try {
|
||||
const result = await backupOnce()
|
||||
console.info(
|
||||
`Convex backup complete: ${result.pathname} (${result.sizeBytes} bytes, sha256 ${result.sha256Digest})`,
|
||||
)
|
||||
process.exit(0)
|
||||
} catch {
|
||||
console.error("Convex backup failed (CLI, Blob, or configuration error)")
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
const interval =
|
||||
positiveInt(process.env.CONVEX_BACKUP_INTERVAL_HOURS ?? "24", "CONVEX_BACKUP_INTERVAL_HOURS") * 60 * 60 * 1000
|
||||
const run = async () => {
|
||||
try {
|
||||
const result = await backupOnce()
|
||||
console.info(
|
||||
`Convex backup complete: ${result.pathname} (${result.sizeBytes} bytes, sha256 ${result.sha256Digest})`,
|
||||
)
|
||||
} catch {
|
||||
// CLI errors can include their argv, which contains the admin key. Never log raw errors.
|
||||
console.error("Convex backup failed (CLI, Blob, or configuration error)")
|
||||
}
|
||||
setTimeout(() => void run(), interval)
|
||||
}
|
||||
void run()
|
||||
}
|
||||
Reference in New Issue
Block a user