@pikku/deploy-standalone 0.12.13 → 0.12.17

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.
@@ -0,0 +1,193 @@
1
+ /** Where the migrations live, when the operator has moved them. */
2
+ export const MIGRATIONS_DIR_ENV = 'PIKKU_MIGRATIONS_DIR';
3
+ const usage = (hasDb, engine) => [
4
+ 'Usage: <bundle> [command]',
5
+ '',
6
+ ' serve Start the server. The default when no command is given.',
7
+ ' version Print the version this artifact was built from.',
8
+ ' help Print this.',
9
+ ...(hasDb
10
+ ? [
11
+ '',
12
+ ' db migrate Apply pending migrations to the database this build opens.',
13
+ ' db status List applied and pending migrations.',
14
+ ...(engine === 'sqlite'
15
+ ? [' backup <path> Copy the database to <path>, consistently.']
16
+ : []),
17
+ ]
18
+ : []),
19
+ '',
20
+ 'Environment:',
21
+ ` ${MIGRATIONS_DIR_ENV} Migrations directory, when not the one beside the bundle.`,
22
+ ].join('\n');
23
+ export function parseStandaloneCommand(argv, options) {
24
+ const write = options.write ?? ((line) => console.log(line));
25
+ const [command, ...rest] = argv;
26
+ if (command === undefined || command === 'serve')
27
+ return { kind: 'serve' };
28
+ if (command === 'version' || command === '--version' || command === '-v') {
29
+ write(options.version);
30
+ return { kind: 'exit', code: 0 };
31
+ }
32
+ if (command === 'help' || command === '--help' || command === '-h') {
33
+ write(usage(options.hasDb, options.engine));
34
+ return { kind: 'exit', code: 0 };
35
+ }
36
+ const needsDb = command === 'db' || command === 'backup';
37
+ if (needsDb && !options.hasDb) {
38
+ write(`This build opens no database, so there is nothing for \`${command}\` to act on.`);
39
+ return { kind: 'exit', code: 1 };
40
+ }
41
+ if (command === 'db') {
42
+ const action = rest[0];
43
+ if (action === 'migrate' || action === 'status') {
44
+ return { kind: 'db', action };
45
+ }
46
+ write(action === undefined
47
+ ? 'db needs an action: migrate or status.'
48
+ : `Unknown db action: ${action}. Expected migrate or status.`);
49
+ return { kind: 'exit', code: 1 };
50
+ }
51
+ if (command === 'backup') {
52
+ // Postgres has pg_dump, which understands roles, extensions and large
53
+ // objects that copying bytes out from in here would silently drop. Offering
54
+ // the word for both engines would promise a backup on one of them that is
55
+ // not one.
56
+ if (options.engine !== 'sqlite') {
57
+ write('backup is for the SQLite builds, whose database is a file this process owns. Use pg_dump against DATABASE_URL.');
58
+ return { kind: 'exit', code: 1 };
59
+ }
60
+ const destination = rest[0];
61
+ if (!destination) {
62
+ write('backup needs a path to write the copy to.');
63
+ return { kind: 'exit', code: 1 };
64
+ }
65
+ return { kind: 'backup', destination };
66
+ }
67
+ write(`Unknown command: ${command}\n\n${usage(options.hasDb, options.engine)}`);
68
+ return { kind: 'exit', code: 1 };
69
+ }
70
+ /**
71
+ * The migrations directory, honouring an operator who keeps them elsewhere.
72
+ *
73
+ * `bundleDir` is where the build put them, which is the same `db/<engine>/`
74
+ * path Fabric's build container stages into an artifact — so an artifact from
75
+ * either producer answers `db migrate` without being told where to look.
76
+ */
77
+ export const resolveMigrationsDir = (bundleDir, env = process.env) => env[MIGRATIONS_DIR_ENV] ?? bundleDir;
78
+ /**
79
+ * The migration executor for whichever database this build opens.
80
+ *
81
+ * Both drivers are reached by dynamic import so a SQLite build never carries
82
+ * the Postgres one into its bundle, and vice versa.
83
+ */
84
+ const executorFor = async (db) => {
85
+ if (db.engine === 'sqlite') {
86
+ const { SqliteMigrationExecutor, loadSqliteRuntime } = await import('@pikku/migrator-sql/sqlite');
87
+ const runtime = await loadSqliteRuntime();
88
+ const handle = runtime.open(db.databaseFile);
89
+ return {
90
+ executor: new SqliteMigrationExecutor(handle),
91
+ close: () => handle.close(),
92
+ };
93
+ }
94
+ const { PostgresMigrationExecutor } = await import('@pikku/migrator-sql/postgres');
95
+ return {
96
+ executor: new PostgresMigrationExecutor(postgresClient(db.sql)),
97
+ close: () => { },
98
+ };
99
+ };
100
+ /**
101
+ * A `PostgresMigrationClient` over the app's own postgres.js connection.
102
+ *
103
+ * `begin` is what makes a failed migration roll back: postgres.js hands the
104
+ * handler a single reserved connection, so the DDL and the bookkeeping row are
105
+ * one transaction rather than statements a pool may spread across three.
106
+ *
107
+ * `simple()` on the plain path is deliberate — a migration file is many
108
+ * statements, and the extended protocol accepts only one per message.
109
+ */
110
+ const postgresClient = (sql) => ({
111
+ async query(text, params) {
112
+ const rows = (await sql.unsafe(text, params ?? []));
113
+ return { rows };
114
+ },
115
+ async exec(text) {
116
+ return sql.unsafe(text).simple();
117
+ },
118
+ begin(handler) {
119
+ return sql.begin((tx) => handler(postgresClient(tx)));
120
+ },
121
+ });
122
+ const stdout = { write: (line) => console.log(line) };
123
+ export async function runDbCommand(action, db, out = stdout) {
124
+ const { migrate, pendingMigrations } = await import('@pikku/migrator-sql');
125
+ const { executor, close } = await executorFor(db);
126
+ try {
127
+ if (action === 'migrate') {
128
+ const { applied, skipped } = await migrate(executor, db.migrationsDir);
129
+ for (const name of applied)
130
+ out.write(`applied ${name}`);
131
+ out.write(applied.length === 0
132
+ ? `Already up to date (${skipped.length} applied previously).`
133
+ : `Applied ${applied.length} migration(s).`);
134
+ return;
135
+ }
136
+ await executor.ensureTrackingTable();
137
+ const applied = await executor.getApplied();
138
+ const pending = pendingMigrations(db.migrationsDir, applied);
139
+ for (const row of applied) {
140
+ out.write(`applied ${row.name} ${row.applied_at}`);
141
+ }
142
+ for (const name of pending)
143
+ out.write(`pending ${name}`);
144
+ out.write(`${applied.length} applied, ${pending.length} pending.`);
145
+ }
146
+ finally {
147
+ close();
148
+ }
149
+ }
150
+ /**
151
+ * Copy the SQLite database somewhere else, while the app may be running.
152
+ *
153
+ * `VACUUM INTO` rather than copying the file: a plain copy taken while another
154
+ * process is mid-write captures a torn page and a write-ahead log it has no
155
+ * copy of, which restores as a corrupt database and only says so later.
156
+ */
157
+ export async function runBackupCommand(destination, db, out = stdout) {
158
+ const { loadSqliteRuntime } = await import('@pikku/migrator-sql/sqlite');
159
+ const runtime = await loadSqliteRuntime();
160
+ const handle = runtime.open(db.databaseFile);
161
+ try {
162
+ handle.exec(`VACUUM INTO '${destination.replace(/'/g, "''")}'`);
163
+ out.write(`Copied ${db.databaseFile} to ${destination}.`);
164
+ }
165
+ finally {
166
+ handle.close();
167
+ }
168
+ }
169
+ /**
170
+ * Run whatever the argv asked for, and say whether the caller should serve.
171
+ *
172
+ * The database is passed already open, because the entry has to open it the one
173
+ * way the app does — a command that resolved its own connection could migrate a
174
+ * different database than the next `serve` reads.
175
+ */
176
+ export async function runStandaloneCommand(command, db, out = stdout) {
177
+ if (command.kind === 'serve')
178
+ return 'serve';
179
+ if (command.kind === 'exit')
180
+ process.exit(command.code);
181
+ if (!db) {
182
+ throw new Error('This build opens no database.');
183
+ }
184
+ if (command.kind === 'db') {
185
+ await runDbCommand(command.action, db, out);
186
+ return 'done';
187
+ }
188
+ if (db.engine !== 'sqlite') {
189
+ throw new Error('backup is only available on a SQLite build.');
190
+ }
191
+ await runBackupCommand(command.destination, db, out);
192
+ return 'done';
193
+ }
@@ -7,3 +7,5 @@
7
7
  */
8
8
  export { DATA_DIR_ENV, PARENT_PID_ENV, watchParentProcess, } from './parent-watch.js';
9
9
  export type { ParentWatch, ParentWatchOptions } from './parent-watch.js';
10
+ export { parseStandaloneCommand, runStandaloneCommand, runDbCommand, runBackupCommand, resolveMigrationsDir, MIGRATIONS_DIR_ENV, } from './cli.js';
11
+ export type { StandaloneCommand, StandaloneDb, StandaloneSqliteDb, StandalonePostgresDb, PostgresSql, CommandOutput, ParseOptions, } from './cli.js';
@@ -6,3 +6,4 @@
6
6
  * in TypeScript instead of being a string the adapter emits and nobody runs.
7
7
  */
8
8
  export { DATA_DIR_ENV, PARENT_PID_ENV, watchParentProcess, } from './parent-watch.js';
9
+ export { parseStandaloneCommand, runStandaloneCommand, runDbCommand, runBackupCommand, resolveMigrationsDir, MIGRATIONS_DIR_ENV, } from './cli.js';
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@pikku/deploy-standalone",
3
- "version": "0.12.13",
3
+ "version": "0.12.17",
4
4
  "description": "Standalone deploy adapter for Pikku — bundles a project into a node bundle or a compiled bun executable",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "scripts": {
8
8
  "build": "tsc -b",
9
9
  "tsc": "tsc --noEmit",
10
- "test": "node --test --import tsx src/*.test.ts"
10
+ "test": "node --test --import tsx \"src/**/*.test.ts\""
11
11
  },
12
12
  "devDependencies": {
13
13
  "typescript": "^6.0.3"
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "license": "MIT",
20
20
  "dependencies": {
21
- "@pikku/deploy": "^0.12.2"
21
+ "@pikku/deploy": "^0.12.5",
22
+ "@pikku/migrator-sql": "^0.12.3"
22
23
  }
23
24
  }