@factorypure/devdb 0.0.0-stage → 0.1.0
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/bin/devdb.js +558 -0
- package/package.json +14 -4
- package/README.md +0 -3
package/bin/devdb.js
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const HOME = process.env.DEVDB_HOME ?? path.join(os.homedir(), '.devdb')
|
|
8
|
+
const GOLDENS = path.join(HOME, 'goldens')
|
|
9
|
+
const CLONES = path.join(HOME, 'clones')
|
|
10
|
+
const DUMPS = path.join(HOME, 'dumps')
|
|
11
|
+
const RUN = path.join(HOME, 'run')
|
|
12
|
+
const STATE_FILE = path.join(HOME, 'state.json')
|
|
13
|
+
|
|
14
|
+
const BUCKET = 'factorypure-dev-db-snapshots'
|
|
15
|
+
const LATEST_KEY = 'dev-db/latest.json'
|
|
16
|
+
// apps hardcode port 3306 and only swap the host (see setupENVVars.js)
|
|
17
|
+
const PORT = Number(process.env.DEVDB_PORT ?? 3306)
|
|
18
|
+
const LOAD_PORT = 3399
|
|
19
|
+
const BUFFER_POOL = process.env.DEVDB_BUFFER_POOL ?? '2G'
|
|
20
|
+
const KEEP_GOLDENS = 2
|
|
21
|
+
const MYSQL_BIN = process.env.DEVDB_MYSQL_BIN ?? '/opt/homebrew/opt/mysql@8.4/bin'
|
|
22
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,40}$/i
|
|
23
|
+
|
|
24
|
+
const HELP = `devdb - local copies of the prod database (refreshed nightly by fpjobs dev-db-snapshot)
|
|
25
|
+
|
|
26
|
+
devdb pull [--env FILE] [--force] download the latest snapshot and load it into a new golden copy
|
|
27
|
+
(--env: create the DB_USER/DB_PASSWORD app user from that .env;
|
|
28
|
+
defaults to ./.env when present)
|
|
29
|
+
devdb new NAME instant copy-on-write clone of the newest golden, started on :${PORT}
|
|
30
|
+
devdb use NAME stop the active clone and start NAME
|
|
31
|
+
devdb reset [NAME] throw away NAME (default: active) and re-clone the newest golden
|
|
32
|
+
devdb rm NAME delete a clone
|
|
33
|
+
devdb start | stop start/stop the active clone
|
|
34
|
+
devdb user [--env FILE] create the app user from a .env on the running clone
|
|
35
|
+
devdb list goldens and clones
|
|
36
|
+
devdb doctor check prerequisites
|
|
37
|
+
|
|
38
|
+
Point apps at it with DB_DEVELOPMENT_HOST = 127.0.0.1, then run the branch's pending migrations.
|
|
39
|
+
Requires: brew install mysql@8.4 zstd, and AWS SSO credentials (aws sso login).`
|
|
40
|
+
|
|
41
|
+
const die = (message) => {
|
|
42
|
+
console.error(`devdb: ${message}`)
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const bin = (name) => path.join(MYSQL_BIN, name)
|
|
47
|
+
const run = (command, args, options = {}) => execFileSync(command, args, { stdio: 'inherit', ...options })
|
|
48
|
+
const capture = (command, args) => execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] })
|
|
49
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
50
|
+
const listDirs = (dir) =>
|
|
51
|
+
fs.existsSync(dir)
|
|
52
|
+
? fs
|
|
53
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
54
|
+
.filter((e) => e.isDirectory())
|
|
55
|
+
.map((e) => e.name)
|
|
56
|
+
.sort()
|
|
57
|
+
: []
|
|
58
|
+
|
|
59
|
+
function readState() {
|
|
60
|
+
return fs.existsSync(STATE_FILE) ? JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) : { active: null, clones: {} }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeState(state) {
|
|
64
|
+
fs.mkdirSync(HOME, { recursive: true })
|
|
65
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const completedGoldens = () => listDirs(GOLDENS).filter((name) => !name.endsWith('.loading'))
|
|
69
|
+
|
|
70
|
+
function latestGolden() {
|
|
71
|
+
const golden = completedGoldens().at(-1)
|
|
72
|
+
if (!golden) {
|
|
73
|
+
die('no golden copy yet, run `devdb pull` first')
|
|
74
|
+
}
|
|
75
|
+
return golden
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function serverPaths(name, datadir) {
|
|
79
|
+
fs.mkdirSync(RUN, { recursive: true })
|
|
80
|
+
return {
|
|
81
|
+
name,
|
|
82
|
+
datadir,
|
|
83
|
+
socket: path.join(RUN, `${name}.sock`),
|
|
84
|
+
pid: path.join(RUN, `${name}.pid`),
|
|
85
|
+
log: path.join(RUN, `${name}.log`),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const clonePaths = (name) => serverPaths(name, path.join(CLONES, name))
|
|
90
|
+
|
|
91
|
+
function validateName(name) {
|
|
92
|
+
if (!name || !NAME_PATTERN.test(name)) {
|
|
93
|
+
die('clone names may use letters, digits, ".", "_" and "-"')
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isRunning(paths) {
|
|
98
|
+
if (!fs.existsSync(paths.pid)) {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
process.kill(Number(fs.readFileSync(paths.pid, 'utf8')), 0)
|
|
103
|
+
return true
|
|
104
|
+
} catch {
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function assertPortFree(port) {
|
|
110
|
+
const result = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8' })
|
|
111
|
+
if (result.stdout.trim()) {
|
|
112
|
+
die(`port ${port} is already in use (pid ${result.stdout.trim()}); stop that server first (e.g. brew services stop mysql@8.4)`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ping = (paths) =>
|
|
117
|
+
spawnSync(bin('mysqladmin'), ['--no-defaults', `--socket=${paths.socket}`, '-uroot', 'ping'], { stdio: 'ignore' })
|
|
118
|
+
.status === 0
|
|
119
|
+
|
|
120
|
+
async function startServer(paths, port, { loading = false } = {}) {
|
|
121
|
+
if (isRunning(paths)) {
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
assertPortFree(port)
|
|
125
|
+
const args = [
|
|
126
|
+
'--no-defaults',
|
|
127
|
+
`--datadir=${paths.datadir}`,
|
|
128
|
+
`--port=${port}`,
|
|
129
|
+
'--bind-address=127.0.0.1',
|
|
130
|
+
`--socket=${paths.socket}`,
|
|
131
|
+
`--pid-file=${paths.pid}`,
|
|
132
|
+
`--log-error=${paths.log}`,
|
|
133
|
+
'--mysqlx=OFF',
|
|
134
|
+
'--skip-log-bin',
|
|
135
|
+
// match prod
|
|
136
|
+
'--sql-mode=IGNORE_SPACE,NO_ENGINE_SUBSTITUTION',
|
|
137
|
+
'--default-time-zone=+00:00',
|
|
138
|
+
'--log-bin-trust-function-creators=1',
|
|
139
|
+
// prod has foreign keys onto non-unique keys (e.g. draft_order_line_items -> draft_orders), which 8.4 rejects by default
|
|
140
|
+
'--restrict-fk-on-non-standard-key=OFF',
|
|
141
|
+
// the apps' `mysql` npm driver can't do 8.4's default caching_sha2_password
|
|
142
|
+
'--mysql-native-password=ON',
|
|
143
|
+
'--character-set-server=utf8mb4',
|
|
144
|
+
'--collation-server=utf8mb4_0900_ai_ci',
|
|
145
|
+
'--max-connections=500',
|
|
146
|
+
`--innodb-buffer-pool-size=${BUFFER_POOL}`,
|
|
147
|
+
'--innodb-redo-log-capacity=2G',
|
|
148
|
+
// durability doesn't matter for a throwaway copy; these make loading much faster
|
|
149
|
+
`--innodb-flush-log-at-trx-commit=${loading ? 0 : 2}`,
|
|
150
|
+
...(loading ? ['--innodb-doublewrite=OFF'] : []),
|
|
151
|
+
]
|
|
152
|
+
spawn(bin('mysqld'), args, { detached: true, stdio: 'ignore' }).unref()
|
|
153
|
+
for (let i = 0; i < 240; i++) {
|
|
154
|
+
if (ping(paths)) {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
await sleep(500)
|
|
158
|
+
}
|
|
159
|
+
const log = fs.existsSync(paths.log) ? fs.readFileSync(paths.log, 'utf8').split('\n').slice(-20).join('\n') : ''
|
|
160
|
+
die(`mysqld for ${paths.name} didn't start. Last log lines:\n${log}`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function stopServer(paths) {
|
|
164
|
+
if (!isRunning(paths)) {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
spawnSync(bin('mysqladmin'), ['--no-defaults', `--socket=${paths.socket}`, '-uroot', 'shutdown'], { stdio: 'inherit' })
|
|
168
|
+
for (let i = 0; i < 600 && isRunning(paths); i++) {
|
|
169
|
+
await sleep(500)
|
|
170
|
+
}
|
|
171
|
+
if (isRunning(paths)) {
|
|
172
|
+
die(`mysqld for ${paths.name} didn't stop, see ${paths.log}`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const sqlString = (value) => `'${String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
|
|
177
|
+
|
|
178
|
+
function mysqlRoot(paths, sql) {
|
|
179
|
+
const result = spawnSync(bin('mysql'), ['--no-defaults', `--socket=${paths.socket}`, '-uroot'], {
|
|
180
|
+
input: sql,
|
|
181
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
182
|
+
})
|
|
183
|
+
if (result.status !== 0) {
|
|
184
|
+
die('mysql command failed')
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseEnvFile(file) {
|
|
189
|
+
const values = {}
|
|
190
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
191
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*?)\s*$/)
|
|
192
|
+
if (match) {
|
|
193
|
+
values[match[1]] = match[2].replace(/^(['"])(.*)\1$/, '$2')
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return values
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function appCredentials(flags) {
|
|
200
|
+
const file = flags.env ?? (fs.existsSync('.env') ? '.env' : null)
|
|
201
|
+
if (!file) {
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
const { DB_USER, DB_PASSWORD } = parseEnvFile(file)
|
|
205
|
+
if (!DB_USER || !DB_PASSWORD) {
|
|
206
|
+
die(`${file} has no DB_USER/DB_PASSWORD`)
|
|
207
|
+
}
|
|
208
|
+
return { user: DB_USER, password: DB_PASSWORD, file }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function createAppUser(paths, credentials) {
|
|
212
|
+
const account = `${sqlString(credentials.user)}@'%'`
|
|
213
|
+
mysqlRoot(
|
|
214
|
+
paths,
|
|
215
|
+
`CREATE USER IF NOT EXISTS ${account} IDENTIFIED WITH mysql_native_password BY ${sqlString(credentials.password)};
|
|
216
|
+
ALTER USER ${account} IDENTIFIED WITH mysql_native_password BY ${sqlString(credentials.password)};
|
|
217
|
+
GRANT ALL ON *.* TO ${account};`
|
|
218
|
+
)
|
|
219
|
+
console.log(`App user ${credentials.user} created from ${credentials.file}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function cloneFromGolden(name, golden) {
|
|
223
|
+
fs.mkdirSync(CLONES, { recursive: true })
|
|
224
|
+
// -c uses APFS clonefile(2): instant, and blocks are only copied once either side changes
|
|
225
|
+
run('cp', ['-c', '-R', path.join(GOLDENS, golden), path.join(CLONES, name)])
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function ensureAppUser(paths, flags) {
|
|
229
|
+
const credentials = appCredentials(flags)
|
|
230
|
+
if (credentials) {
|
|
231
|
+
createAppUser(paths, credentials)
|
|
232
|
+
} else {
|
|
233
|
+
console.log('No .env here: run `devdb user --env path/to/.env` so the apps can log in')
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Runs one mydumper file (zstd-compressed SQL with its own SET header) through the mysql client.
|
|
238
|
+
function sqlFile(paths, file, database = 'main') {
|
|
239
|
+
return new Promise((resolve, reject) => {
|
|
240
|
+
const zstd = spawn('zstd', ['-dc', file], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
241
|
+
const mysql = spawn(
|
|
242
|
+
bin('mysql'),
|
|
243
|
+
['--no-defaults', `--socket=${paths.socket}`, '-uroot', '--max-allowed-packet=1G', ...(database ? [database] : [])],
|
|
244
|
+
{ stdio: ['pipe', 'ignore', 'pipe'] }
|
|
245
|
+
)
|
|
246
|
+
zstd.stdout.pipe(mysql.stdin)
|
|
247
|
+
let stderr = ''
|
|
248
|
+
zstd.stderr.on('data', (d) => (stderr += d))
|
|
249
|
+
mysql.stderr.on('data', (d) => (stderr += d))
|
|
250
|
+
mysql.on('close', (code) =>
|
|
251
|
+
code === 0 ? resolve() : reject(new Error(`${path.basename(file)}: ${stderr.trim()}`))
|
|
252
|
+
)
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function inParallel(items, concurrency, task) {
|
|
257
|
+
let next = 0
|
|
258
|
+
const worker = async () => {
|
|
259
|
+
while (next < items.length) {
|
|
260
|
+
await task(items[next++])
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function sqlText(paths, text) {
|
|
267
|
+
const result = spawnSync(
|
|
268
|
+
bin('mysql'),
|
|
269
|
+
['--no-defaults', `--socket=${paths.socket}`, '-uroot', 'main'],
|
|
270
|
+
{ input: text, encoding: 'utf8' }
|
|
271
|
+
)
|
|
272
|
+
if (result.status !== 0) {
|
|
273
|
+
return Promise.reject(new Error(result.stderr.trim()))
|
|
274
|
+
}
|
|
275
|
+
return Promise.resolve()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// mydumper trigger files rely on myloader to split statements; the mysql client needs DELIMITER around bodies with `;`.
|
|
279
|
+
function withTriggerDelimiters(sql) {
|
|
280
|
+
const out = []
|
|
281
|
+
let depth = 0
|
|
282
|
+
for (const line of sql.split('\n')) {
|
|
283
|
+
if (depth === 0 && /^CREATE\b.*\bTRIGGER\b.*\bBEGIN\b/i.test(line)) {
|
|
284
|
+
out.push('DELIMITER ;;')
|
|
285
|
+
}
|
|
286
|
+
const opened = depth > 0 || /^CREATE\b.*\bTRIGGER\b/i.test(line)
|
|
287
|
+
depth += opened ? (line.match(/\bBEGIN\b/gi) ?? []).length : 0
|
|
288
|
+
if (depth > 0 && /^\s*END;\s*$/i.test(line) && --depth === 0) {
|
|
289
|
+
out.push(line.replace(/;\s*$/, ';;'), 'DELIMITER ;')
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
out.push(line)
|
|
293
|
+
}
|
|
294
|
+
return out.join('\n')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// myloader 1.0.5 stalls after creating a handful of tables from this dump, so the files are loaded directly.
|
|
298
|
+
async function loadDump(dumpDir, paths) {
|
|
299
|
+
const files = fs.readdirSync(dumpDir)
|
|
300
|
+
const full = (f) => path.join(dumpDir, f)
|
|
301
|
+
const isView = new Set(files.filter((f) => f.endsWith('-schema-view.sql.zst')).map((f) => f.replace('-schema-view', '-schema')))
|
|
302
|
+
// view placeholder tables are skipped; the real views are created after the data
|
|
303
|
+
const tables = files.filter((f) => f.endsWith('-schema.sql.zst') && f !== 'main-schema-create.sql.zst' && !isView.has(f))
|
|
304
|
+
const views = files.filter((f) => f.endsWith('-schema-view.sql.zst'))
|
|
305
|
+
const triggers = files.filter((f) => f.endsWith('-schema-triggers.sql.zst'))
|
|
306
|
+
const data = files
|
|
307
|
+
.filter((f) => /\.\d+\.sql\.zst$/.test(f))
|
|
308
|
+
.map((f) => ({ f, size: fs.statSync(full(f)).size }))
|
|
309
|
+
.sort((a, b) => b.size - a.size)
|
|
310
|
+
const totalBytes = data.reduce((sum, d) => sum + d.size, 0)
|
|
311
|
+
const threads = os.cpus().length
|
|
312
|
+
|
|
313
|
+
await sqlFile(paths, full('main-schema-create.sql.zst'), null)
|
|
314
|
+
// one at a time: concurrent CREATE TABLEs with foreign keys deadlock on the data dictionary
|
|
315
|
+
await inParallel(tables, 1, (f) => sqlFile(paths, full(f)))
|
|
316
|
+
console.log(`Created ${tables.length} tables, loading ${data.length} data files (${(totalBytes / 1024 ** 3).toFixed(1)} GB compressed)...`)
|
|
317
|
+
|
|
318
|
+
const startedAt = Date.now()
|
|
319
|
+
let doneBytes = 0
|
|
320
|
+
let doneFiles = 0
|
|
321
|
+
const progress = setInterval(() => {
|
|
322
|
+
const minutes = ((Date.now() - startedAt) / 60000).toFixed(1)
|
|
323
|
+
console.log(` ${doneFiles}/${data.length} files, ${Math.round((doneBytes / totalBytes) * 100)}% of bytes, ${minutes} min`)
|
|
324
|
+
}, 30000)
|
|
325
|
+
try {
|
|
326
|
+
await inParallel(data, threads, async ({ f, size }) => {
|
|
327
|
+
await sqlFile(paths, full(f))
|
|
328
|
+
doneBytes += size
|
|
329
|
+
doneFiles++
|
|
330
|
+
})
|
|
331
|
+
} finally {
|
|
332
|
+
clearInterval(progress)
|
|
333
|
+
}
|
|
334
|
+
console.log(`Data loaded in ${((Date.now() - startedAt) / 60000).toFixed(1)} min`)
|
|
335
|
+
|
|
336
|
+
// a view can select from another view, so failures get retried once the rest exist
|
|
337
|
+
let pending = views
|
|
338
|
+
for (let pass = 0; pending.length && pass < 5; pass++) {
|
|
339
|
+
const failed = []
|
|
340
|
+
for (const f of pending) {
|
|
341
|
+
await sqlFile(paths, full(f)).catch((err) => failed.push({ f, err }))
|
|
342
|
+
}
|
|
343
|
+
if (failed.length === pending.length) {
|
|
344
|
+
die(`could not create views:\n${failed.map((x) => x.err.message).join('\n')}`)
|
|
345
|
+
}
|
|
346
|
+
pending = failed.map((x) => x.f)
|
|
347
|
+
}
|
|
348
|
+
if (pending.length) {
|
|
349
|
+
die(`views still failing after retries: ${pending.join(', ')}`)
|
|
350
|
+
}
|
|
351
|
+
// created last so they don't fire for every loaded row
|
|
352
|
+
for (const f of triggers) {
|
|
353
|
+
await sqlText(paths, withTriggerDelimiters(execFileSync('zstd', ['-dc', full(f)], { encoding: 'utf8' })))
|
|
354
|
+
}
|
|
355
|
+
console.log(`Created ${views.length} views and ${triggers.length} trigger files`)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function parseFlags(args) {
|
|
359
|
+
const flags = {}
|
|
360
|
+
const positional = []
|
|
361
|
+
for (let i = 0; i < args.length; i++) {
|
|
362
|
+
if (args[i] === '--force') {
|
|
363
|
+
flags.force = true
|
|
364
|
+
} else if (args[i] === '--env') {
|
|
365
|
+
flags.env = args[++i]
|
|
366
|
+
} else {
|
|
367
|
+
positional.push(args[i])
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { flags, positional }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function stopActive(state) {
|
|
374
|
+
if (state.active) {
|
|
375
|
+
await stopServer(clonePaths(state.active))
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const commands = {
|
|
380
|
+
async pull(flags) {
|
|
381
|
+
const startedAt = Date.now()
|
|
382
|
+
const manifest = JSON.parse(capture('aws', ['s3', 'cp', `s3://${BUCKET}/${LATEST_KEY}`, '-']))
|
|
383
|
+
const { stamp } = manifest
|
|
384
|
+
if (completedGoldens().includes(stamp) && !flags.force) {
|
|
385
|
+
console.log(`Already have the latest snapshot (${stamp}).`)
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
const credentials = appCredentials(flags)
|
|
389
|
+
console.log(
|
|
390
|
+
`Downloading ${stamp} (${(manifest.bytes / 1024 ** 3).toFixed(1)} GB, prod snapshot from ${manifest.snapshotCreatedAt})`
|
|
391
|
+
)
|
|
392
|
+
const dumpDir = path.join(DUMPS, stamp)
|
|
393
|
+
run('aws', ['s3', 'sync', `s3://${BUCKET}/${manifest.prefix}`, dumpDir, '--only-show-errors'])
|
|
394
|
+
|
|
395
|
+
const loadingDir = path.join(GOLDENS, `${stamp}.loading`)
|
|
396
|
+
fs.rmSync(loadingDir, { recursive: true, force: true })
|
|
397
|
+
fs.mkdirSync(GOLDENS, { recursive: true })
|
|
398
|
+
run(bin('mysqld'), ['--no-defaults', '--initialize-insecure', `--datadir=${loadingDir}`])
|
|
399
|
+
|
|
400
|
+
const loader = serverPaths('loader', loadingDir)
|
|
401
|
+
await startServer(loader, LOAD_PORT, { loading: true })
|
|
402
|
+
try {
|
|
403
|
+
await loadDump(dumpDir, loader)
|
|
404
|
+
if (credentials) {
|
|
405
|
+
createAppUser(loader, credentials)
|
|
406
|
+
}
|
|
407
|
+
} finally {
|
|
408
|
+
await stopServer(loader)
|
|
409
|
+
}
|
|
410
|
+
fs.renameSync(loadingDir, path.join(GOLDENS, stamp))
|
|
411
|
+
fs.rmSync(dumpDir, { recursive: true, force: true })
|
|
412
|
+
|
|
413
|
+
for (const old of completedGoldens().slice(0, -KEEP_GOLDENS)) {
|
|
414
|
+
fs.rmSync(path.join(GOLDENS, old), { recursive: true, force: true })
|
|
415
|
+
}
|
|
416
|
+
const minutes = Math.round((Date.now() - startedAt) / 60000)
|
|
417
|
+
const { active } = readState()
|
|
418
|
+
console.log(
|
|
419
|
+
`Golden ${stamp} ready in ${minutes} min. ${active ? `Run \`devdb reset\` to move ${active} onto it` : 'Run `devdb new <name>` to create a clone'}.`
|
|
420
|
+
)
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
async new(flags, [name]) {
|
|
424
|
+
validateName(name)
|
|
425
|
+
const state = readState()
|
|
426
|
+
if (fs.existsSync(path.join(CLONES, name))) {
|
|
427
|
+
die(`${name} already exists (use \`devdb use ${name}\` or \`devdb reset ${name}\`)`)
|
|
428
|
+
}
|
|
429
|
+
const golden = latestGolden()
|
|
430
|
+
await stopActive(state)
|
|
431
|
+
cloneFromGolden(name, golden)
|
|
432
|
+
await startServer(clonePaths(name), PORT)
|
|
433
|
+
ensureAppUser(clonePaths(name), flags)
|
|
434
|
+
state.clones[name] = { golden, createdAt: new Date().toISOString() }
|
|
435
|
+
state.active = name
|
|
436
|
+
writeState(state)
|
|
437
|
+
console.log(`${name} (from ${golden}) is running on 127.0.0.1:${PORT}`)
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
async use(flags, [name]) {
|
|
441
|
+
validateName(name)
|
|
442
|
+
const state = readState()
|
|
443
|
+
if (!fs.existsSync(path.join(CLONES, name))) {
|
|
444
|
+
die(`no clone named ${name}`)
|
|
445
|
+
}
|
|
446
|
+
if (state.active !== name) {
|
|
447
|
+
await stopActive(state)
|
|
448
|
+
}
|
|
449
|
+
await startServer(clonePaths(name), PORT)
|
|
450
|
+
state.active = name
|
|
451
|
+
writeState(state)
|
|
452
|
+
console.log(`${name} is running on 127.0.0.1:${PORT}`)
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
async reset(flags, [name]) {
|
|
456
|
+
const state = readState()
|
|
457
|
+
name ??= state.active
|
|
458
|
+
validateName(name)
|
|
459
|
+
const golden = latestGolden()
|
|
460
|
+
await stopServer(clonePaths(name))
|
|
461
|
+
fs.rmSync(path.join(CLONES, name), { recursive: true, force: true })
|
|
462
|
+
if (state.active && state.active !== name) {
|
|
463
|
+
await stopActive(state)
|
|
464
|
+
}
|
|
465
|
+
cloneFromGolden(name, golden)
|
|
466
|
+
await startServer(clonePaths(name), PORT)
|
|
467
|
+
ensureAppUser(clonePaths(name), flags)
|
|
468
|
+
state.clones[name] = { golden, createdAt: new Date().toISOString() }
|
|
469
|
+
state.active = name
|
|
470
|
+
writeState(state)
|
|
471
|
+
console.log(`${name} reset to ${golden} and running on 127.0.0.1:${PORT}`)
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
async rm(flags, [name]) {
|
|
475
|
+
validateName(name)
|
|
476
|
+
const state = readState()
|
|
477
|
+
await stopServer(clonePaths(name))
|
|
478
|
+
fs.rmSync(path.join(CLONES, name), { recursive: true, force: true })
|
|
479
|
+
delete state.clones[name]
|
|
480
|
+
if (state.active === name) {
|
|
481
|
+
state.active = null
|
|
482
|
+
}
|
|
483
|
+
writeState(state)
|
|
484
|
+
console.log(`Removed ${name}`)
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
async start() {
|
|
488
|
+
const { active } = readState()
|
|
489
|
+
if (!active) {
|
|
490
|
+
die('no active clone, run `devdb new <name>`')
|
|
491
|
+
}
|
|
492
|
+
await startServer(clonePaths(active), PORT)
|
|
493
|
+
console.log(`${active} is running on 127.0.0.1:${PORT}`)
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
async stop() {
|
|
497
|
+
const { active } = readState()
|
|
498
|
+
if (active) {
|
|
499
|
+
await stopServer(clonePaths(active))
|
|
500
|
+
console.log(`Stopped ${active}`)
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
async user(flags) {
|
|
505
|
+
const { active } = readState()
|
|
506
|
+
const paths = active && clonePaths(active)
|
|
507
|
+
if (!paths || !isRunning(paths)) {
|
|
508
|
+
die('start a clone first')
|
|
509
|
+
}
|
|
510
|
+
const credentials = appCredentials(flags)
|
|
511
|
+
if (!credentials) {
|
|
512
|
+
die('pass --env path/to/.env')
|
|
513
|
+
}
|
|
514
|
+
createAppUser(paths, credentials)
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
async list() {
|
|
518
|
+
const state = readState()
|
|
519
|
+
console.log('Goldens:')
|
|
520
|
+
for (const golden of completedGoldens()) {
|
|
521
|
+
console.log(` ${golden}`)
|
|
522
|
+
}
|
|
523
|
+
console.log('Clones:')
|
|
524
|
+
for (const name of listDirs(CLONES)) {
|
|
525
|
+
const running = isRunning(clonePaths(name)) ? `running on :${PORT}` : 'stopped'
|
|
526
|
+
const marker = state.active === name ? '*' : ' '
|
|
527
|
+
console.log(` ${marker}${name} from ${state.clones[name]?.golden ?? '?'} ${running}`)
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
async doctor() {
|
|
532
|
+
const checks = [
|
|
533
|
+
['mysqld (brew install mysql@8.4)', () => capture(bin('mysqld'), ['--version'])],
|
|
534
|
+
['zstd (brew install zstd)', () => capture('zstd', ['--version'])],
|
|
535
|
+
['aws cli', () => capture('aws', ['--version'])],
|
|
536
|
+
['AWS credentials (aws sso login)', () => capture('aws', ['s3', 'ls', `s3://${BUCKET}/dev-db/`])],
|
|
537
|
+
]
|
|
538
|
+
let ok = true
|
|
539
|
+
for (const [label, check] of checks) {
|
|
540
|
+
try {
|
|
541
|
+
check()
|
|
542
|
+
console.log(`ok ${label}`)
|
|
543
|
+
} catch {
|
|
544
|
+
ok = false
|
|
545
|
+
console.log(`FAIL ${label}`)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
process.exitCode = ok ? 0 : 1
|
|
549
|
+
},
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const [command, ...rest] = process.argv.slice(2)
|
|
553
|
+
if (!command || command === 'help' || command === '--help' || !commands[command]) {
|
|
554
|
+
console.log(HELP)
|
|
555
|
+
process.exit(command && !['help', '--help'].includes(command) ? 1 : 0)
|
|
556
|
+
}
|
|
557
|
+
const { flags, positional } = parseFlags(rest)
|
|
558
|
+
await commands[command](flags, positional)
|
package/package.json
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factorypure/devdb",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local copies of the prod MySQL database for development",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"devdb": "./bin/devdb.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"license": "ISC"
|
|
16
|
+
}
|
package/README.md
DELETED