@pikku/deploy-standalone 0.12.13 → 0.12.19
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/CHANGELOG.md +181 -0
- package/dist/adapter.d.ts +26 -1
- package/dist/adapter.js +332 -7
- package/dist/index.d.ts +1 -0
- package/dist/runtime/cli.d.ts +75 -0
- package/dist/runtime/cli.js +193 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/package.json +4 -3
- package/src/adapter.test.ts +539 -0
- package/src/adapter.ts +395 -8
- package/src/contributors.test.ts +96 -0
- package/src/index.ts +1 -0
- package/src/runtime/cli.test.ts +222 -0
- package/src/runtime/cli.ts +311 -0
- package/src/runtime/index.ts +18 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
parseStandaloneCommand,
|
|
9
|
+
resolveMigrationsDir,
|
|
10
|
+
runDbCommand,
|
|
11
|
+
runBackupCommand,
|
|
12
|
+
runStandaloneCommand,
|
|
13
|
+
MIGRATIONS_DIR_ENV,
|
|
14
|
+
type StandaloneSqliteDb,
|
|
15
|
+
} from './cli.js'
|
|
16
|
+
|
|
17
|
+
const collector = () => {
|
|
18
|
+
const lines: string[] = []
|
|
19
|
+
return { lines, out: { write: (line: string) => lines.push(line) } }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const parse = (argv: string[], overrides: Record<string, unknown> = {}) => {
|
|
23
|
+
const { lines, out } = collector()
|
|
24
|
+
const command = parseStandaloneCommand(argv, {
|
|
25
|
+
version: '1.2.3',
|
|
26
|
+
hasDb: true,
|
|
27
|
+
engine: 'sqlite',
|
|
28
|
+
write: out.write,
|
|
29
|
+
...overrides,
|
|
30
|
+
} as never)
|
|
31
|
+
return { command, lines }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('parseStandaloneCommand', () => {
|
|
35
|
+
it('no arguments means serve, so `node bundle.js` keeps its meaning', () => {
|
|
36
|
+
assert.deepEqual(parse([]).command, { kind: 'serve' })
|
|
37
|
+
assert.deepEqual(parse(['serve']).command, { kind: 'serve' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('version prints and stops before anything is opened', () => {
|
|
41
|
+
const { command, lines } = parse(['version'])
|
|
42
|
+
assert.deepEqual(command, { kind: 'exit', code: 0 })
|
|
43
|
+
assert.deepEqual(lines, ['1.2.3'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('help lists the db commands only when there is a database', () => {
|
|
47
|
+
assert.match(parse(['help']).lines.join('\n'), /db migrate/)
|
|
48
|
+
assert.doesNotMatch(
|
|
49
|
+
parse(['help'], { hasDb: false, engine: undefined }).lines.join('\n'),
|
|
50
|
+
/db migrate/
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('backup is offered on sqlite and refused on postgres', () => {
|
|
55
|
+
assert.deepEqual(parse(['backup', '/tmp/x.db']).command, {
|
|
56
|
+
kind: 'backup',
|
|
57
|
+
destination: '/tmp/x.db',
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const pg = parse(['backup', '/tmp/x.db'], { engine: 'postgres' })
|
|
61
|
+
assert.deepEqual(pg.command, { kind: 'exit', code: 1 })
|
|
62
|
+
assert.match(pg.lines.join('\n'), /pg_dump/)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('a db command on a build with no database fails by saying so', () => {
|
|
66
|
+
const { command, lines } = parse(['db', 'migrate'], {
|
|
67
|
+
hasDb: false,
|
|
68
|
+
engine: undefined,
|
|
69
|
+
})
|
|
70
|
+
assert.deepEqual(command, { kind: 'exit', code: 1 })
|
|
71
|
+
assert.match(lines.join('\n'), /opens no database/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('an unknown db action names the ones that exist', () => {
|
|
75
|
+
const { command, lines } = parse(['db', 'rollback'])
|
|
76
|
+
assert.deepEqual(command, { kind: 'exit', code: 1 })
|
|
77
|
+
assert.match(lines.join('\n'), /Expected migrate or status/)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('an unknown command exits non-zero with the usage', () => {
|
|
81
|
+
const { command, lines } = parse(['start'])
|
|
82
|
+
assert.deepEqual(command, { kind: 'exit', code: 1 })
|
|
83
|
+
assert.match(lines.join('\n'), /Unknown command: start/)
|
|
84
|
+
assert.match(lines.join('\n'), /Usage:/)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('resolveMigrationsDir', () => {
|
|
89
|
+
it('defaults to the directory beside the bundle', () => {
|
|
90
|
+
assert.equal(resolveMigrationsDir('/app/db/sqlite', {}), '/app/db/sqlite')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('an operator who moved them wins', () => {
|
|
94
|
+
assert.equal(
|
|
95
|
+
resolveMigrationsDir('/app/db/sqlite', {
|
|
96
|
+
[MIGRATIONS_DIR_ENV]: '/srv/migrations',
|
|
97
|
+
}),
|
|
98
|
+
'/srv/migrations'
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const sqliteFixture = (
|
|
104
|
+
migrations: Record<string, string>
|
|
105
|
+
): StandaloneSqliteDb => {
|
|
106
|
+
const root = mkdtempSync(join(tmpdir(), 'pikku-cli-'))
|
|
107
|
+
const migrationsDir = join(root, 'db', 'sqlite')
|
|
108
|
+
mkdirSync(migrationsDir, { recursive: true })
|
|
109
|
+
for (const [name, sql] of Object.entries(migrations)) {
|
|
110
|
+
writeFileSync(join(migrationsDir, name), sql)
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
engine: 'sqlite',
|
|
114
|
+
migrationsDir,
|
|
115
|
+
databaseFile: join(root, 'pikku.db'),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe('the sqlite db commands', () => {
|
|
120
|
+
const migrations = {
|
|
121
|
+
'0001_widgets.sql': 'CREATE TABLE widget (id TEXT PRIMARY KEY);',
|
|
122
|
+
'0002_labels.sql': 'ALTER TABLE widget ADD COLUMN label TEXT;',
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
it('migrate applies every file, and a second run applies none', async () => {
|
|
126
|
+
const db = sqliteFixture(migrations)
|
|
127
|
+
|
|
128
|
+
const first = collector()
|
|
129
|
+
await runDbCommand('migrate', db, first.out)
|
|
130
|
+
assert.deepEqual(first.lines, [
|
|
131
|
+
'applied 0001_widgets.sql',
|
|
132
|
+
'applied 0002_labels.sql',
|
|
133
|
+
'Applied 2 migration(s).',
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
const second = collector()
|
|
137
|
+
await runDbCommand('migrate', db, second.out)
|
|
138
|
+
assert.deepEqual(second.lines, ['Already up to date (2 applied previously).'])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('status separates what is applied from what is waiting', async () => {
|
|
142
|
+
const db = sqliteFixture({ '0001_widgets.sql': migrations['0001_widgets.sql']! })
|
|
143
|
+
|
|
144
|
+
await runDbCommand('migrate', db)
|
|
145
|
+
writeFileSync(
|
|
146
|
+
join(db.migrationsDir, '0002_labels.sql'),
|
|
147
|
+
migrations['0002_labels.sql']!
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const { lines, out } = collector()
|
|
151
|
+
await runDbCommand('status', db, out)
|
|
152
|
+
assert.match(lines[0]!, /^applied {2}0001_widgets\.sql {2}\S/)
|
|
153
|
+
assert.equal(lines[1], 'pending 0002_labels.sql')
|
|
154
|
+
assert.equal(lines[2], '1 applied, 1 pending.')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('an edited migration is refused rather than silently re-run', async () => {
|
|
158
|
+
const db = sqliteFixture(migrations)
|
|
159
|
+
await runDbCommand('migrate', db)
|
|
160
|
+
|
|
161
|
+
writeFileSync(
|
|
162
|
+
join(db.migrationsDir, '0001_widgets.sql'),
|
|
163
|
+
'CREATE TABLE widget (id TEXT PRIMARY KEY, tampered TEXT);'
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
await assert.rejects(
|
|
167
|
+
() => runDbCommand('migrate', db),
|
|
168
|
+
/PKU-DB-DRIFT/
|
|
169
|
+
)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('backup writes a database a fresh process can open', async () => {
|
|
173
|
+
const db = sqliteFixture(migrations)
|
|
174
|
+
await runDbCommand('migrate', db)
|
|
175
|
+
|
|
176
|
+
const destination = join(db.migrationsDir, '..', '..', 'copy.db')
|
|
177
|
+
const { lines, out } = collector()
|
|
178
|
+
await runBackupCommand(destination, db, out)
|
|
179
|
+
|
|
180
|
+
assert.ok(existsSync(destination))
|
|
181
|
+
assert.match(lines.join('\n'), /Copied /)
|
|
182
|
+
|
|
183
|
+
const copy = collector()
|
|
184
|
+
await runDbCommand('status', { ...db, databaseFile: destination }, copy.out)
|
|
185
|
+
assert.equal(copy.lines.at(-1), '2 applied, 0 pending.')
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('runStandaloneCommand', () => {
|
|
190
|
+
it('hands serve back to the caller rather than doing anything', async () => {
|
|
191
|
+
assert.equal(
|
|
192
|
+
await runStandaloneCommand({ kind: 'serve' }, undefined),
|
|
193
|
+
'serve'
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('a completed command stops the caller from opening a port', async () => {
|
|
198
|
+
const db = sqliteFixture({
|
|
199
|
+
'0001_widgets.sql': 'CREATE TABLE widget (id TEXT PRIMARY KEY);',
|
|
200
|
+
})
|
|
201
|
+
const { out } = collector()
|
|
202
|
+
assert.equal(
|
|
203
|
+
await runStandaloneCommand({ kind: 'db', action: 'migrate' }, db, out),
|
|
204
|
+
'done'
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('backup on a postgres build is refused, not attempted', async () => {
|
|
209
|
+
await assert.rejects(
|
|
210
|
+
() =>
|
|
211
|
+
runStandaloneCommand(
|
|
212
|
+
{ kind: 'backup', destination: '/tmp/x' },
|
|
213
|
+
{
|
|
214
|
+
engine: 'postgres',
|
|
215
|
+
migrationsDir: '/tmp',
|
|
216
|
+
sql: {} as never,
|
|
217
|
+
}
|
|
218
|
+
),
|
|
219
|
+
/only available on a SQLite build/
|
|
220
|
+
)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command line a shipped standalone artifact answers on.
|
|
3
|
+
*
|
|
4
|
+
* A hosted deploy has a control plane to run migrations and read state; a
|
|
5
|
+
* standalone artifact is a file on a machine, and the operator holding it has
|
|
6
|
+
* nothing but the process itself. Without this, applying a migration to the
|
|
7
|
+
* database the bundle opens means installing the CLI and a checkout beside it
|
|
8
|
+
* on the production host, which is the thing shipping a single bundle was
|
|
9
|
+
* supposed to remove.
|
|
10
|
+
*
|
|
11
|
+
* The set is deliberately small. `serve` is the default so `node bundle.js`
|
|
12
|
+
* keeps meaning what it has always meant, and every other command exits rather
|
|
13
|
+
* than opening a port.
|
|
14
|
+
*
|
|
15
|
+
* Notably absent is a way to invoke an RPC. A running server already answers
|
|
16
|
+
* them over its own wire with auth, sessions and middleware applied; an
|
|
17
|
+
* in-process invoke would answer them with none of that, which on a production
|
|
18
|
+
* box is a way to run any function as nobody.
|
|
19
|
+
*/
|
|
20
|
+
import type { MigrationExecutor } from '@pikku/migrator-sql'
|
|
21
|
+
import type { PostgresMigrationClient } from '@pikku/migrator-sql/postgres'
|
|
22
|
+
|
|
23
|
+
/** Where the migrations live, when the operator has moved them. */
|
|
24
|
+
export const MIGRATIONS_DIR_ENV = 'PIKKU_MIGRATIONS_DIR'
|
|
25
|
+
|
|
26
|
+
export interface StandaloneSqliteDb {
|
|
27
|
+
engine: 'sqlite'
|
|
28
|
+
migrationsDir: string
|
|
29
|
+
/** The file the app itself opens, so a migration cannot target another one. */
|
|
30
|
+
databaseFile: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The postgres.js tagged template the app is already connected through.
|
|
35
|
+
*
|
|
36
|
+
* Taken rather than constructed so a migration runs on the app's own pool: a
|
|
37
|
+
* second connection would need the credentials resolved twice and could reach a
|
|
38
|
+
* different database than the one the next `serve` opens.
|
|
39
|
+
*/
|
|
40
|
+
export interface PostgresSql {
|
|
41
|
+
unsafe(query: string, parameters?: unknown[]): Promise<any> & {
|
|
42
|
+
simple(): Promise<any>
|
|
43
|
+
}
|
|
44
|
+
begin<T>(handler: (sql: PostgresSql) => Promise<T>): Promise<T>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface StandalonePostgresDb {
|
|
48
|
+
engine: 'postgres'
|
|
49
|
+
migrationsDir: string
|
|
50
|
+
sql: PostgresSql
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type StandaloneDb = StandaloneSqliteDb | StandalonePostgresDb
|
|
54
|
+
|
|
55
|
+
export type StandaloneCommand =
|
|
56
|
+
| { kind: 'serve' }
|
|
57
|
+
| { kind: 'db'; action: 'migrate' | 'status' }
|
|
58
|
+
| { kind: 'backup'; destination: string }
|
|
59
|
+
| { kind: 'exit'; code: number }
|
|
60
|
+
|
|
61
|
+
export interface ParseOptions {
|
|
62
|
+
version: string
|
|
63
|
+
/** False for a build with no database, whose db commands cannot be answered. */
|
|
64
|
+
hasDb: boolean
|
|
65
|
+
engine?: 'sqlite' | 'postgres'
|
|
66
|
+
write?: (line: string) => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const usage = (hasDb: boolean, engine?: 'sqlite' | 'postgres'): string =>
|
|
70
|
+
[
|
|
71
|
+
'Usage: <bundle> [command]',
|
|
72
|
+
'',
|
|
73
|
+
' serve Start the server. The default when no command is given.',
|
|
74
|
+
' version Print the version this artifact was built from.',
|
|
75
|
+
' help Print this.',
|
|
76
|
+
...(hasDb
|
|
77
|
+
? [
|
|
78
|
+
'',
|
|
79
|
+
' db migrate Apply pending migrations to the database this build opens.',
|
|
80
|
+
' db status List applied and pending migrations.',
|
|
81
|
+
...(engine === 'sqlite'
|
|
82
|
+
? [' backup <path> Copy the database to <path>, consistently.']
|
|
83
|
+
: []),
|
|
84
|
+
]
|
|
85
|
+
: []),
|
|
86
|
+
'',
|
|
87
|
+
'Environment:',
|
|
88
|
+
` ${MIGRATIONS_DIR_ENV} Migrations directory, when not the one beside the bundle.`,
|
|
89
|
+
].join('\n')
|
|
90
|
+
|
|
91
|
+
export function parseStandaloneCommand(
|
|
92
|
+
argv: string[],
|
|
93
|
+
options: ParseOptions
|
|
94
|
+
): StandaloneCommand {
|
|
95
|
+
const write = options.write ?? ((line: string) => console.log(line))
|
|
96
|
+
const [command, ...rest] = argv
|
|
97
|
+
|
|
98
|
+
if (command === undefined || command === 'serve') return { kind: 'serve' }
|
|
99
|
+
|
|
100
|
+
if (command === 'version' || command === '--version' || command === '-v') {
|
|
101
|
+
write(options.version)
|
|
102
|
+
return { kind: 'exit', code: 0 }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
106
|
+
write(usage(options.hasDb, options.engine))
|
|
107
|
+
return { kind: 'exit', code: 0 }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const needsDb = command === 'db' || command === 'backup'
|
|
111
|
+
if (needsDb && !options.hasDb) {
|
|
112
|
+
write(
|
|
113
|
+
`This build opens no database, so there is nothing for \`${command}\` to act on.`
|
|
114
|
+
)
|
|
115
|
+
return { kind: 'exit', code: 1 }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (command === 'db') {
|
|
119
|
+
const action = rest[0]
|
|
120
|
+
if (action === 'migrate' || action === 'status') {
|
|
121
|
+
return { kind: 'db', action }
|
|
122
|
+
}
|
|
123
|
+
write(
|
|
124
|
+
action === undefined
|
|
125
|
+
? 'db needs an action: migrate or status.'
|
|
126
|
+
: `Unknown db action: ${action}. Expected migrate or status.`
|
|
127
|
+
)
|
|
128
|
+
return { kind: 'exit', code: 1 }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (command === 'backup') {
|
|
132
|
+
// Postgres has pg_dump, which understands roles, extensions and large
|
|
133
|
+
// objects that copying bytes out from in here would silently drop. Offering
|
|
134
|
+
// the word for both engines would promise a backup on one of them that is
|
|
135
|
+
// not one.
|
|
136
|
+
if (options.engine !== 'sqlite') {
|
|
137
|
+
write(
|
|
138
|
+
'backup is for the SQLite builds, whose database is a file this process owns. Use pg_dump against DATABASE_URL.'
|
|
139
|
+
)
|
|
140
|
+
return { kind: 'exit', code: 1 }
|
|
141
|
+
}
|
|
142
|
+
const destination = rest[0]
|
|
143
|
+
if (!destination) {
|
|
144
|
+
write('backup needs a path to write the copy to.')
|
|
145
|
+
return { kind: 'exit', code: 1 }
|
|
146
|
+
}
|
|
147
|
+
return { kind: 'backup', destination }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
write(`Unknown command: ${command}\n\n${usage(options.hasDb, options.engine)}`)
|
|
151
|
+
return { kind: 'exit', code: 1 }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The migrations directory, honouring an operator who keeps them elsewhere.
|
|
156
|
+
*
|
|
157
|
+
* `bundleDir` is where the build put them, which is the same `db/<engine>/`
|
|
158
|
+
* path Fabric's build container stages into an artifact — so an artifact from
|
|
159
|
+
* either producer answers `db migrate` without being told where to look.
|
|
160
|
+
*/
|
|
161
|
+
export const resolveMigrationsDir = (
|
|
162
|
+
bundleDir: string,
|
|
163
|
+
env: Record<string, string | undefined> = process.env
|
|
164
|
+
): string => env[MIGRATIONS_DIR_ENV] ?? bundleDir
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The migration executor for whichever database this build opens.
|
|
168
|
+
*
|
|
169
|
+
* Both drivers are reached by dynamic import so a SQLite build never carries
|
|
170
|
+
* the Postgres one into its bundle, and vice versa.
|
|
171
|
+
*/
|
|
172
|
+
const executorFor = async (
|
|
173
|
+
db: StandaloneDb
|
|
174
|
+
): Promise<{ executor: MigrationExecutor; close: () => void }> => {
|
|
175
|
+
if (db.engine === 'sqlite') {
|
|
176
|
+
const { SqliteMigrationExecutor, loadSqliteRuntime } = await import(
|
|
177
|
+
'@pikku/migrator-sql/sqlite'
|
|
178
|
+
)
|
|
179
|
+
const runtime = await loadSqliteRuntime()
|
|
180
|
+
const handle = runtime.open(db.databaseFile)
|
|
181
|
+
return {
|
|
182
|
+
executor: new SqliteMigrationExecutor(handle),
|
|
183
|
+
close: () => handle.close(),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const { PostgresMigrationExecutor } = await import(
|
|
188
|
+
'@pikku/migrator-sql/postgres'
|
|
189
|
+
)
|
|
190
|
+
return {
|
|
191
|
+
executor: new PostgresMigrationExecutor(postgresClient(db.sql)),
|
|
192
|
+
close: () => {},
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A `PostgresMigrationClient` over the app's own postgres.js connection.
|
|
198
|
+
*
|
|
199
|
+
* `begin` is what makes a failed migration roll back: postgres.js hands the
|
|
200
|
+
* handler a single reserved connection, so the DDL and the bookkeeping row are
|
|
201
|
+
* one transaction rather than statements a pool may spread across three.
|
|
202
|
+
*
|
|
203
|
+
* `simple()` on the plain path is deliberate — a migration file is many
|
|
204
|
+
* statements, and the extended protocol accepts only one per message.
|
|
205
|
+
*/
|
|
206
|
+
const postgresClient = (sql: PostgresSql): PostgresMigrationClient => ({
|
|
207
|
+
async query<T = unknown>(text: string, params?: unknown[]) {
|
|
208
|
+
const rows = (await sql.unsafe(text, params ?? [])) as T[]
|
|
209
|
+
return { rows }
|
|
210
|
+
},
|
|
211
|
+
async exec(text: string) {
|
|
212
|
+
return sql.unsafe(text).simple()
|
|
213
|
+
},
|
|
214
|
+
begin<T>(handler: (client: PostgresMigrationClient) => Promise<T>) {
|
|
215
|
+
return sql.begin((tx) => handler(postgresClient(tx)))
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
export interface CommandOutput {
|
|
220
|
+
write(line: string): void
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const stdout: CommandOutput = { write: (line) => console.log(line) }
|
|
224
|
+
|
|
225
|
+
export async function runDbCommand(
|
|
226
|
+
action: 'migrate' | 'status',
|
|
227
|
+
db: StandaloneDb,
|
|
228
|
+
out: CommandOutput = stdout
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const { migrate, pendingMigrations } = await import('@pikku/migrator-sql')
|
|
231
|
+
const { executor, close } = await executorFor(db)
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
if (action === 'migrate') {
|
|
235
|
+
const { applied, skipped } = await migrate(executor, db.migrationsDir)
|
|
236
|
+
for (const name of applied) out.write(`applied ${name}`)
|
|
237
|
+
out.write(
|
|
238
|
+
applied.length === 0
|
|
239
|
+
? `Already up to date (${skipped.length} applied previously).`
|
|
240
|
+
: `Applied ${applied.length} migration(s).`
|
|
241
|
+
)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await executor.ensureTrackingTable()
|
|
246
|
+
const applied = await executor.getApplied()
|
|
247
|
+
const pending = pendingMigrations(db.migrationsDir, applied)
|
|
248
|
+
|
|
249
|
+
for (const row of applied) {
|
|
250
|
+
out.write(`applied ${row.name} ${row.applied_at}`)
|
|
251
|
+
}
|
|
252
|
+
for (const name of pending) out.write(`pending ${name}`)
|
|
253
|
+
out.write(`${applied.length} applied, ${pending.length} pending.`)
|
|
254
|
+
} finally {
|
|
255
|
+
close()
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Copy the SQLite database somewhere else, while the app may be running.
|
|
261
|
+
*
|
|
262
|
+
* `VACUUM INTO` rather than copying the file: a plain copy taken while another
|
|
263
|
+
* process is mid-write captures a torn page and a write-ahead log it has no
|
|
264
|
+
* copy of, which restores as a corrupt database and only says so later.
|
|
265
|
+
*/
|
|
266
|
+
export async function runBackupCommand(
|
|
267
|
+
destination: string,
|
|
268
|
+
db: StandaloneSqliteDb,
|
|
269
|
+
out: CommandOutput = stdout
|
|
270
|
+
): Promise<void> {
|
|
271
|
+
const { loadSqliteRuntime } = await import('@pikku/migrator-sql/sqlite')
|
|
272
|
+
const runtime = await loadSqliteRuntime()
|
|
273
|
+
const handle = runtime.open(db.databaseFile)
|
|
274
|
+
try {
|
|
275
|
+
handle.exec(`VACUUM INTO '${destination.replace(/'/g, "''")}'`)
|
|
276
|
+
out.write(`Copied ${db.databaseFile} to ${destination}.`)
|
|
277
|
+
} finally {
|
|
278
|
+
handle.close()
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Run whatever the argv asked for, and say whether the caller should serve.
|
|
284
|
+
*
|
|
285
|
+
* The database is passed already open, because the entry has to open it the one
|
|
286
|
+
* way the app does — a command that resolved its own connection could migrate a
|
|
287
|
+
* different database than the next `serve` reads.
|
|
288
|
+
*/
|
|
289
|
+
export async function runStandaloneCommand(
|
|
290
|
+
command: StandaloneCommand,
|
|
291
|
+
db: StandaloneDb | undefined,
|
|
292
|
+
out: CommandOutput = stdout
|
|
293
|
+
): Promise<'serve' | 'done'> {
|
|
294
|
+
if (command.kind === 'serve') return 'serve'
|
|
295
|
+
if (command.kind === 'exit') process.exit(command.code)
|
|
296
|
+
|
|
297
|
+
if (!db) {
|
|
298
|
+
throw new Error('This build opens no database.')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (command.kind === 'db') {
|
|
302
|
+
await runDbCommand(command.action, db, out)
|
|
303
|
+
return 'done'
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (db.engine !== 'sqlite') {
|
|
307
|
+
throw new Error('backup is only available on a SQLite build.')
|
|
308
|
+
}
|
|
309
|
+
await runBackupCommand(command.destination, db, out)
|
|
310
|
+
return 'done'
|
|
311
|
+
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -11,3 +11,21 @@ export {
|
|
|
11
11
|
watchParentProcess,
|
|
12
12
|
} from './parent-watch.js'
|
|
13
13
|
export type { ParentWatch, ParentWatchOptions } from './parent-watch.js'
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
parseStandaloneCommand,
|
|
17
|
+
runStandaloneCommand,
|
|
18
|
+
runDbCommand,
|
|
19
|
+
runBackupCommand,
|
|
20
|
+
resolveMigrationsDir,
|
|
21
|
+
MIGRATIONS_DIR_ENV,
|
|
22
|
+
} from './cli.js'
|
|
23
|
+
export type {
|
|
24
|
+
StandaloneCommand,
|
|
25
|
+
StandaloneDb,
|
|
26
|
+
StandaloneSqliteDb,
|
|
27
|
+
StandalonePostgresDb,
|
|
28
|
+
PostgresSql,
|
|
29
|
+
CommandOutput,
|
|
30
|
+
ParseOptions,
|
|
31
|
+
} from './cli.js'
|