@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 CHANGED
@@ -1,5 +1,141 @@
1
1
  # @pikku/deploy-standalone
2
2
 
3
+ ## 0.12.17
4
+
5
+ ### Patch Changes
6
+
7
+ - b2e038b: Rename `@pikku/sql-migrator` to `@pikku/migrator-sql`, so a future migrator for
8
+ another store sorts beside it rather than under a second prefix. The package has
9
+ never been published under either name, so nothing depends on the old one.
10
+ - Updated dependencies [b2e038b]
11
+ - @pikku/migrator-sql@0.12.3
12
+
13
+ ## 0.12.16
14
+
15
+ ### Patch Changes
16
+
17
+ - f970f8f: Rename `@pikku/db-migrator` to `@pikku/migrator-sql`.
18
+
19
+ It applies `.sql` files and keeps their bookkeeping; it is not a database
20
+ service, and `db-` read as though it were one. Nothing was ever published under
21
+ the old name, so there is no alias to keep.
22
+
23
+ - Updated dependencies [f970f8f]
24
+ - @pikku/migrator-sql@0.12.2
25
+
26
+ ## 0.12.15
27
+
28
+ ### Patch Changes
29
+
30
+ - a057bec: Give a standalone bundle a command line.
31
+
32
+ An operator holding a standalone artifact on a machine had one thing they could
33
+ do with it: start it. Applying the migrations it needs meant a checkout of the
34
+ project and a second copy of the CLI on a production box, and answering "which
35
+ build is this" meant asking whoever ran the deploy.
36
+
37
+ The bundle now takes a command. `serve` remains the default, so an existing
38
+ `node bundle.js` is unchanged. `version` prints the version the project declared
39
+ at build time. `db migrate` and `db status` apply and report the migrations that
40
+ now ship beside the bundle under `db/<engine>/` — the same path Fabric's build
41
+ container stages them to, so the two producers of an artifact cannot disagree
42
+ about where the SQL lives. `backup <path>` writes a consistent copy of a SQLite
43
+ database with `VACUUM INTO`; on postgres it refuses and names `pg_dump`, which
44
+ is the tool for it.
45
+
46
+ Both engines are supported: a postgres build migrates over the connection it
47
+ already opens. `PostgresMigrationClient` grew an optional `begin`, because a
48
+ pooled client is free to answer `BEGIN`, the migration and `COMMIT` on three
49
+ different connections — which leaves a failed migration half applied with
50
+ nothing to roll back.
51
+
52
+ There is deliberately no way to invoke an RPC. A running server already answers
53
+ them with auth, sessions and middleware applied; an in-process invoke would
54
+ answer them with none of that.
55
+
56
+ - Updated dependencies [a057bec]
57
+ - Updated dependencies [a057bec]
58
+ - @pikku/migrator-sql@0.12.1
59
+ - @pikku/deploy@0.12.5
60
+
61
+ ## 0.12.14
62
+
63
+ ### Patch Changes
64
+
65
+ - 3d75643: Run the app's server lifecycle in generated entries, and make an out-of-band
66
+ account signable-in.
67
+
68
+ `pikkuServerLifecycle` was only ever called by `pikku dev` and `pikku serve`, so
69
+ an app that seeds its first admin account, probes a dependency, or warms a cache
70
+ in `beforeStart` did all of that in development and silently skipped it
71
+ everywhere it was actually deployed. The standalone entry and the shared node
72
+ server entry — the one behind every `target: 'server'` unit — now import the
73
+ app's lifecycle and call it: `beforeStart` after `init` and before the port
74
+ opens, so work that must finish before the first request has, `afterStart` once
75
+ the server is listening, and the stop hooks handed to the signal handler that
76
+ already owns shutdown rather than a second listener racing it. Each shutdown
77
+ step is isolated from the ones after it, so a hook that throws is logged without
78
+ taking the service teardown and the socket close down with it.
79
+
80
+ Separately, `createAuthUser` and `setAuthUserPassword` wrote credential accounts
81
+ with no `issuer`. From better-auth 1.7 a credential account is matched by its
82
+ issuer as well as its provider, so those accounts were invisible to sign-in,
83
+ `updatePassword` and `findCredentialAccount` — a user who plainly existed in the
84
+ table was reported as "user not found". The field is written only when the
85
+ resolved schema has it, so older better-auth keeps working, and
86
+ `setAuthUserPassword` repairs an account that predates the fix.
87
+
88
+ - 3d75643: Give a standalone artifact the database connection every other provider's runtime hands it.
89
+
90
+ `createSingletonServices` receives `kysely` from whatever is hosting the app —
91
+ `pikku dev` builds one, a Cloudflare deploy binds one — so app code is written
92
+ expecting it, and the generated templates throw outright when it is absent. The
93
+ standalone provider is its own host and supplied nothing, so a bundle built from
94
+ a project with a database started, called the services factory, and died on the
95
+ first line of it. The artifact was only ever startable by projects that had no
96
+ database at all, which is not the case the provider exists to serve.
97
+
98
+ The generated entry now opens the database itself and passes `kysely` in.
99
+ `EntryGenerationContext` carries a `db` descriptor, and the engine is read from
100
+ the migrations directory the project actually wrote — `db/sqlite` or
101
+ `db/postgres`, the same two conventions the migrator emits to. Having both is
102
+ refused rather than resolved: choosing on directory order would choose which
103
+ database the deployed app talks to, and an app running happily against the
104
+ wrong but entirely valid schema is invisible until someone reads the data.
105
+
106
+ For SQLite the adapter emits the dialect its runtime can actually use —
107
+ `@pikku/kysely-node-sqlite` for the node bundle, `@pikku/kysely-bun-sqlite` for a
108
+ compiled bun binary, which has no `node:sqlite` to reach for. For Postgres it
109
+ emits `PikkuKysely` from `@pikku/kysely-postgres`, connected from `DATABASE_URL`,
110
+ the same variable every other pikku host reads, so an artifact dropped onto a
111
+ machine already running a pikku app needs no new one. An unset `DATABASE_URL`
112
+ fails by name rather than as a driver error about an undefined connection
113
+ string.
114
+
115
+ The connection pool is closed on shutdown, in `afterStop` — after the app's own
116
+ stop hook and the draining server have both finished with it, since a pool
117
+ closed any earlier takes the queries they are still entitled to make down with
118
+ it. SQLite needs no counterpart: the process exiting releases the file.
119
+
120
+ The project's generated coercion map is applied to either engine, so a deployed
121
+ app and `pikku dev` agree about which columns are dates and which are booleans.
122
+ It is attached when the project generated one and skipped when it did not — the
123
+ map is built from `db/annotations.ts` rather than from the dialect, so an app
124
+ that annotates no columns is one with nothing to coerce rather than one that
125
+ should be handed no database at all.
126
+
127
+ A SQLite file is located by `PIKKU_DATA_DIR` rather than derived from the bundle's
128
+ own path: a deploy that swaps the release directory would otherwise take the
129
+ database with it. `PIKKU_DATABASE_FILE` overrides it outright, for when the path
130
+ has to match one something else already chose — notably `pikku db migrate`,
131
+ which has to open the same file or the app runs against an unmigrated schema.
132
+ Neither being set fails with the variable's name rather than as a SQLite error
133
+ about a path of `undefined`.
134
+
135
+ - Updated dependencies [3d75643]
136
+ - Updated dependencies [3d75643]
137
+ - @pikku/deploy@0.12.4
138
+
3
139
  ## 0.12.13
4
140
 
5
141
  ### Patch Changes
package/dist/adapter.d.ts CHANGED
@@ -66,6 +66,17 @@ export declare class StandaloneProviderAdapter implements ProviderAdapter {
66
66
  generateInfraManifest(): string | null;
67
67
  generateProviderConfigs(): Map<string, string>;
68
68
  getExternals(): string[];
69
+ /**
70
+ * The SQLite driver this runtime cannot load.
71
+ *
72
+ * `loadSqliteRuntime` picks its driver by looking for `globalThis.Bun`, so a
73
+ * node process never runs the bun branch — but esbuild still follows the
74
+ * import, and `bun:sqlite` sits at the top of that module as a static import
75
+ * it cannot resolve. Left in, the bundle fails to build; marked external, it
76
+ * becomes a top-level import node fails to load. Stubbing removes the branch
77
+ * that was already dead.
78
+ */
79
+ getStubModules(): string[];
69
80
  getPlatform(): 'node';
70
81
  deploy(options: {
71
82
  buildDir: string;
package/dist/adapter.js CHANGED
@@ -24,7 +24,229 @@ const sidecarHandshakeLines = () => [
24
24
  ` watchParentProcess()`,
25
25
  ` console.log(\`${SERVER_READY_MARKER} on http://\${hostname}:\${server.port}\`)`,
26
26
  ];
27
- const SIDECAR_RUNTIME_IMPORT = `import { watchParentProcess } from '@pikku/deploy-standalone/runtime'`;
27
+ /**
28
+ * The runtime helpers the entry imports. The database ones are left out of a
29
+ * build with no database, so the bundle carries no migrator it can never run.
30
+ */
31
+ const runtimeImport = (ctx) => {
32
+ const names = ['watchParentProcess', 'parseStandaloneCommand'];
33
+ if (ctx.db)
34
+ names.push('runStandaloneCommand', 'resolveMigrationsDir');
35
+ return `import { ${names.join(', ')} } from '@pikku/deploy-standalone/runtime'`;
36
+ };
37
+ /**
38
+ * Environment variable naming the directory the SQLite file lives in.
39
+ *
40
+ * The database has to outlive a release. A deploy that swaps the artifact
41
+ * directory would take the database with it if the file sat beside the bundle,
42
+ * so the path comes from the environment and points somewhere the operator
43
+ * keeps stable across releases, rather than being derived from the bundle's own
44
+ * location the way the frontend directory is.
45
+ */
46
+ const DATA_DIR_VAR = 'PIKKU_DATA_DIR';
47
+ /**
48
+ * Full override for the database file, for when it must match a path something
49
+ * else already decided — notably `pikku db migrate`, which has to open the same
50
+ * file this opens or the app runs against an unmigrated database.
51
+ */
52
+ const DATABASE_FILE_VAR = 'PIKKU_DATABASE_FILE';
53
+ const DEFAULT_DATABASE_FILENAME = 'pikku.db';
54
+ /**
55
+ * The dialect factory each runtime opens SQLite with. bun cannot use the node
56
+ * one — `bun:sqlite` is a different driver, and the node build reaches for
57
+ * `node:sqlite`, which a compiled bun binary does not carry.
58
+ */
59
+ const SQLITE_FACTORY = {
60
+ node: {
61
+ specifier: '@pikku/kysely-node-sqlite',
62
+ fn: 'createNodeSqliteKysely',
63
+ },
64
+ bun: { specifier: '@pikku/kysely-bun-sqlite', fn: 'createBunSqliteKysely' },
65
+ };
66
+ /**
67
+ * The environment variable a Postgres build reads its connection string from.
68
+ *
69
+ * The same name every other pikku host uses, so an artifact dropped onto a
70
+ * machine that already runs a pikku app needs no new variable.
71
+ */
72
+ const DATABASE_URL_VAR = 'DATABASE_URL';
73
+ /** Imports the coercion plugin, for an app that generated a map. */
74
+ const coercionImportLines = (coercionImportPath) => [
75
+ `import { createCoercionPlugin } from '@pikku/kysely'`,
76
+ `import { coercionMap as __pikkuCoercionMap } from '${coercionImportPath}'`,
77
+ ];
78
+ /** Imports a database-backed entry needs on top of the common set. */
79
+ const dbImportLines = (runtime, db) => [
80
+ ...(db.engine === 'sqlite'
81
+ ? [
82
+ `import { ${SQLITE_FACTORY[runtime].fn} } from '${SQLITE_FACTORY[runtime].specifier}'`,
83
+ `import { mkdirSync as __pikkuMkdirSync } from 'node:fs'`,
84
+ ]
85
+ : [`import { PikkuKysely } from '@pikku/kysely-postgres'`]),
86
+ ...(db.coercionImportPath ? coercionImportLines(db.coercionImportPath) : []),
87
+ ];
88
+ /**
89
+ * Opens the database before services are built, so `createSingletonServices`
90
+ * receives `kysely` exactly as a hosted runtime would hand it over.
91
+ *
92
+ * Creating the directory rather than requiring it is deliberate: the artifact
93
+ * is expected to start on a machine where nothing has run yet, and a missing
94
+ * parent directory is the difference between a first boot that works and one
95
+ * that needs a documented mkdir nobody reads.
96
+ */
97
+ const dbSetupLines = (runtime, db) => {
98
+ const plugins = db.coercionImportPath
99
+ ? `[createCoercionPlugin({ map: __pikkuCoercionMap })]`
100
+ : `[]`;
101
+ if (db.engine === 'sqlite') {
102
+ return [
103
+ ` const __pikkuDbFile = process.env.${DATABASE_FILE_VAR}`,
104
+ ` ? process.env.${DATABASE_FILE_VAR}`,
105
+ ` : __pikkuJoin(__pikkuRequireDataDir(), '${DEFAULT_DATABASE_FILENAME}')`,
106
+ ` __pikkuMkdirSync(__pikkuDirname(__pikkuDbFile), { recursive: true })`,
107
+ ` const kysely = ${SQLITE_FACTORY[runtime].fn}({`,
108
+ ` filename: __pikkuDbFile,`,
109
+ ` plugins: ${plugins},`,
110
+ ` })`,
111
+ ];
112
+ }
113
+ return [
114
+ ` const __pikkuDbUrl = process.env.${DATABASE_URL_VAR}`,
115
+ ` if (!__pikkuDbUrl) {`,
116
+ ` throw new Error(`,
117
+ ` 'This build connects to Postgres, so it needs ${DATABASE_URL_VAR} set to the database it should open.'`,
118
+ ` )`,
119
+ ` }`,
120
+ ` const __pikkuPg = new PikkuKysely(logger, __pikkuDbUrl)`,
121
+ ` await __pikkuPg.init()`,
122
+ ...(db.coercionImportPath
123
+ ? [
124
+ ` const kysely = __pikkuPg.kysely.withPlugin(`,
125
+ ` createCoercionPlugin({ map: __pikkuCoercionMap })`,
126
+ ` )`,
127
+ ]
128
+ : [` const kysely = __pikkuPg.kysely`]),
129
+ ];
130
+ };
131
+ /**
132
+ * Where the migrations sit relative to the running artifact.
133
+ *
134
+ * A node bundle reads them from its own directory. A compiled bun binary has no
135
+ * directory — `import.meta.url` points inside the embedded filesystem — so it
136
+ * resolves them beside the executable, which is where an operator unpacking an
137
+ * artifact puts them.
138
+ */
139
+ const bundleDirExpression = (runtime) => runtime === 'node'
140
+ ? `__pikkuDirname(__pikkuFileURLToPath(import.meta.url))`
141
+ : `__pikkuDirname(process.execPath)`;
142
+ /**
143
+ * The command line, parsed before anything is opened.
144
+ *
145
+ * `version` and `help` have to answer without a database, a config factory or a
146
+ * port, because the machine asking may be one where none of the three work yet
147
+ * — which is exactly when someone runs them.
148
+ */
149
+ const commandParseLines = (ctx) => [
150
+ `const __pikkuCommand = parseStandaloneCommand(process.argv.slice(2), {`,
151
+ ` version: '${(ctx.version ?? 'unknown').replace(/'/g, "\\'")}',`,
152
+ ` hasDb: ${Boolean(ctx.db)},`,
153
+ ...(ctx.db ? [` engine: '${ctx.db.engine}',`] : []),
154
+ `})`,
155
+ `if (__pikkuCommand.kind === 'exit') process.exit(__pikkuCommand.code)`,
156
+ ];
157
+ /**
158
+ * Runs a non-serve command against the database the app itself just opened, and
159
+ * stops before a port is bound.
160
+ *
161
+ * Reusing the app's own connection is the point: a command that resolved its
162
+ * own would be free to migrate a different database than the next `serve`
163
+ * reads, and the two would only disagree once in production.
164
+ */
165
+ const commandDispatchLines = (runtime, ctx) => {
166
+ if (!ctx.db) {
167
+ return [` if (__pikkuCommand.kind !== 'serve') process.exit(0)`, ``];
168
+ }
169
+ const dir = `__pikkuJoin(${bundleDirExpression(runtime)}, 'db', '${ctx.db.engine}')`;
170
+ const handle = ctx.db.engine === 'sqlite'
171
+ ? `databaseFile: __pikkuDbFile,`
172
+ : `sql: __pikkuPg.sql,`;
173
+ return [
174
+ ` const __pikkuDbCommandTarget = {`,
175
+ ` engine: '${ctx.db.engine}',`,
176
+ ` migrationsDir: resolveMigrationsDir(${dir}),`,
177
+ ` ${handle}`,
178
+ ` }`,
179
+ ` if ((await runStandaloneCommand(__pikkuCommand, __pikkuDbCommandTarget)) === 'done') {`,
180
+ ...(ctx.db.engine === 'postgres' ? [` await __pikkuPg.close()`] : []),
181
+ ` return`,
182
+ ` }`,
183
+ ``,
184
+ ];
185
+ };
186
+ /** Imports the app's own lifecycle module, when it declares one. */
187
+ const lifecycleImportLines = (lifecycle) => [
188
+ `import { ${lifecycle.variable} as __pikkuLifecycle } from '${lifecycle.importPath}'`,
189
+ ];
190
+ /**
191
+ * Runs the app's start hooks around the port opening, the same order and the
192
+ * same services `pikku dev` gives them.
193
+ *
194
+ * `beforeStart` runs after `init` so a hook can rely on everything the server
195
+ * resolved, and before `start` so work that must finish before the first
196
+ * request — a seeded admin account, a schema probe — is finished when one
197
+ * arrives.
198
+ */
199
+ const lifecycleStartLines = () => [
200
+ ` await __pikkuLifecycle?.beforeStart?.(singletonServices)`,
201
+ ];
202
+ const lifecycleAfterStartLines = () => [
203
+ ` await __pikkuLifecycle?.afterStart?.(singletonServices)`,
204
+ ];
205
+ /**
206
+ * Hands the stop hooks to the signal handler that owns the shutdown.
207
+ *
208
+ * The connection pool is closed in `afterStop`, once the app's own hook and the
209
+ * server have both finished with it — a pool closed any earlier takes the
210
+ * queries they are still allowed to make down with it. SQLite needs no
211
+ * counterpart: the process exiting releases the file.
212
+ */
213
+ const shutdownHooksArg = (ctx) => {
214
+ const before = [];
215
+ const after = [];
216
+ if (ctx.lifecycle) {
217
+ before.push(`await __pikkuLifecycle?.beforeStop?.(singletonServices)`);
218
+ after.push(`await __pikkuLifecycle?.afterStop?.(singletonServices)`);
219
+ }
220
+ if (ctx.db?.engine === 'postgres') {
221
+ after.push(`await __pikkuPg.close()`);
222
+ }
223
+ if (before.length === 0 && after.length === 0)
224
+ return '';
225
+ const hooks = [];
226
+ if (before.length > 0) {
227
+ hooks.push(`beforeStop: async () => { ${before.join('; ')} }`);
228
+ }
229
+ if (after.length > 0) {
230
+ hooks.push(`afterStop: async () => { ${after.join('; ')} }`);
231
+ }
232
+ return `{ ${hooks.join(', ')} }`;
233
+ };
234
+ /**
235
+ * Fails with the variable's name rather than whatever SQLite says about a path
236
+ * of `undefined/pikku.db`, which is the error an operator would otherwise have
237
+ * to work backwards from.
238
+ */
239
+ const dataDirHelperLines = () => [
240
+ `function __pikkuRequireDataDir() {`,
241
+ ` const dir = process.env.${DATA_DIR_VAR}`,
242
+ ` if (!dir) {`,
243
+ ` throw new Error(`,
244
+ ` '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.'`,
245
+ ` )`,
246
+ ` }`,
247
+ ` return dir`,
248
+ `}`,
249
+ ];
28
250
  /**
29
251
  * `rustc -vV`, or nothing when no toolchain is installed. The triple then falls
30
252
  * back to the Node platform pair, which is right for every ordinary host — the
@@ -73,13 +295,15 @@ export class StandaloneProviderAdapter {
73
295
  `import { PikkuNodeHTTPServer } from '@pikku/node-http-server'`,
74
296
  `import { DEFAULT_WS_MAX_PAYLOAD, pikkuWebsocketHandler } from '@pikku/ws'`,
75
297
  `import { WebSocketServer } from 'ws'`,
76
- SIDECAR_RUNTIME_IMPORT,
77
- ...(ctx.frontend
298
+ runtimeImport(ctx),
299
+ ...(ctx.frontend || ctx.db
78
300
  ? [
79
301
  `import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
80
302
  `import { fileURLToPath as __pikkuFileURLToPath } from 'node:url'`,
81
303
  ]
82
304
  : []),
305
+ ...(ctx.db ? dbImportLines('node', ctx.db) : []),
306
+ ...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
83
307
  ``,
84
308
  ctx.configImport,
85
309
  ctx.servicesImport,
@@ -91,6 +315,8 @@ export class StandaloneProviderAdapter {
91
315
  `const port = parseInt(process.env.PORT || '3000', 10)`,
92
316
  `const hostname = process.env.HOST || '0.0.0.0'`,
93
317
  ``,
318
+ ...commandParseLines(ctx),
319
+ ``,
94
320
  `async function main() {`,
95
321
  ` const config = await ${ctx.configVar}()`,
96
322
  ` const schedulerService = new InMemorySchedulerService()`,
@@ -100,8 +326,11 @@ export class StandaloneProviderAdapter {
100
326
  ` const eventHub = new LocalEventHubService()`,
101
327
  ` workflowService.wireQueueWorkers()`,
102
328
  ` wireAgentScorerQueueWorkers()`,
329
+ ...(ctx.db ? dbSetupLines('node', ctx.db) : []),
330
+ ...commandDispatchLines('node', ctx),
103
331
  ` const singletonServices = await ${ctx.servicesVar}(config, {`,
104
332
  ` logger,`,
333
+ ...(ctx.db ? [` kysely,`] : []),
105
334
  ` schedulerService,`,
106
335
  ` queueService,`,
107
336
  ` workflowService,`,
@@ -136,8 +365,10 @@ export class StandaloneProviderAdapter {
136
365
  ` await server.init()`,
137
366
  ` await schedulerService.start()`,
138
367
  ` await triggerService.start()`,
139
- ` server.enableExitOnSignals()`,
368
+ ...(ctx.lifecycle ? lifecycleStartLines() : []),
369
+ ` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
140
370
  ` await server.start()`,
371
+ ...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
141
372
  ...sidecarHandshakeLines(),
142
373
  `}`,
143
374
  ``,
@@ -146,13 +377,14 @@ export class StandaloneProviderAdapter {
146
377
  ` process.exit(1)`,
147
378
  `})`,
148
379
  ``,
380
+ ...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
149
381
  ].join('\n');
150
382
  }
151
383
  generateBunEntrySource(ctx) {
152
384
  return [
153
385
  `// Generated standalone entry (bun runtime) — all functions in one process`,
154
386
  `import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
155
- SIDECAR_RUNTIME_IMPORT,
387
+ runtimeImport(ctx),
156
388
  `import { pikkuState } from '@pikku/core/state'`,
157
389
  `import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
158
390
  `import { InMemorySchedulerService } from '@pikku/schedule'`,
@@ -160,6 +392,13 @@ export class StandaloneProviderAdapter {
160
392
  ...(ctx.frontend
161
393
  ? [`import { frontendAssets } from '${STANDALONE_FRONTEND_MANIFEST}'`]
162
394
  : []),
395
+ ...(ctx.db
396
+ ? [
397
+ `import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
398
+ ]
399
+ : []),
400
+ ...(ctx.db ? dbImportLines('bun', ctx.db) : []),
401
+ ...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
163
402
  ``,
164
403
  ctx.configImport,
165
404
  ctx.servicesImport,
@@ -171,6 +410,8 @@ export class StandaloneProviderAdapter {
171
410
  `const port = parseInt(process.env.PORT || '3000', 10)`,
172
411
  `const hostname = process.env.HOST || '0.0.0.0'`,
173
412
  ``,
413
+ ...commandParseLines(ctx),
414
+ ``,
174
415
  `async function main() {`,
175
416
  ` const config = await ${ctx.configVar}()`,
176
417
  ` const schedulerService = new InMemorySchedulerService()`,
@@ -180,8 +421,11 @@ export class StandaloneProviderAdapter {
180
421
  ` const eventHub = new BunEventHubService()`,
181
422
  ` workflowService.wireQueueWorkers()`,
182
423
  ` wireAgentScorerQueueWorkers()`,
424
+ ...(ctx.db ? dbSetupLines('bun', ctx.db) : []),
425
+ ...commandDispatchLines('bun', ctx),
183
426
  ` const singletonServices = await ${ctx.servicesVar}(config, {`,
184
427
  ` logger,`,
428
+ ...(ctx.db ? [` kysely,`] : []),
185
429
  ` schedulerService,`,
186
430
  ` queueService,`,
187
431
  ` workflowService,`,
@@ -208,8 +452,10 @@ export class StandaloneProviderAdapter {
208
452
  ` await server.init()`,
209
453
  ` await schedulerService.start()`,
210
454
  ` await triggerService.start()`,
211
- ` server.enableExitOnSignals()`,
455
+ ...(ctx.lifecycle ? lifecycleStartLines() : []),
456
+ ` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
212
457
  ` await server.start()`,
458
+ ...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
213
459
  ...sidecarHandshakeLines(),
214
460
  `}`,
215
461
  ``,
@@ -218,6 +464,7 @@ export class StandaloneProviderAdapter {
218
464
  ` process.exit(1)`,
219
465
  `})`,
220
466
  ``,
467
+ ...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
221
468
  ].join('\n');
222
469
  }
223
470
  generateUnitConfigs() {
@@ -239,6 +486,21 @@ export class StandaloneProviderAdapter {
239
486
  }
240
487
  return externals;
241
488
  }
489
+ /**
490
+ * The SQLite driver this runtime cannot load.
491
+ *
492
+ * `loadSqliteRuntime` picks its driver by looking for `globalThis.Bun`, so a
493
+ * node process never runs the bun branch — but esbuild still follows the
494
+ * import, and `bun:sqlite` sits at the top of that module as a static import
495
+ * it cannot resolve. Left in, the bundle fails to build; marked external, it
496
+ * becomes a top-level import node fails to load. Stubbing removes the branch
497
+ * that was already dead.
498
+ */
499
+ getStubModules() {
500
+ if (this.runtime === 'bun')
501
+ return [];
502
+ return ['sqlite-runtime-bun'];
503
+ }
242
504
  getPlatform() {
243
505
  return 'node';
244
506
  }
@@ -0,0 +1,75 @@
1
+ /** Where the migrations live, when the operator has moved them. */
2
+ export declare const MIGRATIONS_DIR_ENV = "PIKKU_MIGRATIONS_DIR";
3
+ export interface StandaloneSqliteDb {
4
+ engine: 'sqlite';
5
+ migrationsDir: string;
6
+ /** The file the app itself opens, so a migration cannot target another one. */
7
+ databaseFile: string;
8
+ }
9
+ /**
10
+ * The postgres.js tagged template the app is already connected through.
11
+ *
12
+ * Taken rather than constructed so a migration runs on the app's own pool: a
13
+ * second connection would need the credentials resolved twice and could reach a
14
+ * different database than the one the next `serve` opens.
15
+ */
16
+ export interface PostgresSql {
17
+ unsafe(query: string, parameters?: unknown[]): Promise<any> & {
18
+ simple(): Promise<any>;
19
+ };
20
+ begin<T>(handler: (sql: PostgresSql) => Promise<T>): Promise<T>;
21
+ }
22
+ export interface StandalonePostgresDb {
23
+ engine: 'postgres';
24
+ migrationsDir: string;
25
+ sql: PostgresSql;
26
+ }
27
+ export type StandaloneDb = StandaloneSqliteDb | StandalonePostgresDb;
28
+ export type StandaloneCommand = {
29
+ kind: 'serve';
30
+ } | {
31
+ kind: 'db';
32
+ action: 'migrate' | 'status';
33
+ } | {
34
+ kind: 'backup';
35
+ destination: string;
36
+ } | {
37
+ kind: 'exit';
38
+ code: number;
39
+ };
40
+ export interface ParseOptions {
41
+ version: string;
42
+ /** False for a build with no database, whose db commands cannot be answered. */
43
+ hasDb: boolean;
44
+ engine?: 'sqlite' | 'postgres';
45
+ write?: (line: string) => void;
46
+ }
47
+ export declare function parseStandaloneCommand(argv: string[], options: ParseOptions): StandaloneCommand;
48
+ /**
49
+ * The migrations directory, honouring an operator who keeps them elsewhere.
50
+ *
51
+ * `bundleDir` is where the build put them, which is the same `db/<engine>/`
52
+ * path Fabric's build container stages into an artifact — so an artifact from
53
+ * either producer answers `db migrate` without being told where to look.
54
+ */
55
+ export declare const resolveMigrationsDir: (bundleDir: string, env?: Record<string, string | undefined>) => string;
56
+ export interface CommandOutput {
57
+ write(line: string): void;
58
+ }
59
+ export declare function runDbCommand(action: 'migrate' | 'status', db: StandaloneDb, out?: CommandOutput): Promise<void>;
60
+ /**
61
+ * Copy the SQLite database somewhere else, while the app may be running.
62
+ *
63
+ * `VACUUM INTO` rather than copying the file: a plain copy taken while another
64
+ * process is mid-write captures a torn page and a write-ahead log it has no
65
+ * copy of, which restores as a corrupt database and only says so later.
66
+ */
67
+ export declare function runBackupCommand(destination: string, db: StandaloneSqliteDb, out?: CommandOutput): Promise<void>;
68
+ /**
69
+ * Run whatever the argv asked for, and say whether the caller should serve.
70
+ *
71
+ * The database is passed already open, because the entry has to open it the one
72
+ * way the app does — a command that resolved its own connection could migrate a
73
+ * different database than the next `serve` reads.
74
+ */
75
+ export declare function runStandaloneCommand(command: StandaloneCommand, db: StandaloneDb | undefined, out?: CommandOutput): Promise<'serve' | 'done'>;