@iskra-bun/db-kit 0.1.0 → 0.3.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/src/cli.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * Requiere un archivo drizzle.config.ts en el directorio actual.
12
12
  */
13
13
 
14
+ import { drizzleKitCommand } from './migrations';
15
+
14
16
  const [command, ...rest] = process.argv.slice(2);
15
17
 
16
18
  const validCommands = ['generate', 'migrate', 'push', 'drop'];
@@ -26,12 +28,19 @@ Comandos:
26
28
  generate [nombre] Genera archivos de migración a partir del schema
27
29
  migrate Aplica migraciones pendientes
28
30
  push Empuja el schema directo a la DB (sin migración)
29
- drop Elimina todas las tablas
31
+ drop Elimina un archivo de migracion generado (no toca la DB)
30
32
  `);
31
33
  process.exit(command ? 1 : 0);
32
34
  }
33
35
 
34
- const args = ['bunx', 'drizzle-kit', command, ...rest];
36
+ // The project's own drizzle-kit, never one bunx downloads (see drizzleKitCommand).
37
+ let args: string[];
38
+ try {
39
+ args = drizzleKitCommand([command, ...rest]);
40
+ } catch (error) {
41
+ console.error((error as Error).message);
42
+ process.exit(1);
43
+ }
35
44
 
36
45
  console.log(`> ${args.join(' ')}`);
37
46
 
package/src/driver.ts CHANGED
@@ -1,17 +1,113 @@
1
- import { type App, type Driver, type AppConfig, DriverError } from '@iskra-bun/core';
2
- import { drizzle } from 'drizzle-orm/postgres-js';
3
- import { drizzle as drizzleMysql } from 'drizzle-orm/mysql2';
1
+ import { type App, type Driver, DriverError } from '@iskra-bun/core';
2
+ import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
3
+ import { drizzle as drizzleMysql, type MySql2Database } from 'drizzle-orm/mysql2';
4
+ import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
5
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql';
4
6
  import postgres from 'postgres';
5
7
  import mysql from 'mysql2/promise';
6
- import { ConnectionError } from './errors';
7
- import { MigrationHelper, mapDialect } from './migrations';
8
+ import { sql } from 'drizzle-orm';
9
+ import { AsyncLocalStorage } from 'node:async_hooks';
10
+ import { ConnectionError, MigrationError, QueryError } from './errors';
11
+ import { SENSITIVE_URL_PARAM } from './secrets';
8
12
 
9
- export class DbDriver implements Driver {
13
+ /**
14
+ * Observability callback invoked for every SQL statement Drizzle executes.
15
+ * Receives the rendered query and its bound parameters.
16
+ */
17
+ export type OnQueryHook = (query: string, params: unknown[]) => void;
18
+
19
+ /**
20
+ * The transaction handle passed to {@link DbDriver.transaction}. Drizzle types
21
+ * the transaction object per dialect, so — like {@link IskraDrizzleDb} — this is
22
+ * the union of the supported dialect databases for the same schema. Callers can
23
+ * narrow by dialect if they need dialect-specific transaction APIs.
24
+ */
25
+ export type IskraDrizzleTx<TSchema extends Record<string, unknown> = Record<string, never>> = IskraDrizzleDb<TSchema>;
26
+
27
+ /**
28
+ * The Drizzle database handle exposed by {@link DbDriver}, parameterized by the
29
+ * caller's schema. Because the concrete dialect is chosen at runtime, this is a
30
+ * union of the supported dialect databases — all four share the same
31
+ * `TSchema extends Record<string, unknown> = Record<string, never>` parameter,
32
+ * so passing a schema types `db.query.*` for opt-in callers while the default
33
+ * `Record<string, never>` reproduces the historical untyped behavior.
34
+ */
35
+ export type IskraDrizzleDb<TSchema extends Record<string, unknown> = Record<string, never>> =
36
+ | PostgresJsDatabase<TSchema>
37
+ | MySql2Database<TSchema>
38
+ | BunSQLiteDatabase<TSchema>
39
+ | LibSQLDatabase<TSchema>;
40
+
41
+ /**
42
+ * Redact the username, password and secret query parameters (`authToken`,
43
+ * `password`, `sslpassword`...) from a database URL so it is safe to log.
44
+ * Returns the scrubbed URL string, or undefined if parsing fails.
45
+ *
46
+ * e.g. postgres://user:pass@host:5432/db → postgres://***:***@host:5432/db
47
+ * libsql://db.turso.io?authToken=eyJ… → libsql://db.turso.io?authToken=***
48
+ */
49
+ export function scrubUrl(url: string): string | undefined {
50
+ try {
51
+ const parsed = new URL(url);
52
+ if (parsed.username) parsed.username = '***';
53
+ if (parsed.password) parsed.password = '***';
54
+ for (const key of [...new Set(parsed.searchParams.keys())]) {
55
+ if (SENSITIVE_URL_PARAM.test(key)) parsed.searchParams.set(key, '***');
56
+ }
57
+ return parsed.toString();
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The minimal teardown surface {@link DbDriver.stop} probes on the underlying
65
+ * client. postgres-js and mysql2 expose async `end()`; bun:sqlite and libsql
66
+ * expose synchronous `close()`. Typed as optional so either shape satisfies it.
67
+ */
68
+ interface DbClient {
69
+ end?(): Promise<void>;
70
+ close?(): void;
71
+ }
72
+
73
+ export class DbDriver<TSchema extends Record<string, unknown> = Record<string, never>> implements Driver {
10
74
  name = 'db';
11
- private client: any;
12
- public db: any;
75
+ private client: DbClient | undefined;
76
+ /** Serializes transactions on the single bun:sqlite connection. */
77
+ private sqliteTxQueue: Promise<unknown> = Promise.resolve();
78
+ private readonly inSqliteTx = new AsyncLocalStorage<true>();
79
+ public db: IskraDrizzleDb<TSchema> | undefined;
13
80
 
14
81
  private app: App | undefined;
82
+ private onQuery: OnQueryHook | undefined;
83
+
84
+ /**
85
+ * Register an observability callback that receives every SQL statement (and
86
+ * its bound params) Drizzle executes. Must be called before {@link start},
87
+ * since Drizzle's logger is wired at connection time. A throwing callback is
88
+ * swallowed so observability never breaks a real query.
89
+ */
90
+ setOnQuery(onQuery: OnQueryHook): void {
91
+ this.onQuery = onQuery;
92
+ }
93
+
94
+ /**
95
+ * Build the Drizzle `logger` option that forwards to {@link onQuery} when a
96
+ * hook is registered, or `undefined` to leave Drizzle's default logging off.
97
+ */
98
+ private buildLogger(): { logQuery(query: string, params: unknown[]): void } | undefined {
99
+ const hook = this.onQuery;
100
+ if (!hook) return undefined;
101
+ return {
102
+ logQuery: (query: string, params: unknown[]) => {
103
+ try {
104
+ hook(query, params);
105
+ } catch {
106
+ // Observability must never break the underlying query.
107
+ }
108
+ },
109
+ };
110
+ }
15
111
 
16
112
  async init(app: App) {
17
113
  this.app = app;
@@ -28,28 +124,38 @@ export class DbDriver implements Driver {
28
124
 
29
125
  this.app!.logger.info(`Initializing DB driver: ${config.driver}`);
30
126
 
127
+ const logger = this.buildLogger();
128
+
31
129
  try {
32
130
  switch (config.driver) {
33
- case 'postgres':
34
- this.client = postgres(config.url);
35
- this.db = drizzle(this.client);
131
+ case 'postgres': {
132
+ const client = postgres(config.url);
133
+ this.client = client;
134
+ this.db = drizzle<TSchema>(client, { logger });
36
135
  break;
37
- case 'mysql':
38
- this.client = await mysql.createConnection(config.url);
39
- this.db = drizzleMysql(this.client);
136
+ }
137
+ case 'mysql': {
138
+ const client = mysql.createPool(config.url);
139
+ this.client = client;
140
+ // Name the client type: with only TSchema given, drizzle's
141
+ // TClient defaults to mysql2's callback Pool, not this promise Pool.
142
+ this.db = drizzleMysql<TSchema, typeof client>(client, { logger });
40
143
  break;
144
+ }
41
145
  case 'sqlite': {
42
- const { Database } = await import("bun:sqlite");
43
- const { drizzle: drizzleSqlite } = await import("drizzle-orm/bun-sqlite");
44
- this.client = new Database(config.url);
45
- this.db = drizzleSqlite(this.client);
146
+ const { Database } = await import('bun:sqlite');
147
+ const { drizzle: drizzleSqlite } = await import('drizzle-orm/bun-sqlite');
148
+ const client = new Database(config.url);
149
+ this.client = client;
150
+ this.db = drizzleSqlite<TSchema>(client, { logger });
46
151
  break;
47
152
  }
48
153
  case 'libsql': {
49
154
  const { createClient } = await import('@libsql/client');
50
155
  const { drizzle: drizzleLibsql } = await import('drizzle-orm/libsql');
51
- this.client = createClient({ url: config.url, authToken: config.authToken });
52
- this.db = drizzleLibsql(this.client);
156
+ const client = createClient({ url: config.url, authToken: config.authToken });
157
+ this.client = client;
158
+ this.db = drizzleLibsql<TSchema>(client, { logger });
53
159
  break;
54
160
  }
55
161
  default:
@@ -58,51 +164,201 @@ export class DbDriver implements Driver {
58
164
  context: { driver: config.driver },
59
165
  });
60
166
  }
167
+ // postgres-js and mysql2 pools connect lazily: without a round-trip
168
+ // a wrong host or password only surfaced on the first query.
169
+ await this.roundTrip();
61
170
  this.app!.logger.info('DB connected successfully.');
62
171
  } catch (error) {
63
172
  if (error instanceof DriverError) throw error;
173
+ await this.stop();
64
174
  this.app!.logger.error({ error }, 'Failed to connect to DB');
175
+ const safeUrl = scrubUrl(config.url);
65
176
  throw new ConnectionError('Failed to connect to DB', {
66
177
  cause: error instanceof Error ? error : new Error(String(error)),
67
- context: { driver: config.driver, url: config.url },
178
+ context: {
179
+ driver: config.driver,
180
+ ...(safeUrl !== undefined ? { url: safeUrl } : {}),
181
+ },
68
182
  });
69
183
  }
70
184
  }
71
185
 
72
186
  /**
73
- * Ejecuta migraciones pendientes usando Drizzle Kit.
187
+ * Applies the pending migrations in `migrationsDir` (generated with
188
+ * `drizzle-kit generate`) over the live connection, using Drizzle's
189
+ * migrator for the configured dialect. Requires `start()`.
190
+ *
191
+ * It used to shell out to `drizzle-kit migrate`, which ignored both
192
+ * arguments and failed without a drizzle.config.ts. `schemaPath` is kept for
193
+ * compatibility; applying migrations does not need the schema.
74
194
  */
75
- async runMigrations(schemaPath: string, migrationsDir: string = './drizzle'): Promise<void> {
76
- if (!this.app?.config.db) {
195
+ async runMigrations(_schemaPath?: string, migrationsDir: string = './drizzle'): Promise<void> {
196
+ const config = this.app?.config.db;
197
+ if (!config) {
77
198
  throw new DriverError('Cannot run migrations: no DB configuration found', {
78
199
  code: 'DRIVER_START_FAILED',
79
200
  });
80
201
  }
202
+ if (!this.db) {
203
+ throw new DriverError('Cannot run migrations: DB is not started', {
204
+ code: 'DRIVER_START_FAILED',
205
+ context: { driver: config.driver },
206
+ });
207
+ }
81
208
 
82
- const config = this.app.config.db;
83
- const helper = new MigrationHelper(
84
- {
85
- dialect: mapDialect(config.driver),
86
- dbUrl: config.url,
87
- schemaPath,
88
- migrationsDir,
89
- },
90
- this.app,
91
- );
209
+ const options = { migrationsFolder: migrationsDir };
210
+ const db = this.db as never;
211
+ try {
212
+ switch (config.driver) {
213
+ case 'postgres':
214
+ await (await import('drizzle-orm/postgres-js/migrator')).migrate(db, options);
215
+ break;
216
+ case 'mysql':
217
+ await (await import('drizzle-orm/mysql2/migrator')).migrate(db, options);
218
+ break;
219
+ case 'sqlite':
220
+ (await import('drizzle-orm/bun-sqlite/migrator')).migrate(db, options);
221
+ break;
222
+ case 'libsql':
223
+ await (await import('drizzle-orm/libsql/migrator')).migrate(db, options);
224
+ break;
225
+ }
226
+ this.app!.logger.info({ migrationsDir }, 'Migrations applied');
227
+ } catch (error) {
228
+ throw new MigrationError('Failed to apply migrations', {
229
+ cause: error instanceof Error ? error : new Error(String(error)),
230
+ context: { driver: config.driver, migrationsDir },
231
+ });
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Run `fn` inside a database transaction, delegating to Drizzle's
237
+ * `db.transaction`. Callers receive the transaction-scoped db handle instead
238
+ * of reaching into the raw `db`. The dialect union means `tx` is typed as
239
+ * {@link IskraDrizzleTx}; narrow by dialect if you need dialect-specific APIs.
240
+ * Failures are wrapped in {@link QueryError}; the transaction is rolled back.
241
+ *
242
+ * With `sqlite`, transactions run one at a time on the single connection,
243
+ * and a query made outside `tx` while one is open is part of it. A nested
244
+ * `transaction()` call is rejected: it would wait for itself.
245
+ */
246
+ async transaction<R>(fn: (tx: IskraDrizzleTx<TSchema>) => Promise<R>): Promise<R> {
247
+ if (!this.db) {
248
+ throw new QueryError('Cannot run transaction: DB is not started', {
249
+ context: { driver: this.app?.config.db?.driver },
250
+ });
251
+ }
252
+ try {
253
+ if (this.app?.config.db?.driver === 'sqlite') return await this.sqliteTransaction(fn);
254
+ // The dialect-specific `transaction` overloads do not unify across the
255
+ // union, so we route through the runtime method with a faithful cast
256
+ // of the public handle types.
257
+ return await (
258
+ this.db as IskraDrizzleDb<TSchema> & {
259
+ transaction(cb: (tx: IskraDrizzleTx<TSchema>) => Promise<R>): Promise<R>;
260
+ }
261
+ ).transaction((tx) => fn(tx));
262
+ } catch (error) {
263
+ if (error instanceof QueryError) throw error;
264
+ throw new QueryError('Transaction failed', {
265
+ cause: error instanceof Error ? error : new Error(String(error)),
266
+ context: { driver: this.app?.config.db?.driver },
267
+ });
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Drizzle's bun-sqlite `transaction()` is synchronous: it commits as soon
273
+ * as the callback returns its promise, so an async callback that threw
274
+ * afterwards never rolled back. The transaction is opened and closed here
275
+ * around the awaited callback instead.
276
+ */
277
+ private async sqliteTransaction<R>(fn: (tx: IskraDrizzleTx<TSchema>) => Promise<R>): Promise<R> {
278
+ if (this.inSqliteTx.getStore()) {
279
+ throw new QueryError('Nested transaction() calls are not supported with sqlite', {
280
+ context: { driver: 'sqlite' },
281
+ });
282
+ }
283
+ const db = this.db as BunSQLiteDatabase<TSchema>;
284
+ const run = () =>
285
+ this.inSqliteTx.run(true, async () => {
286
+ // A transaction handle (tx.rollback(), tx.query.*) bound to the
287
+ // connection; the empty native transaction it comes from is done.
288
+ const tx = db.transaction((t) => t);
289
+ db.run(sql`begin`);
290
+ try {
291
+ const result = await fn(tx as unknown as IskraDrizzleTx<TSchema>);
292
+ db.run(sql`commit`);
293
+ return result;
294
+ } catch (error) {
295
+ db.run(sql`rollback`);
296
+ throw error;
297
+ }
298
+ });
299
+ const result = this.sqliteTxQueue.then(run, run);
300
+ this.sqliteTxQueue = result.catch(() => undefined);
301
+ return result;
302
+ }
92
303
 
93
- await helper.migrate();
304
+ /**
305
+ * Liveness probe for readiness checks (e.g. web-kit's addReadinessCheck /
306
+ * k8s readiness). Runs a trivial `SELECT 1` against the active dialect and
307
+ * resolves `true` on success or `false` on any failure — it never rejects.
308
+ */
309
+ async ping(): Promise<boolean> {
310
+ if (!this.db) return false;
311
+ try {
312
+ await this.roundTrip();
313
+ return true;
314
+ } catch {
315
+ return false;
316
+ }
317
+ }
318
+
319
+ /** `SELECT 1` on the active connection; throws on failure. */
320
+ private async roundTrip(): Promise<void> {
321
+ // bun-sqlite exposes the synchronous `.run()`; postgres-js, mysql2 and
322
+ // libsql expose the async `.execute()`. Prefer whichever exists.
323
+ const handle = this.db as {
324
+ run?(query: unknown): unknown;
325
+ execute?(query: unknown): Promise<unknown>;
326
+ };
327
+ if (typeof handle.run === 'function') {
328
+ await handle.run(sql`SELECT 1`);
329
+ } else if (typeof handle.execute === 'function') {
330
+ await handle.execute(sql`SELECT 1`);
331
+ } else {
332
+ throw new Error('DB handle exposes neither run() nor execute()');
333
+ }
94
334
  }
95
335
 
96
336
  async stop() {
97
- if (this.client) {
98
- // Close connections based on client type
99
- if (this.client.end) { // Postgres usage with postgres.js usually handles itself or has end.
100
- // mysql2 has end()
101
- await this.client.end();
102
- } else if (this.client.close) { // bun:sqlite / libsql
103
- this.client.close();
337
+ const client = this.client;
338
+ try {
339
+ // postgres-js / mysql2 expose async end(); bun:sqlite / libsql expose
340
+ // synchronous close(). Probe for whichever this client provides.
341
+ if (client?.end) {
342
+ await client.end();
343
+ } else if (client?.close) {
344
+ client.close();
104
345
  }
105
- // postgres.js handles cleanup usually but explicit close might be needed depending on version/usage
346
+ } catch (error) {
347
+ // A throwing teardown must never abort the orderly shutdown of other
348
+ // drivers; log and continue so the handles below are still cleared.
349
+ this.app?.logger.error({ error }, 'Failed to close DB connection cleanly');
350
+ } finally {
351
+ // Null the handles so a post-stop ping()/transaction() hits the
352
+ // not-started guard instead of an already-closed connection.
353
+ this.client = undefined;
354
+ this.db = undefined;
106
355
  }
107
356
  }
108
357
  }
358
+
359
+ // `app.context.get('db')` is the DbDriver registered on the app.
360
+ declare module '@iskra-bun/core' {
361
+ interface AppContextRegistry {
362
+ db: DbDriver;
363
+ }
364
+ }
@@ -1,5 +1,3 @@
1
- import { defineConfig } from 'drizzle-kit';
2
-
3
1
  export interface DrizzleConfigOptions {
4
2
  /** Dialecto: 'postgresql', 'mysql', 'sqlite' */
5
3
  dialect: 'postgresql' | 'mysql' | 'sqlite';
@@ -11,6 +9,19 @@ export interface DrizzleConfigOptions {
11
9
  migrationsDir?: string;
12
10
  }
13
11
 
12
+ /**
13
+ * La configuración que lee drizzle-kit (el `export default` de `drizzle.config.ts`).
14
+ * Se declara aquí para no importar drizzle-kit: es una herramienta de desarrollo,
15
+ * y el entry point de db-kit (que usa Bun vía la condición `bun`) fallaba al
16
+ * importarse donde no estaba instalada, por ejemplo con `--production`.
17
+ */
18
+ export interface DrizzleKitConfig {
19
+ dialect: DrizzleConfigOptions['dialect'];
20
+ schema: string;
21
+ out: string;
22
+ dbCredentials: { url: string };
23
+ }
24
+
14
25
  /**
15
26
  * Crea una configuración de drizzle-kit reutilizable.
16
27
  *
@@ -26,13 +37,13 @@ export interface DrizzleConfigOptions {
26
37
  * });
27
38
  * ```
28
39
  */
29
- export function createDrizzleConfig(options: DrizzleConfigOptions) {
30
- return defineConfig({
40
+ export function createDrizzleConfig(options: DrizzleConfigOptions): DrizzleKitConfig {
41
+ return {
31
42
  dialect: options.dialect,
32
43
  schema: options.schemaPath,
33
44
  out: options.migrationsDir || './drizzle',
34
45
  dbCredentials: {
35
46
  url: options.dbUrl,
36
47
  },
37
- });
48
+ };
38
49
  }
package/src/errors.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { IskraError, ErrorCodes, type ErrorCode } from '@iskra-bun/core';
1
+ import { IskraError, ErrorCodes } from '@iskra-bun/core';
2
2
 
3
3
  // ─── Connection Error ────────────────────────────────────────────────────────
4
4
 
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export * from './driver';
2
2
  export * from './errors';
3
3
  export * from './migrations';
4
- export { createDrizzleConfig, type DrizzleConfigOptions } from './drizzle.config.template';
4
+ export { createDrizzleConfig, type DrizzleConfigOptions, type DrizzleKitConfig } from './drizzle.config.template';
package/src/migrations.ts CHANGED
@@ -1,5 +1,49 @@
1
1
  import { type App } from '@iskra-bun/core';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
2
4
  import { MigrationError } from './errors';
5
+ import { SENSITIVE_URL_PARAM } from './secrets';
6
+
7
+ /**
8
+ * The drizzle-kit command to run `args` with, from the project's own install:
9
+ * `node_modules/.bin` in `cwd` or a parent (workspaces hoist it). drizzle-kit
10
+ * is a devDependency, and `bunx drizzle-kit` downloaded its latest release
11
+ * from npm where it was missing (a production install) and ran it with
12
+ * DATABASE_URL in its environment. `--no-install` keeps bunx from doing so.
13
+ */
14
+ export function drizzleKitCommand(args: string[], cwd: string = process.cwd()): string[] {
15
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
16
+ const bin = path.join(dir, 'node_modules', '.bin', 'drizzle-kit');
17
+ if (['', '.exe', '.cmd'].some((ext) => existsSync(bin + ext))) {
18
+ return ['bunx', '--no-install', 'drizzle-kit', ...args];
19
+ }
20
+ if (path.dirname(dir) === dir) break;
21
+ }
22
+ throw new MigrationError(
23
+ 'drizzle-kit is not installed in this project (it is never downloaded at run time): ' +
24
+ 'add it with `bun add -d drizzle-kit`',
25
+ { context: { cwd } },
26
+ );
27
+ }
28
+
29
+ /**
30
+ * Redacta credenciales `//user:pass@host` y parámetros secretos (`authToken`,
31
+ * `password`...) embebidos en texto arbitrario (p. ej. el stderr de drizzle-kit,
32
+ * que suele imprimir la cadena de conexión completa al fallar). A diferencia de
33
+ * `scrubUrl`, opera sobre texto libre y no requiere que el contenido sea una
34
+ * URL parseable, dejando intacto el resto del diagnóstico. Una contraseña con
35
+ * `@` o `/` sin codificar también se redacta: se toma hasta el último `@`.
36
+ *
37
+ * e.g. "... postgres://user:pass@host:5432/db" → "... postgres://***:***@host:5432/db"
38
+ */
39
+ export function scrubCredentials(text: string): string {
40
+ return text
41
+ .replace(/(\/\/)(?:[^\s'"`@]*@)+/g, '$1***:***@')
42
+ .replace(
43
+ new RegExp(`([?&][^=\\s&'"\`]*(?:${SENSITIVE_URL_PARAM.source})[^=\\s&'"\`]*=)[^&\\s'"\`]*`, 'gi'),
44
+ '$1***',
45
+ );
46
+ }
3
47
 
4
48
  export interface MigrationConfig {
5
49
  /** Dialecto de la base de datos */
@@ -10,11 +54,14 @@ export interface MigrationConfig {
10
54
  schemaPath: string;
11
55
  /** Directorio donde se generan las migraciones (ej: './drizzle') */
12
56
  migrationsDir: string;
57
+ /** Ruta opcional a un drizzle.config.ts; cuando se define se pasa como --config. */
58
+ configPath?: string;
13
59
  }
14
60
 
15
61
  /**
16
62
  * Helper para ejecutar migraciones de Drizzle Kit.
17
- * Usa `bunx drizzle-kit` como subproceso para generar y aplicar migraciones.
63
+ * Usa el drizzle-kit instalado en el proyecto (`bunx --no-install drizzle-kit`)
64
+ * como subproceso para generar y aplicar migraciones; nunca lo descarga.
18
65
  */
19
66
  export class MigrationHelper {
20
67
  private config: MigrationConfig;
@@ -27,46 +74,66 @@ export class MigrationHelper {
27
74
 
28
75
  /**
29
76
  * Genera archivos de migración basados en los cambios del schema.
77
+ * drizzle-kit generate soporta --schema y --out, así que ambos se reenvían
78
+ * desde la config (antes se ignoraban silenciosamente).
30
79
  */
31
80
  async generate(name?: string): Promise<void> {
32
- const args = ['drizzle-kit', 'generate'];
81
+ const args = ['generate'];
82
+ if (this.config.schemaPath) args.push('--schema', this.config.schemaPath);
83
+ if (this.config.migrationsDir) args.push('--out', this.config.migrationsDir);
33
84
  if (name) args.push('--name', name);
85
+ if (this.config.configPath) args.push('--config', this.config.configPath);
34
86
  await this.exec(args, 'generate');
35
87
  }
36
88
 
37
89
  /**
38
90
  * Aplica las migraciones pendientes a la base de datos.
91
+ * `migrate` sólo acepta --config; schema y out no son flags válidos en este
92
+ * comando, por eso únicamente reenviamos configPath cuando está presente.
39
93
  */
40
94
  async migrate(): Promise<void> {
41
- await this.exec(['drizzle-kit', 'migrate'], 'migrate');
95
+ const args = ['migrate'];
96
+ if (this.config.configPath) args.push('--config', this.config.configPath);
97
+ await this.exec(args, 'migrate');
42
98
  }
43
99
 
44
100
  /**
45
101
  * Empuja el schema directamente a la base de datos (sin generar archivos de migración).
46
- * Útil para desarrollo rápido.
102
+ * Útil para desarrollo rápido. `push` acepta --schema pero no --out.
47
103
  */
48
104
  async push(): Promise<void> {
49
- await this.exec(['drizzle-kit', 'push'], 'push');
105
+ const args = ['push'];
106
+ if (this.config.schemaPath) args.push('--schema', this.config.schemaPath);
107
+ if (this.config.configPath) args.push('--config', this.config.configPath);
108
+ await this.exec(args, 'push');
50
109
  }
51
110
 
52
111
  /**
53
- * Elimina todas las tablas de la base de datos.
112
+ * Elimina un archivo de migración ya generado (`drizzle-kit drop`, interactivo).
113
+ * No borra tablas ni datos de la base.
114
+ * `drop` acepta --out (dónde viven las migraciones) pero no --schema.
54
115
  */
55
116
  async drop(): Promise<void> {
56
- await this.exec(['drizzle-kit', 'drop'], 'drop');
117
+ const args = ['drop'];
118
+ if (this.config.migrationsDir) args.push('--out', this.config.migrationsDir);
119
+ if (this.config.configPath) args.push('--config', this.config.configPath);
120
+ await this.exec(args, 'drop');
57
121
  }
58
122
 
59
123
  private async exec(args: string[], operation: string): Promise<void> {
60
124
  const env: Record<string, string> = {
61
- ...process.env as Record<string, string>,
125
+ ...(process.env as Record<string, string>),
62
126
  DATABASE_URL: this.config.dbUrl,
63
127
  };
128
+ const cwd = process.cwd();
129
+ // Before anything runs with DATABASE_URL: never a downloaded drizzle-kit.
130
+ const command = drizzleKitCommand(args, cwd);
64
131
 
65
132
  this.app?.logger.info(`Running migration: ${operation}`);
66
133
 
67
134
  try {
68
- const proc = Bun.spawn(['bunx', ...args], {
69
- cwd: process.cwd(),
135
+ const proc = Bun.spawn(command, {
136
+ cwd,
70
137
  env,
71
138
  stdout: 'pipe',
72
139
  stderr: 'pipe',
@@ -80,7 +147,7 @@ export class MigrationHelper {
80
147
 
81
148
  if (exitCode !== 0) {
82
149
  throw new MigrationError(`Migration ${operation} failed with exit code ${exitCode}`, {
83
- context: { operation, exitCode, stderr: stderr.trim() },
150
+ context: { operation, exitCode, stderr: scrubCredentials(stderr.trim()) },
84
151
  });
85
152
  }
86
153
 
package/src/secrets.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Query parameters that carry secrets, such as libsql's `authToken` or `password`. */
2
+ export const SENSITIVE_URL_PARAM = /pass|token|secret|key|auth|credential|signature/i;