@o/database 0.16.18 → 0.16.19-canary.1790191946359
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/takeout/better-auth.d.ts +19 -0
- package/dist/takeout/better-auth.d.ts.map +1 -0
- package/dist/takeout/better-auth.js +59 -0
- package/dist/takeout/better-auth.js.map +1 -0
- package/dist/takeout/build.d.ts +2 -0
- package/dist/takeout/build.d.ts.map +1 -0
- package/dist/takeout/build.js +3 -0
- package/dist/takeout/build.js.map +1 -0
- package/dist/takeout/chunkedQuery.d.ts +17 -0
- package/dist/takeout/chunkedQuery.d.ts.map +1 -0
- package/dist/takeout/chunkedQuery.js +55 -0
- package/dist/takeout/chunkedQuery.js.map +1 -0
- package/dist/takeout/cli.d.ts +3 -0
- package/dist/takeout/cli.d.ts.map +1 -0
- package/dist/takeout/cli.js +114 -0
- package/dist/takeout/cli.js.map +1 -0
- package/dist/takeout/createDatabase.d.ts +49 -0
- package/dist/takeout/createDatabase.d.ts.map +1 -0
- package/dist/takeout/createDatabase.js +100 -0
- package/dist/takeout/createDatabase.js.map +1 -0
- package/dist/takeout/createDb.d.ts +3 -0
- package/dist/takeout/createDb.d.ts.map +1 -0
- package/dist/takeout/createDb.js +11 -0
- package/dist/takeout/createDb.js.map +1 -0
- package/dist/takeout/createDrizzle.d.ts +38 -0
- package/dist/takeout/createDrizzle.d.ts.map +1 -0
- package/dist/takeout/createDrizzle.js +60 -0
- package/dist/takeout/createDrizzle.js.map +1 -0
- package/dist/takeout/createPool.d.ts +11 -0
- package/dist/takeout/createPool.d.ts.map +1 -0
- package/dist/takeout/createPool.js +36 -0
- package/dist/takeout/createPool.js.map +1 -0
- package/dist/takeout/createServerHelpers.d.ts +9 -0
- package/dist/takeout/createServerHelpers.d.ts.map +1 -0
- package/dist/takeout/createServerHelpers.js +11 -0
- package/dist/takeout/createServerHelpers.js.map +1 -0
- package/dist/takeout/getDBClient.d.ts +11 -0
- package/dist/takeout/getDBClient.d.ts.map +1 -0
- package/dist/takeout/getDBClient.js +169 -0
- package/dist/takeout/getDBClient.js.map +1 -0
- package/dist/takeout/index.d.ts +13 -0
- package/dist/takeout/index.d.ts.map +1 -0
- package/dist/takeout/index.js +13 -0
- package/dist/takeout/index.js.map +1 -0
- package/dist/takeout/migrate.d.ts +33 -0
- package/dist/takeout/migrate.d.ts.map +1 -0
- package/dist/takeout/migrate.js +214 -0
- package/dist/takeout/migrate.js.map +1 -0
- package/dist/takeout/scripts/build-migrations.d.ts +8 -0
- package/dist/takeout/scripts/build-migrations.d.ts.map +1 -0
- package/dist/takeout/scripts/build-migrations.js +51 -0
- package/dist/takeout/scripts/build-migrations.js.map +1 -0
- package/dist/takeout/scripts/drizzle-migrations-sync.d.ts +17 -0
- package/dist/takeout/scripts/drizzle-migrations-sync.d.ts.map +1 -0
- package/dist/takeout/scripts/drizzle-migrations-sync.js +69 -0
- package/dist/takeout/scripts/drizzle-migrations-sync.js.map +1 -0
- package/dist/takeout/scripts/dump-database.d.ts +22 -0
- package/dist/takeout/scripts/dump-database.d.ts.map +1 -0
- package/dist/takeout/scripts/dump-database.js +64 -0
- package/dist/takeout/scripts/dump-database.js.map +1 -0
- package/dist/takeout/scripts/migration-add.d.ts +6 -0
- package/dist/takeout/scripts/migration-add.d.ts.map +1 -0
- package/dist/takeout/scripts/migration-add.js +39 -0
- package/dist/takeout/scripts/migration-add.js.map +1 -0
- package/dist/takeout/scripts/pg_dump.d.ts +11 -0
- package/dist/takeout/scripts/pg_dump.d.ts.map +1 -0
- package/dist/takeout/scripts/pg_dump.js +37 -0
- package/dist/takeout/scripts/pg_dump.js.map +1 -0
- package/dist/takeout/scripts/prune-backups.d.ts +8 -0
- package/dist/takeout/scripts/prune-backups.d.ts.map +1 -0
- package/dist/takeout/scripts/prune-backups.js +166 -0
- package/dist/takeout/scripts/prune-backups.js.map +1 -0
- package/dist/takeout/scripts/psql.d.ts +11 -0
- package/dist/takeout/scripts/psql.d.ts.map +1 -0
- package/dist/takeout/scripts/psql.js +41 -0
- package/dist/takeout/scripts/psql.js.map +1 -0
- package/dist/takeout/seed.d.ts +10 -0
- package/dist/takeout/seed.d.ts.map +1 -0
- package/dist/takeout/seed.js +22 -0
- package/dist/takeout/seed.js.map +1 -0
- package/dist/takeout/sql.d.ts +9 -0
- package/dist/takeout/sql.d.ts.map +1 -0
- package/dist/takeout/sql.js +24 -0
- package/dist/takeout/sql.js.map +1 -0
- package/dist/takeout/sqlite.d.ts +66 -0
- package/dist/takeout/sqlite.d.ts.map +1 -0
- package/dist/takeout/sqlite.js +110 -0
- package/dist/takeout/sqlite.js.map +1 -0
- package/dist/takeout/waitForDatabase.d.ts +2 -0
- package/dist/takeout/waitForDatabase.d.ts.map +1 -0
- package/dist/takeout/waitForDatabase.js +26 -0
- package/dist/takeout/waitForDatabase.js.map +1 -0
- package/dist/takeout/zero.d.ts +44 -0
- package/dist/takeout/zero.d.ts.map +1 -0
- package/dist/takeout/zero.js +177 -0
- package/dist/takeout/zero.js.map +1 -0
- package/package.json +54 -4
- package/src/takeout/better-auth.ts +110 -0
- package/src/takeout/build.ts +2 -0
- package/src/takeout/chunkedQuery.ts +91 -0
- package/src/takeout/cli.ts +126 -0
- package/src/takeout/createDatabase.ts +164 -0
- package/src/takeout/createDb.ts +15 -0
- package/src/takeout/createDrizzle.ts +127 -0
- package/src/takeout/createPool.ts +58 -0
- package/src/takeout/createServerHelpers.ts +21 -0
- package/src/takeout/getDBClient.ts +254 -0
- package/src/takeout/index.ts +38 -0
- package/src/takeout/migrate.ts +272 -0
- package/src/takeout/scripts/build-migrations.ts +66 -0
- package/src/takeout/scripts/drizzle-migrations-sync.ts +81 -0
- package/src/takeout/scripts/dump-database.ts +103 -0
- package/src/takeout/scripts/migration-add.ts +54 -0
- package/src/takeout/scripts/pg_dump.ts +46 -0
- package/src/takeout/scripts/prune-backups.ts +211 -0
- package/src/takeout/scripts/psql.ts +51 -0
- package/src/takeout/seed.ts +45 -0
- package/src/takeout/sql.ts +39 -0
- package/src/takeout/sqlite.ts +237 -0
- package/src/takeout/waitForDatabase.ts +27 -0
- package/src/takeout/zero.ts +281 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* drizzle-kit v1 beta generates migrations as directories:
|
|
5
|
+
* YYYYMMDDHHMMSS_name/migration.sql + snapshot.json
|
|
6
|
+
*
|
|
7
|
+
* our migration runner uses loadMigrationsFromDir which only
|
|
8
|
+
* finds flat .ts files. this script creates a flat .ts wrapper for each
|
|
9
|
+
* directory migration so the runner can pick them up.
|
|
10
|
+
*
|
|
11
|
+
* the wrapper inlines the SQL as a string constant (no ?raw import needed)
|
|
12
|
+
* which makes it work with both vite and bun.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from 'node:fs'
|
|
16
|
+
import { readdir, readFile, writeFile, stat } from 'node:fs/promises'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
export type DrizzleSyncOptions = {
|
|
20
|
+
migrationsDir: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function syncDrizzleMigrations(options: DrizzleSyncOptions) {
|
|
24
|
+
const { migrationsDir } = options
|
|
25
|
+
const entries = await readdir(migrationsDir, { withFileTypes: true })
|
|
26
|
+
|
|
27
|
+
// find directory-format migrations (contain migration.sql)
|
|
28
|
+
const migrationDirs = entries.filter(
|
|
29
|
+
(e) => e.isDirectory() && e.name !== 'meta' && e.name !== 'node_modules'
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
let created = 0
|
|
33
|
+
let updated = 0
|
|
34
|
+
|
|
35
|
+
for (const dir of migrationDirs) {
|
|
36
|
+
const sqlPath = join(migrationsDir, dir.name, 'migration.sql')
|
|
37
|
+
if (!existsSync(sqlPath)) continue
|
|
38
|
+
|
|
39
|
+
const sql = await readFile(sqlPath, 'utf-8')
|
|
40
|
+
const trimmed = sql.trim()
|
|
41
|
+
|
|
42
|
+
if (!trimmed || trimmed.startsWith('-- seed')) {
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const wrapperPath = join(migrationsDir, `${dir.name}.ts`)
|
|
47
|
+
|
|
48
|
+
// check if wrapper already exists and is up to date
|
|
49
|
+
if (existsSync(wrapperPath)) {
|
|
50
|
+
const sqlMtime = (await stat(sqlPath)).mtimeMs
|
|
51
|
+
const tsMtime = (await stat(wrapperPath)).mtimeMs
|
|
52
|
+
if (tsMtime > sqlMtime) continue
|
|
53
|
+
updated++
|
|
54
|
+
} else {
|
|
55
|
+
created++
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// generate wrapper with inlined SQL
|
|
59
|
+
const escaped = trimmed
|
|
60
|
+
.replace(/\\/g, '\\\\')
|
|
61
|
+
.replace(/`/g, '\\`')
|
|
62
|
+
.replace(/\$/g, '\\$')
|
|
63
|
+
const content = `import type { PoolClient } from 'pg'
|
|
64
|
+
|
|
65
|
+
const sql = \`${escaped}\`
|
|
66
|
+
|
|
67
|
+
export async function up(client: PoolClient) {
|
|
68
|
+
for (const stmt of sql.split('--> statement-breakpoint')) {
|
|
69
|
+
const trimmed = stmt.trim()
|
|
70
|
+
if (trimmed) await client.query(trimmed)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
`
|
|
74
|
+
|
|
75
|
+
await writeFile(wrapperPath, content)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (created > 0) console.info(`created ${created} migration wrapper(s)`)
|
|
79
|
+
if (updated > 0) console.info(`updated ${updated} migration wrapper(s)`)
|
|
80
|
+
if (created === 0 && updated === 0) console.info('migration wrappers up to date')
|
|
81
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export type DumpDatabaseOptions = {
|
|
4
|
+
host: string
|
|
5
|
+
database: string
|
|
6
|
+
username: string
|
|
7
|
+
password?: string
|
|
8
|
+
/** output file path */
|
|
9
|
+
outputPath: string
|
|
10
|
+
/** timeout in ms (default: 5 minutes) */
|
|
11
|
+
timeout?: number
|
|
12
|
+
/** pg_dump format (default: 'custom') */
|
|
13
|
+
format?: 'custom' | 'plain' | 'directory' | 'tar'
|
|
14
|
+
/** compression level 0-9 (default: 9) */
|
|
15
|
+
compress?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function dumpDatabase(options: DumpDatabaseOptions): Promise<boolean> {
|
|
19
|
+
const {
|
|
20
|
+
host,
|
|
21
|
+
database,
|
|
22
|
+
username,
|
|
23
|
+
password,
|
|
24
|
+
outputPath,
|
|
25
|
+
timeout = 5 * 60 * 1000,
|
|
26
|
+
format = 'custom',
|
|
27
|
+
compress = 9,
|
|
28
|
+
} = options
|
|
29
|
+
|
|
30
|
+
return new Promise<boolean>((resolve) => {
|
|
31
|
+
const proc = spawn(
|
|
32
|
+
'pg_dump',
|
|
33
|
+
[
|
|
34
|
+
'--clean',
|
|
35
|
+
'--if-exists',
|
|
36
|
+
'--no-owner',
|
|
37
|
+
'--no-privileges',
|
|
38
|
+
'--verbose',
|
|
39
|
+
`--format=${format}`,
|
|
40
|
+
`--compress=${compress}`,
|
|
41
|
+
'--file',
|
|
42
|
+
outputPath,
|
|
43
|
+
],
|
|
44
|
+
{
|
|
45
|
+
env: {
|
|
46
|
+
...process.env,
|
|
47
|
+
PGPASSWORD: password,
|
|
48
|
+
PGUSER: username,
|
|
49
|
+
PGHOST: host,
|
|
50
|
+
PGDATABASE: database,
|
|
51
|
+
PGCONNECT_TIMEOUT: '10',
|
|
52
|
+
} as any,
|
|
53
|
+
stdio: 'inherit',
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
let timeoutId: ReturnType<typeof setTimeout>
|
|
58
|
+
|
|
59
|
+
proc.on('error', (err) => {
|
|
60
|
+
console.error('[database] pg_dump process error:', err)
|
|
61
|
+
clearTimeout(timeoutId)
|
|
62
|
+
resolve(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
proc.on('exit', (code) => {
|
|
66
|
+
clearTimeout(timeoutId)
|
|
67
|
+
if (code === 0) {
|
|
68
|
+
resolve(true)
|
|
69
|
+
} else {
|
|
70
|
+
console.error(`[database] pg_dump exited with code ${code}`)
|
|
71
|
+
resolve(false)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
timeoutId = setTimeout(() => {
|
|
76
|
+
console.error(`[database] pg_dump timed out after ${timeout}ms`)
|
|
77
|
+
proc.kill('SIGTERM')
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
if (!proc.killed) proc.kill('SIGKILL')
|
|
80
|
+
}, 5000)
|
|
81
|
+
resolve(false)
|
|
82
|
+
}, timeout)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function checkDatabaseConnection(options: {
|
|
87
|
+
host: string
|
|
88
|
+
database: string
|
|
89
|
+
username: string
|
|
90
|
+
timeout?: number
|
|
91
|
+
}): Promise<boolean> {
|
|
92
|
+
const { host, database, username, timeout = 5000 } = options
|
|
93
|
+
|
|
94
|
+
return new Promise<boolean>((resolve) => {
|
|
95
|
+
const proc = spawn('pg_isready', ['-d', database, '-h', host, '-U', username], {
|
|
96
|
+
stdio: 'inherit',
|
|
97
|
+
timeout,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
proc.on('error', () => resolve(false))
|
|
101
|
+
proc.on('exit', (code) => resolve(code === 0))
|
|
102
|
+
})
|
|
103
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type MigrationAddOptions = {
|
|
5
|
+
migrationsDir: string
|
|
6
|
+
name?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const commonWords = ['sapphire', 'emerald', 'ruby', 'amber', 'topaz', 'onyx', 'pearl']
|
|
10
|
+
|
|
11
|
+
export function addMigration(options: MigrationAddOptions): string {
|
|
12
|
+
const { migrationsDir, name } = options
|
|
13
|
+
|
|
14
|
+
// if no name is provided, pick a random one from common words
|
|
15
|
+
const migrationName =
|
|
16
|
+
name || commonWords[Math.floor(Math.random() * commonWords.length)]!
|
|
17
|
+
|
|
18
|
+
// read all files in the migrations directory
|
|
19
|
+
const files = readdirSync(migrationsDir)
|
|
20
|
+
|
|
21
|
+
// find the highest migration number from both .ts and .sql files
|
|
22
|
+
const migrationRegex = /^(\d+)[-_].*\.(ts|sql)$/
|
|
23
|
+
let maxNumber = 0
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const match = file.match(migrationRegex)
|
|
26
|
+
if (match?.[1]) {
|
|
27
|
+
const num = Number.parseInt(match[1], 10)
|
|
28
|
+
if (!Number.isNaN(num) && num > maxNumber) {
|
|
29
|
+
maxNumber = num
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// calculate the next migration number and pad to 4 digits
|
|
35
|
+
const nextNumber = (maxNumber + 1).toString().padStart(4, '0')
|
|
36
|
+
const newFilename = `${nextNumber}-${migrationName}.ts`
|
|
37
|
+
const newFilePath = join(migrationsDir, newFilename)
|
|
38
|
+
|
|
39
|
+
// create a template for custom TypeScript migrations
|
|
40
|
+
const templateContent = `import type { PoolClient } from 'pg'
|
|
41
|
+
|
|
42
|
+
export async function up(client: PoolClient) {
|
|
43
|
+
// implementation for applying this migration
|
|
44
|
+
}
|
|
45
|
+
`
|
|
46
|
+
|
|
47
|
+
// write the template to the new file
|
|
48
|
+
writeFileSync(newFilePath, templateContent)
|
|
49
|
+
|
|
50
|
+
console.info(`Created custom migration: ${newFilePath}`)
|
|
51
|
+
console.info(`For Drizzle schema migrations, run 'drizzle-kit generate' instead`)
|
|
52
|
+
|
|
53
|
+
return newFilePath
|
|
54
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export type PgDumpOptions = {
|
|
4
|
+
connectionString?: string
|
|
5
|
+
host?: string
|
|
6
|
+
port?: number
|
|
7
|
+
database?: string
|
|
8
|
+
user?: string
|
|
9
|
+
password?: string
|
|
10
|
+
args?: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function runPgDump(options: PgDumpOptions = {}) {
|
|
14
|
+
const { connectionString, host, port, database, user, password, args = [] } = options
|
|
15
|
+
|
|
16
|
+
const env: Record<string, string> = {
|
|
17
|
+
...process.env,
|
|
18
|
+
} as any
|
|
19
|
+
|
|
20
|
+
if (connectionString) {
|
|
21
|
+
// parse connection string and set individual env vars
|
|
22
|
+
const url = new URL(connectionString)
|
|
23
|
+
env.PGHOST = url.hostname
|
|
24
|
+
env.PGPORT = url.port || '5432'
|
|
25
|
+
env.PGDATABASE = url.pathname.slice(1)
|
|
26
|
+
env.PGUSER = url.username
|
|
27
|
+
if (url.password) {
|
|
28
|
+
env.PGPASSWORD = url.password
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
if (host) env.PGHOST = host
|
|
32
|
+
if (port) env.PGPORT = port.toString()
|
|
33
|
+
if (database) env.PGDATABASE = database
|
|
34
|
+
if (user) env.PGUSER = user
|
|
35
|
+
if (password) env.PGPASSWORD = password
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.info(`Running pg_dump on postgres ${env.PGHOST}/${env.PGDATABASE}`)
|
|
39
|
+
|
|
40
|
+
const result = spawnSync('pg_dump', args, {
|
|
41
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
42
|
+
env,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return result.status
|
|
46
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { DeleteObjectsCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
|
|
2
|
+
|
|
3
|
+
interface BackupInfo {
|
|
4
|
+
key: string
|
|
5
|
+
date: Date
|
|
6
|
+
size: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function pruneBackups(
|
|
10
|
+
bucketName: string,
|
|
11
|
+
prefix: string,
|
|
12
|
+
options?: {
|
|
13
|
+
region?: string
|
|
14
|
+
dryRun?: boolean
|
|
15
|
+
}
|
|
16
|
+
): Promise<{ kept: string[]; deleted: string[] }> {
|
|
17
|
+
const region = options?.region ?? 'us-west-1'
|
|
18
|
+
const dryRun = options?.dryRun ?? false
|
|
19
|
+
|
|
20
|
+
const s3Client = new S3Client({
|
|
21
|
+
region,
|
|
22
|
+
credentials: {
|
|
23
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
24
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
console.info('🧹 Pruning old backups...')
|
|
29
|
+
|
|
30
|
+
// list all backups
|
|
31
|
+
const allBackups: BackupInfo[] = []
|
|
32
|
+
let continuationToken: string | undefined
|
|
33
|
+
|
|
34
|
+
do {
|
|
35
|
+
const response = await s3Client.send(
|
|
36
|
+
new ListObjectsV2Command({
|
|
37
|
+
Bucket: bucketName,
|
|
38
|
+
Prefix: prefix,
|
|
39
|
+
ContinuationToken: continuationToken,
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if (response.Contents) {
|
|
44
|
+
for (const obj of response.Contents) {
|
|
45
|
+
if (obj.Key && obj.LastModified && obj.Size) {
|
|
46
|
+
// extract date from filename (format: db-backup-2025-08-21T15-50-48-103Z.sql)
|
|
47
|
+
const match = obj.Key.match(/(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/)
|
|
48
|
+
if (match) {
|
|
49
|
+
const dateStr = match[1]!.replace(/-(\d{2})-(\d{2})-(\d{3}Z)/, ':$1:$2.$3')
|
|
50
|
+
allBackups.push({
|
|
51
|
+
key: obj.Key,
|
|
52
|
+
date: new Date(dateStr),
|
|
53
|
+
size: obj.Size,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
continuationToken = response.NextContinuationToken
|
|
61
|
+
} while (continuationToken)
|
|
62
|
+
|
|
63
|
+
if (allBackups.length === 0) {
|
|
64
|
+
console.info('No backups found to prune')
|
|
65
|
+
return { kept: [], deleted: [] }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// sort by date (newest first)
|
|
69
|
+
allBackups.sort((a, b) => b.date.getTime() - a.date.getTime())
|
|
70
|
+
|
|
71
|
+
const now = new Date()
|
|
72
|
+
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
|
73
|
+
|
|
74
|
+
const toKeep = new Set<string>()
|
|
75
|
+
const toDelete: string[] = []
|
|
76
|
+
|
|
77
|
+
// group backups by day and month
|
|
78
|
+
const dailyBackups = new Map<string, BackupInfo[]>()
|
|
79
|
+
const monthlyBackups = new Map<string, BackupInfo[]>()
|
|
80
|
+
|
|
81
|
+
for (const backup of allBackups) {
|
|
82
|
+
const dayKey = backup.date.toISOString().split('T')[0]! // YYYY-MM-DD
|
|
83
|
+
const monthKey = dayKey.substring(0, 7) // YYYY-MM
|
|
84
|
+
|
|
85
|
+
if (!dailyBackups.has(dayKey)) {
|
|
86
|
+
dailyBackups.set(dayKey, [])
|
|
87
|
+
}
|
|
88
|
+
const dayBackupsList = dailyBackups.get(dayKey)
|
|
89
|
+
if (dayBackupsList) {
|
|
90
|
+
dayBackupsList.push(backup)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!monthlyBackups.has(monthKey)) {
|
|
94
|
+
monthlyBackups.set(monthKey, [])
|
|
95
|
+
}
|
|
96
|
+
const monthBackupsList = monthlyBackups.get(monthKey)
|
|
97
|
+
if (monthBackupsList) {
|
|
98
|
+
monthBackupsList.push(backup)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// keep daily backups for last 30 days
|
|
103
|
+
for (const [dayKey, backups] of dailyBackups) {
|
|
104
|
+
const dayDate = new Date(dayKey)
|
|
105
|
+
if (dayDate >= thirtyDaysAgo) {
|
|
106
|
+
// keep the most recent backup for each day
|
|
107
|
+
const latestBackup = backups[0]
|
|
108
|
+
if (latestBackup) {
|
|
109
|
+
toKeep.add(latestBackup.key)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// keep monthly backups for older than 30 days
|
|
115
|
+
for (const [monthKey, backups] of monthlyBackups) {
|
|
116
|
+
const monthDate = new Date(monthKey + '-01')
|
|
117
|
+
if (monthDate < thirtyDaysAgo) {
|
|
118
|
+
// keep the most recent backup for each month
|
|
119
|
+
const latestBackup = backups[0]
|
|
120
|
+
if (latestBackup) {
|
|
121
|
+
toKeep.add(latestBackup.key)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// always keep the very latest backup regardless of rules
|
|
127
|
+
if (allBackups.length > 0) {
|
|
128
|
+
const latestBackup = allBackups[0]
|
|
129
|
+
if (latestBackup) {
|
|
130
|
+
toKeep.add(latestBackup.key)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// determine which backups to delete
|
|
135
|
+
for (const backup of allBackups) {
|
|
136
|
+
if (!toKeep.has(backup.key)) {
|
|
137
|
+
toDelete.push(backup.key)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.info(`📊 Backup retention summary:`)
|
|
142
|
+
console.info(` Total backups: ${allBackups.length}`)
|
|
143
|
+
console.info(` Keeping: ${toKeep.size}`)
|
|
144
|
+
console.info(` Deleting: ${toDelete.length}`)
|
|
145
|
+
|
|
146
|
+
if (toDelete.length > 0) {
|
|
147
|
+
if (dryRun) {
|
|
148
|
+
console.info('\n🔍 DRY RUN - Would delete:')
|
|
149
|
+
for (const key of toDelete.slice(0, 10)) {
|
|
150
|
+
console.info(` - ${key}`)
|
|
151
|
+
}
|
|
152
|
+
if (toDelete.length > 10) {
|
|
153
|
+
console.info(` ... and ${toDelete.length - 10} more`)
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
console.info('\n🗑️ Deleting old backups...')
|
|
157
|
+
|
|
158
|
+
// delete in batches of 1000 (S3 limit)
|
|
159
|
+
for (let i = 0; i < toDelete.length; i += 1000) {
|
|
160
|
+
const batch = toDelete.slice(i, i + 1000)
|
|
161
|
+
|
|
162
|
+
await s3Client.send(
|
|
163
|
+
new DeleteObjectsCommand({
|
|
164
|
+
Bucket: bucketName,
|
|
165
|
+
Delete: {
|
|
166
|
+
Objects: batch.map((key) => ({ Key: key })),
|
|
167
|
+
Quiet: true,
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
console.info(
|
|
173
|
+
` Deleted batch ${Math.floor(i / 1000) + 1}/${Math.ceil(toDelete.length / 1000)}`
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.info(`✅ Successfully deleted ${toDelete.length} old backup(s)`)
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
console.info('✅ No backups need to be deleted')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// log retention details
|
|
184
|
+
console.info('\n📅 Retention details:')
|
|
185
|
+
const keptList = Array.from(toKeep).sort()
|
|
186
|
+
const recentBackups = keptList.filter((key) => {
|
|
187
|
+
const match = key.match(/(\d{4}-\d{2}-\d{2})/)
|
|
188
|
+
if (match && match[1]) {
|
|
189
|
+
const date = new Date(match[1])
|
|
190
|
+
return date >= thirtyDaysAgo
|
|
191
|
+
}
|
|
192
|
+
return false
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const oldBackups = keptList.filter((key) => {
|
|
196
|
+
const match = key.match(/(\d{4}-\d{2}-\d{2})/)
|
|
197
|
+
if (match && match[1]) {
|
|
198
|
+
const date = new Date(match[1])
|
|
199
|
+
return date < thirtyDaysAgo
|
|
200
|
+
}
|
|
201
|
+
return false
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
console.info(` Daily backups (last 30 days): ${recentBackups.length}`)
|
|
205
|
+
console.info(` Monthly backups (older): ${oldBackups.length}`)
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
kept: Array.from(toKeep),
|
|
209
|
+
deleted: toDelete,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export type PsqlOptions = {
|
|
4
|
+
connectionString?: string
|
|
5
|
+
host?: string
|
|
6
|
+
port?: number
|
|
7
|
+
database?: string
|
|
8
|
+
user?: string
|
|
9
|
+
password?: string
|
|
10
|
+
query?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function runPsql(options: PsqlOptions = {}) {
|
|
14
|
+
const { connectionString, host, port, database, user, password, query } = options
|
|
15
|
+
|
|
16
|
+
const env: Record<string, string> = {
|
|
17
|
+
...process.env,
|
|
18
|
+
} as any
|
|
19
|
+
|
|
20
|
+
if (connectionString) {
|
|
21
|
+
// parse connection string and set individual env vars
|
|
22
|
+
const url = new URL(connectionString)
|
|
23
|
+
env.PGHOST = url.hostname
|
|
24
|
+
env.PGPORT = url.port || '5432'
|
|
25
|
+
env.PGDATABASE = url.pathname.slice(1)
|
|
26
|
+
env.PGUSER = url.username
|
|
27
|
+
if (url.password) {
|
|
28
|
+
env.PGPASSWORD = url.password
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
if (host) env.PGHOST = host
|
|
32
|
+
if (port) env.PGPORT = port.toString()
|
|
33
|
+
if (database) env.PGDATABASE = database
|
|
34
|
+
if (user) env.PGUSER = user
|
|
35
|
+
if (password) env.PGPASSWORD = password
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const args: string[] = []
|
|
39
|
+
if (query) {
|
|
40
|
+
args.push('-c', query)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.info(`Connecting to postgres ${env.PGHOST}/${env.PGDATABASE}`)
|
|
44
|
+
|
|
45
|
+
const result = spawnSync('psql', args, {
|
|
46
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
47
|
+
env,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
return result.status
|
|
51
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// standardized demo/world seed applier — the one mechanism every runtime uses
|
|
2
|
+
// to apply a project's database/seed.ts rows after migrations: pg via the
|
|
3
|
+
// --seed flag on database/migrate.ts, and the browser preview worker (PGlite)
|
|
4
|
+
// at cold boot. same rows, same insert semantics, only the client differs.
|
|
5
|
+
|
|
6
|
+
import type { InferInsertModel, Table } from 'drizzle-orm'
|
|
7
|
+
|
|
8
|
+
// plain rows keyed by table name, in insert order (FK parents before children)
|
|
9
|
+
export type SeedRows = Record<string, Array<Record<string, unknown>>>
|
|
10
|
+
|
|
11
|
+
// typed view of SeedRows against a drizzle schema module: table exports map to
|
|
12
|
+
// their insert models so seed rows typecheck against real columns, while
|
|
13
|
+
// `import type` keeps the seed file drizzle-free at runtime
|
|
14
|
+
export type SeedData<TSchema> = {
|
|
15
|
+
[K in keyof TSchema as TSchema[K] extends Table ? K : never]?: Array<
|
|
16
|
+
InferInsertModel<Extract<TSchema[K], Table>>
|
|
17
|
+
>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// the minimal client shape every runtime already has: node-pg PoolClient,
|
|
21
|
+
// PGlite, and the preview worker pool all expose query(sql, params)
|
|
22
|
+
export interface SeedClient {
|
|
23
|
+
query(sql: string, params?: unknown[]): Promise<unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const quote = (id: string) => `"${id.replaceAll('"', '""')}"`
|
|
27
|
+
|
|
28
|
+
// INSERT ... ON CONFLICT DO NOTHING per row, tables in key order. idempotent
|
|
29
|
+
// by construction: deterministic seed ids make re-application a no-op and a
|
|
30
|
+
// grown seed set back-fills only the missing rows. undefined columns are
|
|
31
|
+
// omitted so they fall to the DB default.
|
|
32
|
+
export async function applySeed(client: SeedClient, rows: SeedRows): Promise<void> {
|
|
33
|
+
for (const [table, tableRows] of Object.entries(rows)) {
|
|
34
|
+
for (const row of tableRows) {
|
|
35
|
+
const columns = Object.keys(row).filter((key) => row[key] !== undefined)
|
|
36
|
+
if (columns.length === 0) continue
|
|
37
|
+
const values = columns.map((key) => row[key])
|
|
38
|
+
const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ')
|
|
39
|
+
await client.query(
|
|
40
|
+
`INSERT INTO ${quote(table)} (${columns.map(quote).join(', ')}) VALUES (${placeholders}) ON CONFLICT DO NOTHING`,
|
|
41
|
+
values
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ellipsis } from '@o/helpers'
|
|
2
|
+
|
|
3
|
+
import type { Pool, QueryResult } from 'pg'
|
|
4
|
+
|
|
5
|
+
export type SqlQuery = {
|
|
6
|
+
text: string
|
|
7
|
+
values: any[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const createSql = (pool: Pool) => {
|
|
11
|
+
return (strings: TemplateStringsArray, ...values: any[]): Promise<QueryResult> => {
|
|
12
|
+
const text = strings.reduce((result, str, i) => {
|
|
13
|
+
return result + str + (i < values.length ? `$${i + 1}` : '')
|
|
14
|
+
}, '')
|
|
15
|
+
|
|
16
|
+
if (process.env.DEBUG) {
|
|
17
|
+
console.info(`sql: ${ellipsis(text, 80)}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return pool.query(text.trim(), values)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// default export for backward compatibility
|
|
25
|
+
let defaultPool: Pool | null = null
|
|
26
|
+
|
|
27
|
+
export const setDefaultPool = (pool: Pool) => {
|
|
28
|
+
defaultPool = pool
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const sql = (
|
|
32
|
+
strings: TemplateStringsArray,
|
|
33
|
+
...values: any[]
|
|
34
|
+
): Promise<QueryResult> => {
|
|
35
|
+
if (!defaultPool) {
|
|
36
|
+
throw new Error('No default pool set. Call setDefaultPool() first or use createSql()')
|
|
37
|
+
}
|
|
38
|
+
return createSql(defaultPool)(strings, ...values)
|
|
39
|
+
}
|