@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.
- package/CHANGELOG.md +136 -0
- package/dist/adapter.d.ts +11 -0
- package/dist/adapter.js +268 -6
- 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 +302 -6
- 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
package/src/adapter.ts
CHANGED
|
@@ -49,7 +49,263 @@ const sidecarHandshakeLines = (): string[] => [
|
|
|
49
49
|
` console.log(\`${SERVER_READY_MARKER} on http://\${hostname}:\${server.port}\`)`,
|
|
50
50
|
]
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The runtime helpers the entry imports. The database ones are left out of a
|
|
54
|
+
* build with no database, so the bundle carries no migrator it can never run.
|
|
55
|
+
*/
|
|
56
|
+
const runtimeImport = (ctx: EntryGenerationContext): string => {
|
|
57
|
+
const names = ['watchParentProcess', 'parseStandaloneCommand']
|
|
58
|
+
if (ctx.db) names.push('runStandaloneCommand', 'resolveMigrationsDir')
|
|
59
|
+
return `import { ${names.join(', ')} } from '@pikku/deploy-standalone/runtime'`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Environment variable naming the directory the SQLite file lives in.
|
|
64
|
+
*
|
|
65
|
+
* The database has to outlive a release. A deploy that swaps the artifact
|
|
66
|
+
* directory would take the database with it if the file sat beside the bundle,
|
|
67
|
+
* so the path comes from the environment and points somewhere the operator
|
|
68
|
+
* keeps stable across releases, rather than being derived from the bundle's own
|
|
69
|
+
* location the way the frontend directory is.
|
|
70
|
+
*/
|
|
71
|
+
const DATA_DIR_VAR = 'PIKKU_DATA_DIR'
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Full override for the database file, for when it must match a path something
|
|
75
|
+
* else already decided — notably `pikku db migrate`, which has to open the same
|
|
76
|
+
* file this opens or the app runs against an unmigrated database.
|
|
77
|
+
*/
|
|
78
|
+
const DATABASE_FILE_VAR = 'PIKKU_DATABASE_FILE'
|
|
79
|
+
|
|
80
|
+
const DEFAULT_DATABASE_FILENAME = 'pikku.db'
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The dialect factory each runtime opens SQLite with. bun cannot use the node
|
|
84
|
+
* one — `bun:sqlite` is a different driver, and the node build reaches for
|
|
85
|
+
* `node:sqlite`, which a compiled bun binary does not carry.
|
|
86
|
+
*/
|
|
87
|
+
const SQLITE_FACTORY = {
|
|
88
|
+
node: {
|
|
89
|
+
specifier: '@pikku/kysely-node-sqlite',
|
|
90
|
+
fn: 'createNodeSqliteKysely',
|
|
91
|
+
},
|
|
92
|
+
bun: { specifier: '@pikku/kysely-bun-sqlite', fn: 'createBunSqliteKysely' },
|
|
93
|
+
} as const
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The environment variable a Postgres build reads its connection string from.
|
|
97
|
+
*
|
|
98
|
+
* The same name every other pikku host uses, so an artifact dropped onto a
|
|
99
|
+
* machine that already runs a pikku app needs no new variable.
|
|
100
|
+
*/
|
|
101
|
+
const DATABASE_URL_VAR = 'DATABASE_URL'
|
|
102
|
+
|
|
103
|
+
/** Imports the coercion plugin, for an app that generated a map. */
|
|
104
|
+
const coercionImportLines = (coercionImportPath: string): string[] => [
|
|
105
|
+
`import { createCoercionPlugin } from '@pikku/kysely'`,
|
|
106
|
+
`import { coercionMap as __pikkuCoercionMap } from '${coercionImportPath}'`,
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
/** Imports a database-backed entry needs on top of the common set. */
|
|
110
|
+
const dbImportLines = (
|
|
111
|
+
runtime: 'node' | 'bun',
|
|
112
|
+
db: NonNullable<EntryGenerationContext['db']>
|
|
113
|
+
): string[] => [
|
|
114
|
+
...(db.engine === 'sqlite'
|
|
115
|
+
? [
|
|
116
|
+
`import { ${SQLITE_FACTORY[runtime].fn} } from '${SQLITE_FACTORY[runtime].specifier}'`,
|
|
117
|
+
`import { mkdirSync as __pikkuMkdirSync } from 'node:fs'`,
|
|
118
|
+
]
|
|
119
|
+
: [`import { PikkuKysely } from '@pikku/kysely-postgres'`]),
|
|
120
|
+
...(db.coercionImportPath ? coercionImportLines(db.coercionImportPath) : []),
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Opens the database before services are built, so `createSingletonServices`
|
|
125
|
+
* receives `kysely` exactly as a hosted runtime would hand it over.
|
|
126
|
+
*
|
|
127
|
+
* Creating the directory rather than requiring it is deliberate: the artifact
|
|
128
|
+
* is expected to start on a machine where nothing has run yet, and a missing
|
|
129
|
+
* parent directory is the difference between a first boot that works and one
|
|
130
|
+
* that needs a documented mkdir nobody reads.
|
|
131
|
+
*/
|
|
132
|
+
const dbSetupLines = (
|
|
133
|
+
runtime: 'node' | 'bun',
|
|
134
|
+
db: NonNullable<EntryGenerationContext['db']>
|
|
135
|
+
): string[] => {
|
|
136
|
+
const plugins = db.coercionImportPath
|
|
137
|
+
? `[createCoercionPlugin({ map: __pikkuCoercionMap })]`
|
|
138
|
+
: `[]`
|
|
139
|
+
|
|
140
|
+
if (db.engine === 'sqlite') {
|
|
141
|
+
return [
|
|
142
|
+
` const __pikkuDbFile = process.env.${DATABASE_FILE_VAR}`,
|
|
143
|
+
` ? process.env.${DATABASE_FILE_VAR}`,
|
|
144
|
+
` : __pikkuJoin(__pikkuRequireDataDir(), '${DEFAULT_DATABASE_FILENAME}')`,
|
|
145
|
+
` __pikkuMkdirSync(__pikkuDirname(__pikkuDbFile), { recursive: true })`,
|
|
146
|
+
` const kysely = ${SQLITE_FACTORY[runtime].fn}({`,
|
|
147
|
+
` filename: __pikkuDbFile,`,
|
|
148
|
+
` plugins: ${plugins},`,
|
|
149
|
+
` })`,
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return [
|
|
154
|
+
` const __pikkuDbUrl = process.env.${DATABASE_URL_VAR}`,
|
|
155
|
+
` if (!__pikkuDbUrl) {`,
|
|
156
|
+
` throw new Error(`,
|
|
157
|
+
` 'This build connects to Postgres, so it needs ${DATABASE_URL_VAR} set to the database it should open.'`,
|
|
158
|
+
` )`,
|
|
159
|
+
` }`,
|
|
160
|
+
` const __pikkuPg = new PikkuKysely(logger, __pikkuDbUrl)`,
|
|
161
|
+
` await __pikkuPg.init()`,
|
|
162
|
+
...(db.coercionImportPath
|
|
163
|
+
? [
|
|
164
|
+
` const kysely = __pikkuPg.kysely.withPlugin(`,
|
|
165
|
+
` createCoercionPlugin({ map: __pikkuCoercionMap })`,
|
|
166
|
+
` )`,
|
|
167
|
+
]
|
|
168
|
+
: [` const kysely = __pikkuPg.kysely`]),
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Where the migrations sit relative to the running artifact.
|
|
174
|
+
*
|
|
175
|
+
* A node bundle reads them from its own directory. A compiled bun binary has no
|
|
176
|
+
* directory — `import.meta.url` points inside the embedded filesystem — so it
|
|
177
|
+
* resolves them beside the executable, which is where an operator unpacking an
|
|
178
|
+
* artifact puts them.
|
|
179
|
+
*/
|
|
180
|
+
const bundleDirExpression = (runtime: 'node' | 'bun'): string =>
|
|
181
|
+
runtime === 'node'
|
|
182
|
+
? `__pikkuDirname(__pikkuFileURLToPath(import.meta.url))`
|
|
183
|
+
: `__pikkuDirname(process.execPath)`
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The command line, parsed before anything is opened.
|
|
187
|
+
*
|
|
188
|
+
* `version` and `help` have to answer without a database, a config factory or a
|
|
189
|
+
* port, because the machine asking may be one where none of the three work yet
|
|
190
|
+
* — which is exactly when someone runs them.
|
|
191
|
+
*/
|
|
192
|
+
const commandParseLines = (ctx: EntryGenerationContext): string[] => [
|
|
193
|
+
`const __pikkuCommand = parseStandaloneCommand(process.argv.slice(2), {`,
|
|
194
|
+
` version: '${(ctx.version ?? 'unknown').replace(/'/g, "\\'")}',`,
|
|
195
|
+
` hasDb: ${Boolean(ctx.db)},`,
|
|
196
|
+
...(ctx.db ? [` engine: '${ctx.db.engine}',`] : []),
|
|
197
|
+
`})`,
|
|
198
|
+
`if (__pikkuCommand.kind === 'exit') process.exit(__pikkuCommand.code)`,
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Runs a non-serve command against the database the app itself just opened, and
|
|
203
|
+
* stops before a port is bound.
|
|
204
|
+
*
|
|
205
|
+
* Reusing the app's own connection is the point: a command that resolved its
|
|
206
|
+
* own would be free to migrate a different database than the next `serve`
|
|
207
|
+
* reads, and the two would only disagree once in production.
|
|
208
|
+
*/
|
|
209
|
+
const commandDispatchLines = (
|
|
210
|
+
runtime: 'node' | 'bun',
|
|
211
|
+
ctx: EntryGenerationContext
|
|
212
|
+
): string[] => {
|
|
213
|
+
if (!ctx.db) {
|
|
214
|
+
return [` if (__pikkuCommand.kind !== 'serve') process.exit(0)`, ``]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const dir = `__pikkuJoin(${bundleDirExpression(runtime)}, 'db', '${ctx.db.engine}')`
|
|
218
|
+
const handle =
|
|
219
|
+
ctx.db.engine === 'sqlite'
|
|
220
|
+
? `databaseFile: __pikkuDbFile,`
|
|
221
|
+
: `sql: __pikkuPg.sql,`
|
|
222
|
+
|
|
223
|
+
return [
|
|
224
|
+
` const __pikkuDbCommandTarget = {`,
|
|
225
|
+
` engine: '${ctx.db.engine}',`,
|
|
226
|
+
` migrationsDir: resolveMigrationsDir(${dir}),`,
|
|
227
|
+
` ${handle}`,
|
|
228
|
+
` }`,
|
|
229
|
+
` if ((await runStandaloneCommand(__pikkuCommand, __pikkuDbCommandTarget)) === 'done') {`,
|
|
230
|
+
...(ctx.db.engine === 'postgres' ? [` await __pikkuPg.close()`] : []),
|
|
231
|
+
` return`,
|
|
232
|
+
` }`,
|
|
233
|
+
``,
|
|
234
|
+
]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Imports the app's own lifecycle module, when it declares one. */
|
|
238
|
+
const lifecycleImportLines = (
|
|
239
|
+
lifecycle: NonNullable<EntryGenerationContext['lifecycle']>
|
|
240
|
+
): string[] => [
|
|
241
|
+
`import { ${lifecycle.variable} as __pikkuLifecycle } from '${lifecycle.importPath}'`,
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Runs the app's start hooks around the port opening, the same order and the
|
|
246
|
+
* same services `pikku dev` gives them.
|
|
247
|
+
*
|
|
248
|
+
* `beforeStart` runs after `init` so a hook can rely on everything the server
|
|
249
|
+
* resolved, and before `start` so work that must finish before the first
|
|
250
|
+
* request — a seeded admin account, a schema probe — is finished when one
|
|
251
|
+
* arrives.
|
|
252
|
+
*/
|
|
253
|
+
const lifecycleStartLines = (): string[] => [
|
|
254
|
+
` await __pikkuLifecycle?.beforeStart?.(singletonServices)`,
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
const lifecycleAfterStartLines = (): string[] => [
|
|
258
|
+
` await __pikkuLifecycle?.afterStart?.(singletonServices)`,
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Hands the stop hooks to the signal handler that owns the shutdown.
|
|
263
|
+
*
|
|
264
|
+
* The connection pool is closed in `afterStop`, once the app's own hook and the
|
|
265
|
+
* server have both finished with it — a pool closed any earlier takes the
|
|
266
|
+
* queries they are still allowed to make down with it. SQLite needs no
|
|
267
|
+
* counterpart: the process exiting releases the file.
|
|
268
|
+
*/
|
|
269
|
+
const shutdownHooksArg = (ctx: EntryGenerationContext): string => {
|
|
270
|
+
const before: string[] = []
|
|
271
|
+
const after: string[] = []
|
|
272
|
+
|
|
273
|
+
if (ctx.lifecycle) {
|
|
274
|
+
before.push(`await __pikkuLifecycle?.beforeStop?.(singletonServices)`)
|
|
275
|
+
after.push(`await __pikkuLifecycle?.afterStop?.(singletonServices)`)
|
|
276
|
+
}
|
|
277
|
+
if (ctx.db?.engine === 'postgres') {
|
|
278
|
+
after.push(`await __pikkuPg.close()`)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (before.length === 0 && after.length === 0) return ''
|
|
282
|
+
|
|
283
|
+
const hooks: string[] = []
|
|
284
|
+
if (before.length > 0) {
|
|
285
|
+
hooks.push(`beforeStop: async () => { ${before.join('; ')} }`)
|
|
286
|
+
}
|
|
287
|
+
if (after.length > 0) {
|
|
288
|
+
hooks.push(`afterStop: async () => { ${after.join('; ')} }`)
|
|
289
|
+
}
|
|
290
|
+
return `{ ${hooks.join(', ')} }`
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Fails with the variable's name rather than whatever SQLite says about a path
|
|
295
|
+
* of `undefined/pikku.db`, which is the error an operator would otherwise have
|
|
296
|
+
* to work backwards from.
|
|
297
|
+
*/
|
|
298
|
+
const dataDirHelperLines = (): string[] => [
|
|
299
|
+
`function __pikkuRequireDataDir() {`,
|
|
300
|
+
` const dir = process.env.${DATA_DIR_VAR}`,
|
|
301
|
+
` if (!dir) {`,
|
|
302
|
+
` throw new Error(`,
|
|
303
|
+
` 'This build bundles a SQLite database, so it needs somewhere to keep it. Set ${DATA_DIR_VAR} to a writable directory that survives a release swap, or set ${DATABASE_FILE_VAR} to the database file itself.'`,
|
|
304
|
+
` )`,
|
|
305
|
+
` }`,
|
|
306
|
+
` return dir`,
|
|
307
|
+
`}`,
|
|
308
|
+
]
|
|
53
309
|
|
|
54
310
|
/**
|
|
55
311
|
* `rustc -vV`, or nothing when no toolchain is installed. The triple then falls
|
|
@@ -122,13 +378,15 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
122
378
|
`import { PikkuNodeHTTPServer } from '@pikku/node-http-server'`,
|
|
123
379
|
`import { DEFAULT_WS_MAX_PAYLOAD, pikkuWebsocketHandler } from '@pikku/ws'`,
|
|
124
380
|
`import { WebSocketServer } from 'ws'`,
|
|
125
|
-
|
|
126
|
-
...(ctx.frontend
|
|
381
|
+
runtimeImport(ctx),
|
|
382
|
+
...(ctx.frontend || ctx.db
|
|
127
383
|
? [
|
|
128
384
|
`import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
|
|
129
385
|
`import { fileURLToPath as __pikkuFileURLToPath } from 'node:url'`,
|
|
130
386
|
]
|
|
131
387
|
: []),
|
|
388
|
+
...(ctx.db ? dbImportLines('node', ctx.db) : []),
|
|
389
|
+
...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
|
|
132
390
|
``,
|
|
133
391
|
ctx.configImport,
|
|
134
392
|
ctx.servicesImport,
|
|
@@ -140,6 +398,8 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
140
398
|
`const port = parseInt(process.env.PORT || '3000', 10)`,
|
|
141
399
|
`const hostname = process.env.HOST || '0.0.0.0'`,
|
|
142
400
|
``,
|
|
401
|
+
...commandParseLines(ctx),
|
|
402
|
+
``,
|
|
143
403
|
`async function main() {`,
|
|
144
404
|
` const config = await ${ctx.configVar}()`,
|
|
145
405
|
` const schedulerService = new InMemorySchedulerService()`,
|
|
@@ -149,8 +409,11 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
149
409
|
` const eventHub = new LocalEventHubService()`,
|
|
150
410
|
` workflowService.wireQueueWorkers()`,
|
|
151
411
|
` wireAgentScorerQueueWorkers()`,
|
|
412
|
+
...(ctx.db ? dbSetupLines('node', ctx.db) : []),
|
|
413
|
+
...commandDispatchLines('node', ctx),
|
|
152
414
|
` const singletonServices = await ${ctx.servicesVar}(config, {`,
|
|
153
415
|
` logger,`,
|
|
416
|
+
...(ctx.db ? [` kysely,`] : []),
|
|
154
417
|
` schedulerService,`,
|
|
155
418
|
` queueService,`,
|
|
156
419
|
` workflowService,`,
|
|
@@ -185,8 +448,10 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
185
448
|
` await server.init()`,
|
|
186
449
|
` await schedulerService.start()`,
|
|
187
450
|
` await triggerService.start()`,
|
|
188
|
-
|
|
451
|
+
...(ctx.lifecycle ? lifecycleStartLines() : []),
|
|
452
|
+
` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
|
|
189
453
|
` await server.start()`,
|
|
454
|
+
...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
|
|
190
455
|
...sidecarHandshakeLines(),
|
|
191
456
|
`}`,
|
|
192
457
|
``,
|
|
@@ -195,6 +460,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
195
460
|
` process.exit(1)`,
|
|
196
461
|
`})`,
|
|
197
462
|
``,
|
|
463
|
+
...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
|
|
198
464
|
].join('\n')
|
|
199
465
|
}
|
|
200
466
|
|
|
@@ -202,7 +468,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
202
468
|
return [
|
|
203
469
|
`// Generated standalone entry (bun runtime) — all functions in one process`,
|
|
204
470
|
`import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
|
|
205
|
-
|
|
471
|
+
runtimeImport(ctx),
|
|
206
472
|
`import { pikkuState } from '@pikku/core/state'`,
|
|
207
473
|
`import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
|
|
208
474
|
`import { InMemorySchedulerService } from '@pikku/schedule'`,
|
|
@@ -210,6 +476,13 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
210
476
|
...(ctx.frontend
|
|
211
477
|
? [`import { frontendAssets } from '${STANDALONE_FRONTEND_MANIFEST}'`]
|
|
212
478
|
: []),
|
|
479
|
+
...(ctx.db
|
|
480
|
+
? [
|
|
481
|
+
`import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
|
|
482
|
+
]
|
|
483
|
+
: []),
|
|
484
|
+
...(ctx.db ? dbImportLines('bun', ctx.db) : []),
|
|
485
|
+
...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
|
|
213
486
|
``,
|
|
214
487
|
ctx.configImport,
|
|
215
488
|
ctx.servicesImport,
|
|
@@ -221,6 +494,8 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
221
494
|
`const port = parseInt(process.env.PORT || '3000', 10)`,
|
|
222
495
|
`const hostname = process.env.HOST || '0.0.0.0'`,
|
|
223
496
|
``,
|
|
497
|
+
...commandParseLines(ctx),
|
|
498
|
+
``,
|
|
224
499
|
`async function main() {`,
|
|
225
500
|
` const config = await ${ctx.configVar}()`,
|
|
226
501
|
` const schedulerService = new InMemorySchedulerService()`,
|
|
@@ -230,8 +505,11 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
230
505
|
` const eventHub = new BunEventHubService()`,
|
|
231
506
|
` workflowService.wireQueueWorkers()`,
|
|
232
507
|
` wireAgentScorerQueueWorkers()`,
|
|
508
|
+
...(ctx.db ? dbSetupLines('bun', ctx.db) : []),
|
|
509
|
+
...commandDispatchLines('bun', ctx),
|
|
233
510
|
` const singletonServices = await ${ctx.servicesVar}(config, {`,
|
|
234
511
|
` logger,`,
|
|
512
|
+
...(ctx.db ? [` kysely,`] : []),
|
|
235
513
|
` schedulerService,`,
|
|
236
514
|
` queueService,`,
|
|
237
515
|
` workflowService,`,
|
|
@@ -258,8 +536,10 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
258
536
|
` await server.init()`,
|
|
259
537
|
` await schedulerService.start()`,
|
|
260
538
|
` await triggerService.start()`,
|
|
261
|
-
|
|
539
|
+
...(ctx.lifecycle ? lifecycleStartLines() : []),
|
|
540
|
+
` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
|
|
262
541
|
` await server.start()`,
|
|
542
|
+
...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
|
|
263
543
|
...sidecarHandshakeLines(),
|
|
264
544
|
`}`,
|
|
265
545
|
``,
|
|
@@ -268,6 +548,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
268
548
|
` process.exit(1)`,
|
|
269
549
|
`})`,
|
|
270
550
|
``,
|
|
551
|
+
...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
|
|
271
552
|
].join('\n')
|
|
272
553
|
}
|
|
273
554
|
|
|
@@ -294,6 +575,21 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
294
575
|
return externals
|
|
295
576
|
}
|
|
296
577
|
|
|
578
|
+
/**
|
|
579
|
+
* The SQLite driver this runtime cannot load.
|
|
580
|
+
*
|
|
581
|
+
* `loadSqliteRuntime` picks its driver by looking for `globalThis.Bun`, so a
|
|
582
|
+
* node process never runs the bun branch — but esbuild still follows the
|
|
583
|
+
* import, and `bun:sqlite` sits at the top of that module as a static import
|
|
584
|
+
* it cannot resolve. Left in, the bundle fails to build; marked external, it
|
|
585
|
+
* becomes a top-level import node fails to load. Stubbing removes the branch
|
|
586
|
+
* that was already dead.
|
|
587
|
+
*/
|
|
588
|
+
getStubModules(): string[] {
|
|
589
|
+
if (this.runtime === 'bun') return []
|
|
590
|
+
return ['sqlite-runtime-bun']
|
|
591
|
+
}
|
|
592
|
+
|
|
297
593
|
getPlatform(): 'node' {
|
|
298
594
|
return 'node'
|
|
299
595
|
}
|
|
@@ -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
|
+
})
|